feat: community publish modals -- focus traps, aria-live, plan + outcome forms
This commit is contained in:
parent
730445e479
commit
9603d421b6
4 changed files with 723 additions and 0 deletions
|
|
@ -15,6 +15,17 @@
|
||||||
>{{ f.label }}</button>
|
>{{ f.label }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Share a plan action row -->
|
||||||
|
<div class="action-row flex-between mb-sm">
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary btn-sm share-plan-btn"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
@click="showPublishPlan = true"
|
||||||
|
>
|
||||||
|
Share a plan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Loading skeletons -->
|
<!-- Loading skeletons -->
|
||||||
<div
|
<div
|
||||||
v-if="store.loading"
|
v-if="store.loading"
|
||||||
|
|
@ -84,6 +95,14 @@
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
|
<!-- Publish plan modal -->
|
||||||
|
<PublishPlanModal
|
||||||
|
v-if="showPublishPlan"
|
||||||
|
:plan="null"
|
||||||
|
@close="showPublishPlan = false"
|
||||||
|
@published="onPlanPublished"
|
||||||
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -91,6 +110,7 @@
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useCommunityStore } from '../stores/community'
|
import { useCommunityStore } from '../stores/community'
|
||||||
import CommunityPostCard from './CommunityPostCard.vue'
|
import CommunityPostCard from './CommunityPostCard.vue'
|
||||||
|
import PublishPlanModal from './PublishPlanModal.vue'
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'plan-forked': [payload: { plan_id: number; week_start: string }]
|
'plan-forked': [payload: { plan_id: number; week_start: string }]
|
||||||
|
|
@ -99,6 +119,7 @@ const emit = defineEmits<{
|
||||||
const store = useCommunityStore()
|
const store = useCommunityStore()
|
||||||
|
|
||||||
const activeFilter = ref('all')
|
const activeFilter = ref('all')
|
||||||
|
const showPublishPlan = ref(false)
|
||||||
|
|
||||||
const filters = [
|
const filters = [
|
||||||
{ id: 'all', label: 'All' },
|
{ id: 'all', label: 'All' },
|
||||||
|
|
@ -160,6 +181,11 @@ async function handleFork(slug: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onPlanPublished(_payload: { slug: string }) {
|
||||||
|
showPublishPlan.value = false
|
||||||
|
store.fetchPosts(activeFilter.value === 'all' ? undefined : activeFilter.value)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (store.posts.length === 0) {
|
if (store.posts.length === 0) {
|
||||||
await store.fetchPosts()
|
await store.fetchPosts()
|
||||||
|
|
@ -182,6 +208,14 @@ onMounted(async () => {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-row {
|
||||||
|
padding: var(--spacing-xs) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-plan-btn {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
/* Loading skeletons */
|
/* Loading skeletons */
|
||||||
.skeleton-card {
|
.skeleton-card {
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
|
|
|
||||||
363
frontend/src/components/PublishOutcomeModal.vue
Normal file
363
frontend/src/components/PublishOutcomeModal.vue
Normal file
|
|
@ -0,0 +1,363 @@
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
class="modal-overlay"
|
||||||
|
@click.self="$emit('close')"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref="dialogRef"
|
||||||
|
class="modal-panel card"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="publish-outcome-title"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex-between mb-md">
|
||||||
|
<h2 id="publish-outcome-title" class="section-title">
|
||||||
|
Share a recipe story
|
||||||
|
<span v-if="recipeName" class="recipe-name-hint text-sm text-muted">
|
||||||
|
-- {{ recipeName }}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
class="btn-close"
|
||||||
|
aria-label="Close"
|
||||||
|
@click="$emit('close')"
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Post type selector -->
|
||||||
|
<div class="form-group">
|
||||||
|
<fieldset class="type-fieldset">
|
||||||
|
<legend class="form-label">What kind of story is this?</legend>
|
||||||
|
<div class="type-toggle flex gap-sm">
|
||||||
|
<button
|
||||||
|
:class="['btn', 'type-btn', postType === 'recipe_success' ? 'type-btn-active' : 'btn-secondary']"
|
||||||
|
:aria-pressed="postType === 'recipe_success'"
|
||||||
|
@click="postType = 'recipe_success'"
|
||||||
|
>
|
||||||
|
Success
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="['btn', 'type-btn', postType === 'recipe_blooper' ? 'type-btn-active type-btn-blooper' : 'btn-secondary']"
|
||||||
|
:aria-pressed="postType === 'recipe_blooper'"
|
||||||
|
@click="postType = 'recipe_blooper'"
|
||||||
|
>
|
||||||
|
Blooper
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Title field -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="outcome-title">
|
||||||
|
Title <span class="required-mark" aria-hidden="true">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="outcome-title"
|
||||||
|
ref="firstFocusRef"
|
||||||
|
v-model="title"
|
||||||
|
class="form-input"
|
||||||
|
type="text"
|
||||||
|
maxlength="200"
|
||||||
|
placeholder="e.g. Perfect crust on the first try"
|
||||||
|
autocomplete="off"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span class="form-hint char-counter" aria-live="polite" aria-atomic="true">
|
||||||
|
{{ title.length }}/200
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Outcome notes field -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="outcome-notes">
|
||||||
|
What happened? <span class="optional-mark">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="outcome-notes"
|
||||||
|
v-model="outcomeNotes"
|
||||||
|
class="form-input form-textarea"
|
||||||
|
maxlength="2000"
|
||||||
|
rows="4"
|
||||||
|
placeholder="Describe what you tried, what worked, or what went sideways."
|
||||||
|
/>
|
||||||
|
<span class="form-hint char-counter" aria-live="polite" aria-atomic="true">
|
||||||
|
{{ outcomeNotes.length }}/2000
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pseudonym field -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="outcome-pseudonym">
|
||||||
|
Community name <span class="optional-mark">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="outcome-pseudonym"
|
||||||
|
v-model="pseudonymName"
|
||||||
|
class="form-input"
|
||||||
|
type="text"
|
||||||
|
maxlength="40"
|
||||||
|
placeholder="Leave blank to use your existing handle"
|
||||||
|
autocomplete="nickname"
|
||||||
|
/>
|
||||||
|
<span class="form-hint">How you appear on posts -- not your real name or email.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submission feedback (aria-live region, always rendered) -->
|
||||||
|
<div
|
||||||
|
class="feedback-region"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
>
|
||||||
|
<p v-if="submitError" class="feedback-error text-sm" role="alert">{{ submitError }}</p>
|
||||||
|
<p v-if="submitSuccess" class="feedback-success text-sm">{{ submitSuccess }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer actions -->
|
||||||
|
<div class="modal-footer flex gap-sm">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="submitting || !title.trim()"
|
||||||
|
:aria-busy="submitting"
|
||||||
|
@click="onSubmit"
|
||||||
|
>
|
||||||
|
<span v-if="submitting" class="spinner spinner-sm" aria-hidden="true"></span>
|
||||||
|
{{ submitting ? 'Publishing...' : 'Publish' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" @click="$emit('close')">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import { useCommunityStore } from '../stores/community'
|
||||||
|
import type { PublishPayload } from '../stores/community'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
recipeId: number | null
|
||||||
|
recipeName: string | null
|
||||||
|
visible?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
published: [payload: { slug: string }]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const store = useCommunityStore()
|
||||||
|
|
||||||
|
const postType = ref<'recipe_success' | 'recipe_blooper'>('recipe_success')
|
||||||
|
const title = ref('')
|
||||||
|
const outcomeNotes = ref('')
|
||||||
|
const pseudonymName = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const submitError = ref<string | null>(null)
|
||||||
|
const submitSuccess = ref<string | null>(null)
|
||||||
|
|
||||||
|
const dialogRef = ref<HTMLElement | null>(null)
|
||||||
|
const firstFocusRef = ref<HTMLInputElement | null>(null)
|
||||||
|
let previousFocus: HTMLElement | null = null
|
||||||
|
|
||||||
|
function getFocusables(): HTMLElement[] {
|
||||||
|
if (!dialogRef.value) return []
|
||||||
|
return Array.from(
|
||||||
|
dialogRef.value.querySelectorAll<HTMLElement>(
|
||||||
|
'button:not([disabled]), [href], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
emit('close')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key !== 'Tab') return
|
||||||
|
|
||||||
|
const focusables = getFocusables()
|
||||||
|
if (focusables.length === 0) return
|
||||||
|
const first = focusables[0]!
|
||||||
|
const last = focusables[focusables.length - 1]!
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === first) {
|
||||||
|
e.preventDefault()
|
||||||
|
last.focus()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === last) {
|
||||||
|
e.preventDefault()
|
||||||
|
first.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
previousFocus = document.activeElement as HTMLElement
|
||||||
|
document.addEventListener('keydown', handleKeydown)
|
||||||
|
nextTick(() => {
|
||||||
|
(firstFocusRef.value ?? dialogRef.value)?.focus()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
|
previousFocus?.focus()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
submitError.value = null
|
||||||
|
submitSuccess.value = null
|
||||||
|
|
||||||
|
if (!title.value.trim()) return
|
||||||
|
|
||||||
|
const payload: PublishPayload = {
|
||||||
|
post_type: postType.value,
|
||||||
|
title: title.value.trim(),
|
||||||
|
}
|
||||||
|
if (outcomeNotes.value.trim()) payload.outcome_notes = outcomeNotes.value.trim()
|
||||||
|
if (pseudonymName.value.trim()) payload.pseudonym_name = pseudonymName.value.trim()
|
||||||
|
if (props.recipeId != null) payload.recipe_id = props.recipeId
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const result = await store.publishPost(payload)
|
||||||
|
submitSuccess.value = 'Your story has been posted.'
|
||||||
|
nextTick(() => {
|
||||||
|
emit('published', { slug: result.slug })
|
||||||
|
})
|
||||||
|
} catch (err: unknown) {
|
||||||
|
submitError.value = err instanceof Error ? err.message : 'Could not publish. Please try again.'
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 400;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-name-hint {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required-mark {
|
||||||
|
color: var(--color-error);
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optional-mark {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-fieldset {
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-toggle {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-btn {
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-btn-active {
|
||||||
|
background: var(--color-success);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--color-success);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-btn-active.type-btn-blooper {
|
||||||
|
background: var(--color-warning);
|
||||||
|
border-color: var(--color-warning);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-counter {
|
||||||
|
text-align: right;
|
||||||
|
display: block;
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-region {
|
||||||
|
min-height: 1.4rem;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-error {
|
||||||
|
color: var(--color-error);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-success {
|
||||||
|
color: var(--color-success);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-top: var(--spacing-md);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.modal-panel {
|
||||||
|
max-height: 95vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
306
frontend/src/components/PublishPlanModal.vue
Normal file
306
frontend/src/components/PublishPlanModal.vue
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
class="modal-overlay"
|
||||||
|
@click.self="$emit('close')"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref="dialogRef"
|
||||||
|
class="modal-panel card"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="publish-plan-title"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex-between mb-md">
|
||||||
|
<h2 id="publish-plan-title" class="section-title">Share this week's plan</h2>
|
||||||
|
<button
|
||||||
|
class="btn-close"
|
||||||
|
aria-label="Close"
|
||||||
|
@click="$emit('close')"
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Title field -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="plan-pub-title">
|
||||||
|
Title <span class="required-mark" aria-hidden="true">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="plan-pub-title"
|
||||||
|
ref="firstFocusRef"
|
||||||
|
v-model="title"
|
||||||
|
class="form-input"
|
||||||
|
type="text"
|
||||||
|
maxlength="200"
|
||||||
|
placeholder="e.g. Mediterranean Week"
|
||||||
|
autocomplete="off"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span class="form-hint char-counter" aria-live="polite" aria-atomic="true">
|
||||||
|
{{ title.length }}/200
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description field -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="plan-pub-desc">
|
||||||
|
Description <span class="optional-mark">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="plan-pub-desc"
|
||||||
|
v-model="description"
|
||||||
|
class="form-input form-textarea"
|
||||||
|
maxlength="2000"
|
||||||
|
rows="3"
|
||||||
|
placeholder="What makes this week worth sharing?"
|
||||||
|
/>
|
||||||
|
<span class="form-hint char-counter" aria-live="polite" aria-atomic="true">
|
||||||
|
{{ description.length }}/2000
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pseudonym field -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="plan-pub-pseudonym">
|
||||||
|
Community name <span class="optional-mark">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="plan-pub-pseudonym"
|
||||||
|
v-model="pseudonymName"
|
||||||
|
class="form-input"
|
||||||
|
type="text"
|
||||||
|
maxlength="40"
|
||||||
|
placeholder="Leave blank to use your existing handle"
|
||||||
|
autocomplete="nickname"
|
||||||
|
/>
|
||||||
|
<span class="form-hint">How you appear on posts -- not your real name or email.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submission feedback (aria-live region, always rendered) -->
|
||||||
|
<div
|
||||||
|
class="feedback-region"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
>
|
||||||
|
<p v-if="submitError" class="feedback-error text-sm" role="alert">{{ submitError }}</p>
|
||||||
|
<p v-if="submitSuccess" class="feedback-success text-sm">{{ submitSuccess }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer actions -->
|
||||||
|
<div class="modal-footer flex gap-sm">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="submitting || !title.trim()"
|
||||||
|
:aria-busy="submitting"
|
||||||
|
@click="onSubmit"
|
||||||
|
>
|
||||||
|
<span v-if="submitting" class="spinner spinner-sm" aria-hidden="true"></span>
|
||||||
|
{{ submitting ? 'Publishing...' : 'Publish' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" @click="$emit('close')">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import { useCommunityStore } from '../stores/community'
|
||||||
|
import type { PublishPayload } from '../stores/community'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
plan?: {
|
||||||
|
id: number
|
||||||
|
week_start: string
|
||||||
|
slots: Array<{ day: string; meal_type: string; recipe_id: number; recipe_name: string }>
|
||||||
|
} | null
|
||||||
|
visible?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
published: [payload: { slug: string }]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const store = useCommunityStore()
|
||||||
|
|
||||||
|
const title = ref('')
|
||||||
|
const description = ref('')
|
||||||
|
const pseudonymName = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const submitError = ref<string | null>(null)
|
||||||
|
const submitSuccess = ref<string | null>(null)
|
||||||
|
|
||||||
|
const dialogRef = ref<HTMLElement | null>(null)
|
||||||
|
const firstFocusRef = ref<HTMLInputElement | null>(null)
|
||||||
|
let previousFocus: HTMLElement | null = null
|
||||||
|
|
||||||
|
function getFocusables(): HTMLElement[] {
|
||||||
|
if (!dialogRef.value) return []
|
||||||
|
return Array.from(
|
||||||
|
dialogRef.value.querySelectorAll<HTMLElement>(
|
||||||
|
'button:not([disabled]), [href], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
emit('close')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key !== 'Tab') return
|
||||||
|
|
||||||
|
const focusables = getFocusables()
|
||||||
|
if (focusables.length === 0) return
|
||||||
|
const first = focusables[0]!
|
||||||
|
const last = focusables[focusables.length - 1]!
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === first) {
|
||||||
|
e.preventDefault()
|
||||||
|
last.focus()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === last) {
|
||||||
|
e.preventDefault()
|
||||||
|
first.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
previousFocus = document.activeElement as HTMLElement
|
||||||
|
document.addEventListener('keydown', handleKeydown)
|
||||||
|
nextTick(() => {
|
||||||
|
(firstFocusRef.value ?? dialogRef.value)?.focus()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
|
previousFocus?.focus()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
submitError.value = null
|
||||||
|
submitSuccess.value = null
|
||||||
|
|
||||||
|
if (!title.value.trim()) return
|
||||||
|
|
||||||
|
const payload: PublishPayload = {
|
||||||
|
post_type: 'plan',
|
||||||
|
title: title.value.trim(),
|
||||||
|
}
|
||||||
|
if (description.value.trim()) payload.description = description.value.trim()
|
||||||
|
if (pseudonymName.value.trim()) payload.pseudonym_name = pseudonymName.value.trim()
|
||||||
|
if (props.plan?.id != null) payload.plan_id = props.plan.id
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const result = await store.publishPost(payload)
|
||||||
|
submitSuccess.value = 'Plan published to the community feed.'
|
||||||
|
nextTick(() => {
|
||||||
|
emit('published', { slug: result.slug })
|
||||||
|
})
|
||||||
|
} catch (err: unknown) {
|
||||||
|
submitError.value = err instanceof Error ? err.message : 'Could not publish. Please try again.'
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 400;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.required-mark {
|
||||||
|
color: var(--color-error);
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optional-mark {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-counter {
|
||||||
|
text-align: right;
|
||||||
|
display: block;
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-region {
|
||||||
|
min-height: 1.4rem;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-error {
|
||||||
|
color: var(--color-error);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-success {
|
||||||
|
color: var(--color-success);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-top: var(--spacing-md);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.modal-panel {
|
||||||
|
max-height: 95vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -55,6 +55,20 @@ export interface ForkResult {
|
||||||
forked_from: string
|
forked_from: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PublishPayload {
|
||||||
|
post_type: 'plan' | 'recipe_success' | 'recipe_blooper'
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
pseudonym_name?: string
|
||||||
|
plan_id?: number
|
||||||
|
recipe_id?: number
|
||||||
|
outcome_notes?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublishResult {
|
||||||
|
slug: string
|
||||||
|
}
|
||||||
|
|
||||||
// ========== Store ==========
|
// ========== Store ==========
|
||||||
|
|
||||||
export const useCommunityStore = defineStore('community', () => {
|
export const useCommunityStore = defineStore('community', () => {
|
||||||
|
|
@ -87,6 +101,11 @@ export const useCommunityStore = defineStore('community', () => {
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function publishPost(payload: PublishPayload): Promise<PublishResult> {
|
||||||
|
const response = await api.post<PublishResult>('/community/posts', payload)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
posts,
|
posts,
|
||||||
loading,
|
loading,
|
||||||
|
|
@ -94,5 +113,6 @@ export const useCommunityStore = defineStore('community', () => {
|
||||||
currentFilter,
|
currentFilter,
|
||||||
fetchPosts,
|
fetchPosts,
|
||||||
forkPost,
|
forkPost,
|
||||||
|
publishPost,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue