Add activity creation and publishing (Phase 2)

CreateActivityDialog with vee-validate + Zod form: title, summary,
description, start/end date+time, location, categories (multi-select),
and image URL. Signs NIP-52 kind 31923 events with user's signing key
via nostr-tools finalizeEvent and publishes through RelayHub. Fixed
ActivitiesNostrService.publishCalendarEvent to properly sign events
before publishing. CategorySelector and LocationPicker helper components.
Create button visible only to authenticated users.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-04-20 07:58:41 +02:00
commit ac163d3b82
5 changed files with 386 additions and 6 deletions

View file

@ -0,0 +1,45 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { Badge } from '@/components/ui/badge'
import type { ActivityCategory } from '../types/category'
import { ALL_CATEGORIES } from '../types/category'
const props = defineProps<{
modelValue: ActivityCategory[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: ActivityCategory[]]
}>()
const { t } = useI18n()
function toggle(cat: ActivityCategory) {
const current = [...props.modelValue]
const idx = current.indexOf(cat)
if (idx >= 0) {
current.splice(idx, 1)
} else {
current.push(cat)
}
emit('update:modelValue', current)
}
function label(cat: ActivityCategory): string {
return t(`activities.categories.${cat}`, cat)
}
</script>
<template>
<div class="flex flex-wrap gap-1.5">
<Badge
v-for="cat in ALL_CATEGORIES"
:key="cat"
:variant="modelValue.includes(cat) ? 'default' : 'outline'"
class="cursor-pointer text-xs select-none hover:opacity-80 transition-opacity"
@click="toggle(cat)"
>
{{ label(cat) }}
</Badge>
</div>
</template>

View file

@ -0,0 +1,270 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from '@/components/ui/dialog'
import {
FormControl, FormField, FormItem, FormLabel, FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { CalendarPlus } from 'lucide-vue-next'
import { useAuth } from '@/composables/useAuthService'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ActivitiesNostrService } from '../services/ActivitiesNostrService'
import type { CalendarTimeEvent } from '../types/nip52'
import type { ActivityCategory } from '../types/category'
import CategorySelector from './CategorySelector.vue'
import LocationPicker from './LocationPicker.vue'
import { toast } from 'vue-sonner'
defineProps<{
isOpen: boolean
}>()
const emit = defineEmits<{
'update:isOpen': [value: boolean]
'created': []
}>()
const { t } = useI18n()
const { currentUser } = useAuth()
const isPublishing = ref(false)
const selectedCategories = ref<ActivityCategory[]>([])
const location = ref('')
const formSchema = toTypedSchema(z.object({
title: z.string().min(1, 'Title is required').max(200),
summary: z.string().max(500).optional(),
description: z.string().min(1, 'Description is required').max(5000),
startDate: z.string().min(1, 'Start date is required'),
startTime: z.string().min(1, 'Start time is required'),
endDate: z.string().optional(),
endTime: z.string().optional(),
image: z.string().url('Must be a valid URL').optional().or(z.literal('')),
}))
const form = useForm({
validationSchema: formSchema,
initialValues: {
title: '',
summary: '',
description: '',
startDate: '',
startTime: '',
endDate: '',
endTime: '',
image: '',
},
})
const isFormValid = computed(() => form.meta.value.valid)
const onSubmit = form.handleSubmit(async (values) => {
const nostrService = tryInjectService<ActivitiesNostrService>(SERVICE_TOKENS.ACTIVITIES_NOSTR_SERVICE)
if (!nostrService) {
toast.error('Activities service not available')
return
}
const signingKey = currentUser.value?.prvkey
if (!signingKey) {
toast.error('Signing key not available. Please log in again.')
return
}
isPublishing.value = true
try {
// Build unix timestamps
const startTimestamp = Math.floor(new Date(`${values.startDate}T${values.startTime}`).getTime() / 1000)
let endTimestamp: number | undefined
if (values.endDate && values.endTime) {
endTimestamp = Math.floor(new Date(`${values.endDate}T${values.endTime}`).getTime() / 1000)
}
// Generate a unique d-tag
const dTag = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const eventData: Partial<CalendarTimeEvent> = {
dTag,
title: values.title,
summary: values.summary || undefined,
content: values.description,
image: values.image || undefined,
start: startTimestamp,
end: endTimestamp,
startTzid: Intl.DateTimeFormat().resolvedOptions().timeZone,
location: location.value || undefined,
hashtags: selectedCategories.value,
}
const result = await nostrService.publishCalendarEvent(eventData, signingKey)
if (result.success > 0) {
toast.success(`Activity published to ${result.success} relay${result.success > 1 ? 's' : ''}`)
emit('created')
handleClose()
} else {
toast.error('Failed to publish to any relay')
}
} catch (err) {
console.error('Failed to publish activity:', err)
toast.error(err instanceof Error ? err.message : 'Failed to publish activity')
} finally {
isPublishing.value = false
}
})
function handleClose() {
emit('update:isOpen', false)
form.resetForm()
selectedCategories.value = []
location.value = ''
}
</script>
<template>
<Dialog :open="isOpen" @update:open="handleClose">
<DialogContent class="sm:max-w-[550px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<CalendarPlus class="w-5 h-5" />
{{ t('activities.createNew') }}
</DialogTitle>
<DialogDescription>
Publish a new activity to Nostr relays
</DialogDescription>
</DialogHeader>
<form @submit="onSubmit" class="space-y-4 py-2">
<!-- Title -->
<FormField v-slot="{ componentField }" name="title">
<FormItem>
<FormLabel>Title *</FormLabel>
<FormControl>
<Input placeholder="e.g. Marché de Noël de Foix" v-bind="componentField" :disabled="isPublishing" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Summary -->
<FormField v-slot="{ componentField }" name="summary">
<FormItem>
<FormLabel>Summary</FormLabel>
<FormControl>
<Input placeholder="Brief one-line description" v-bind="componentField" :disabled="isPublishing" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Description -->
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Description *</FormLabel>
<FormControl>
<Textarea
placeholder="Full details about the activity..."
v-bind="componentField"
:disabled="isPublishing"
rows="4"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Start date/time -->
<div class="grid grid-cols-2 gap-3">
<FormField v-slot="{ componentField }" name="startDate">
<FormItem>
<FormLabel>Start date *</FormLabel>
<FormControl>
<Input type="date" v-bind="componentField" :disabled="isPublishing" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="startTime">
<FormItem>
<FormLabel>Start time *</FormLabel>
<FormControl>
<Input type="time" v-bind="componentField" :disabled="isPublishing" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- End date/time -->
<div class="grid grid-cols-2 gap-3">
<FormField v-slot="{ componentField }" name="endDate">
<FormItem>
<FormLabel>End date</FormLabel>
<FormControl>
<Input type="date" v-bind="componentField" :disabled="isPublishing" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="endTime">
<FormItem>
<FormLabel>End time</FormLabel>
<FormControl>
<Input type="time" v-bind="componentField" :disabled="isPublishing" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Location -->
<LocationPicker
v-model="location"
:disabled="isPublishing"
/>
<!-- Categories -->
<div class="space-y-1.5">
<label class="text-sm font-medium">Categories</label>
<CategorySelector v-model="selectedCategories" />
</div>
<!-- Image URL -->
<FormField v-slot="{ componentField }" name="image">
<FormItem>
<FormLabel>Image URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/image.jpg"
v-bind="componentField"
:disabled="isPublishing"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Submit -->
<Button
type="submit"
:disabled="isPublishing || !isFormValid"
class="w-full"
>
<span v-if="isPublishing" class="animate-spin mr-2"></span>
{{ isPublishing ? 'Publishing...' : 'Publish Activity' }}
</Button>
</form>
</DialogContent>
</Dialog>
</template>

View file

@ -0,0 +1,34 @@
<script setup lang="ts">
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { MapPin } from 'lucide-vue-next'
defineProps<{
modelValue: string
placeholder?: string
disabled?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
</script>
<template>
<div class="space-y-1.5">
<Label class="text-sm font-medium">Location</Label>
<div class="relative">
<MapPin class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
:model-value="modelValue"
@update:model-value="emit('update:modelValue', $event as string)"
:placeholder="placeholder ?? 'e.g. Salle des fêtes, Foix, Ariège'"
:disabled="disabled"
class="pl-9"
/>
</div>
<p class="text-xs text-muted-foreground">
Enter the venue name and address
</p>
</div>
</template>

View file

@ -1,5 +1,5 @@
import { BaseService } from '@/core/base/BaseService'
import type { Event as NostrEvent } from 'nostr-tools'
import { finalizeEvent, type Event as NostrEvent, type EventTemplate } from 'nostr-tools'
import type { SubscriptionConfig } from '@/modules/base/nostr/relay-hub'
import {
NIP52_KINDS,
@ -107,23 +107,28 @@ export class ActivitiesNostrService extends BaseService {
/**
* Publish a NIP-52 time-based calendar event.
* Requires an authenticated user with a signing key.
*/
async publishCalendarEvent(
eventData: Partial<CalendarTimeEvent>
eventData: Partial<CalendarTimeEvent>,
signingKeyHex: string
): Promise<{ success: number; total: number }> {
if (!this.relayHub) {
throw new Error('RelayHub not available')
}
const tags = buildCalendarTimeEventTags(eventData)
const eventTemplate = {
const template: EventTemplate = {
kind: NIP52_KINDS.CALENDAR_TIME_EVENT,
created_at: Math.floor(Date.now() / 1000),
content: eventData.content ?? '',
tags,
}
return await this.relayHub.publishEvent(eventTemplate)
const privkeyBytes = hexToUint8Array(signingKeyHex)
const signedEvent = finalizeEvent(template, privkeyBytes)
return await this.relayHub.publishEvent(signedEvent)
}
/**
@ -161,10 +166,17 @@ export class ActivitiesNostrService extends BaseService {
}
protected override async onDispose(): Promise<void> {
// Clean up all active subscriptions
for (const unsub of this.activeUnsubscribes) {
unsub()
}
this.activeUnsubscribes = []
}
}
function hexToUint8Array(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
}
return bytes
}

View file

@ -10,8 +10,10 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { RefreshCw, Search, SlidersHorizontal, ChevronDown } from 'lucide-vue-next'
import { RefreshCw, Search, SlidersHorizontal, ChevronDown, Plus } from 'lucide-vue-next'
import { useAuth } from '@/composables/useAuthService'
import { useActivities } from '../composables/useActivities'
import CreateActivityDialog from '../components/CreateActivityDialog.vue'
import TemporalFilterBar from '../components/TemporalFilterBar.vue'
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
import DatePickerStrip from '../components/DatePickerStrip.vue'
@ -20,6 +22,9 @@ import type { Activity } from '../types/activity'
const router = useRouter()
const { t } = useI18n()
const { isAuthenticated } = useAuth()
const showCreateDialog = ref(false)
const {
activities,
@ -62,6 +67,14 @@ function handleRefresh() {
</h1>
</div>
<div class="flex gap-2 sm:flex-shrink-0">
<Button
v-if="isAuthenticated"
size="sm"
@click="showCreateDialog = true"
>
<Plus class="w-4 h-4 mr-1.5" />
{{ t('activities.createNew') }}
</Button>
<Button variant="outline" size="sm" @click="handleRefresh" :disabled="isLoading">
<RefreshCw class="w-4 h-4 mr-1.5" :class="{ 'animate-spin': isLoading }" />
Refresh
@ -156,5 +169,11 @@ function handleRefresh() {
/>
</TabsContent>
</Tabs>
<!-- Create Activity Dialog -->
<CreateActivityDialog
v-model:is-open="showCreateDialog"
@created="handleRefresh"
/>
</div>
</template>