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:
parent
00eddc9189
commit
ac163d3b82
5 changed files with 386 additions and 6 deletions
45
src/modules/activities/components/CategorySelector.vue
Normal file
45
src/modules/activities/components/CategorySelector.vue
Normal 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>
|
||||||
270
src/modules/activities/components/CreateActivityDialog.vue
Normal file
270
src/modules/activities/components/CreateActivityDialog.vue
Normal 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>
|
||||||
34
src/modules/activities/components/LocationPicker.vue
Normal file
34
src/modules/activities/components/LocationPicker.vue
Normal 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>
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { BaseService } from '@/core/base/BaseService'
|
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 type { SubscriptionConfig } from '@/modules/base/nostr/relay-hub'
|
||||||
import {
|
import {
|
||||||
NIP52_KINDS,
|
NIP52_KINDS,
|
||||||
|
|
@ -107,23 +107,28 @@ export class ActivitiesNostrService extends BaseService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publish a NIP-52 time-based calendar event.
|
* Publish a NIP-52 time-based calendar event.
|
||||||
|
* Requires an authenticated user with a signing key.
|
||||||
*/
|
*/
|
||||||
async publishCalendarEvent(
|
async publishCalendarEvent(
|
||||||
eventData: Partial<CalendarTimeEvent>
|
eventData: Partial<CalendarTimeEvent>,
|
||||||
|
signingKeyHex: string
|
||||||
): Promise<{ success: number; total: number }> {
|
): Promise<{ success: number; total: number }> {
|
||||||
if (!this.relayHub) {
|
if (!this.relayHub) {
|
||||||
throw new Error('RelayHub not available')
|
throw new Error('RelayHub not available')
|
||||||
}
|
}
|
||||||
|
|
||||||
const tags = buildCalendarTimeEventTags(eventData)
|
const tags = buildCalendarTimeEventTags(eventData)
|
||||||
const eventTemplate = {
|
const template: EventTemplate = {
|
||||||
kind: NIP52_KINDS.CALENDAR_TIME_EVENT,
|
kind: NIP52_KINDS.CALENDAR_TIME_EVENT,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
content: eventData.content ?? '',
|
content: eventData.content ?? '',
|
||||||
tags,
|
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> {
|
protected override async onDispose(): Promise<void> {
|
||||||
// Clean up all active subscriptions
|
|
||||||
for (const unsub of this.activeUnsubscribes) {
|
for (const unsub of this.activeUnsubscribes) {
|
||||||
unsub()
|
unsub()
|
||||||
}
|
}
|
||||||
this.activeUnsubscribes = []
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,10 @@ import {
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from '@/components/ui/collapsible'
|
} 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 { useActivities } from '../composables/useActivities'
|
||||||
|
import CreateActivityDialog from '../components/CreateActivityDialog.vue'
|
||||||
import TemporalFilterBar from '../components/TemporalFilterBar.vue'
|
import TemporalFilterBar from '../components/TemporalFilterBar.vue'
|
||||||
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
|
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
|
||||||
import DatePickerStrip from '../components/DatePickerStrip.vue'
|
import DatePickerStrip from '../components/DatePickerStrip.vue'
|
||||||
|
|
@ -20,6 +22,9 @@ import type { Activity } from '../types/activity'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
|
||||||
|
const showCreateDialog = ref(false)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
activities,
|
activities,
|
||||||
|
|
@ -62,6 +67,14 @@ function handleRefresh() {
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 sm:flex-shrink-0">
|
<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">
|
<Button variant="outline" size="sm" @click="handleRefresh" :disabled="isLoading">
|
||||||
<RefreshCw class="w-4 h-4 mr-1.5" :class="{ 'animate-spin': isLoading }" />
|
<RefreshCw class="w-4 h-4 mr-1.5" :class="{ 'animate-spin': isLoading }" />
|
||||||
Refresh
|
Refresh
|
||||||
|
|
@ -156,5 +169,11 @@ function handleRefresh() {
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
<!-- Create Activity Dialog -->
|
||||||
|
<CreateActivityDialog
|
||||||
|
v-model:is-open="showCreateDialog"
|
||||||
|
@created="handleRefresh"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue