feat(nostr-feed): Add submission composer (Phase 2)
- Create SubmitComposer.vue with post type selector (text/link/media) - Add debounced link preview fetching on URL input - Wire up submission publishing via SubmissionService.createSubmission() - Add /submit route with SubmitPage.vue wrapper - Support community query param for pre-selection - Include NSFW toggle and form validation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d197c53afb
commit
54d81b7c79
4 changed files with 458 additions and 5 deletions
|
|
@ -86,11 +86,12 @@ Transform the nostr-feed module into a Reddit-style link aggregator with support
|
||||||
- [x] Create `LinkPreviewService.ts` - OG tag fetching
|
- [x] Create `LinkPreviewService.ts` - OG tag fetching
|
||||||
- [x] Extend `FeedService.ts` - Handle kind 1111
|
- [x] Extend `FeedService.ts` - Handle kind 1111
|
||||||
|
|
||||||
### Phase 2: Post Creation (Pending)
|
### Phase 2: Post Creation
|
||||||
- [ ] Create `SubmitComposer.vue` - Multi-type composer
|
- [x] Create `SubmitComposer.vue` - Multi-type composer
|
||||||
- [ ] Add link preview on URL paste
|
- [x] Add link preview on URL paste
|
||||||
- [ ] Integrate with pictrs for media upload
|
- [x] Add NSFW toggle
|
||||||
- [ ] Add NSFW toggle
|
- [x] Add route `/submit` for composer
|
||||||
|
- [ ] Integrate with pictrs for media upload (Future)
|
||||||
|
|
||||||
### Phase 3: Feed Display
|
### Phase 3: Feed Display
|
||||||
- [x] Create `SubmissionRow.vue` - Link aggregator row (Reddit/Lemmy style)
|
- [x] Create `SubmissionRow.vue` - Link aggregator row (Reddit/Lemmy style)
|
||||||
|
|
|
||||||
406
src/modules/nostr-feed/components/SubmitComposer.vue
Normal file
406
src/modules/nostr-feed/components/SubmitComposer.vue
Normal file
|
|
@ -0,0 +1,406 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* SubmitComposer - Create new submissions (link, media, self posts)
|
||||||
|
* Similar to Lemmy's Create Post form
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import {
|
||||||
|
Link2,
|
||||||
|
FileText,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Loader2,
|
||||||
|
ExternalLink,
|
||||||
|
X,
|
||||||
|
AlertCircle
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
import type { SubmissionService } from '../services/SubmissionService'
|
||||||
|
import type { LinkPreviewService } from '../services/LinkPreviewService'
|
||||||
|
import type {
|
||||||
|
LinkPreview,
|
||||||
|
SubmissionType,
|
||||||
|
LinkSubmissionForm,
|
||||||
|
SelfSubmissionForm,
|
||||||
|
SubmissionForm
|
||||||
|
} from '../types/submission'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Pre-selected community */
|
||||||
|
community?: string
|
||||||
|
/** Initial post type */
|
||||||
|
initialType?: SubmissionType
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
initialType: 'self'
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'submitted', submissionId: string): void
|
||||||
|
(e: 'cancel'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// Services
|
||||||
|
const submissionService = tryInjectService<SubmissionService>(SERVICE_TOKENS.SUBMISSION_SERVICE)
|
||||||
|
const linkPreviewService = tryInjectService<LinkPreviewService>(SERVICE_TOKENS.LINK_PREVIEW_SERVICE)
|
||||||
|
const authService = tryInjectService<any>(SERVICE_TOKENS.AUTH_SERVICE)
|
||||||
|
|
||||||
|
// Auth state
|
||||||
|
const isAuthenticated = computed(() => authService?.isAuthenticated?.value || false)
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const postType = ref<SubmissionType>(props.initialType)
|
||||||
|
const title = ref('')
|
||||||
|
const url = ref('')
|
||||||
|
const body = ref('')
|
||||||
|
const thumbnailUrl = ref('')
|
||||||
|
const nsfw = ref(false)
|
||||||
|
|
||||||
|
// Link preview state
|
||||||
|
const linkPreview = ref<LinkPreview | null>(null)
|
||||||
|
const isLoadingPreview = ref(false)
|
||||||
|
const previewError = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Submission state
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
const submitError = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
const isValid = computed(() => {
|
||||||
|
if (!title.value.trim()) return false
|
||||||
|
if (postType.value === 'link' && !url.value.trim()) return false
|
||||||
|
if (postType.value === 'self' && !body.value.trim()) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Debounced URL preview fetching
|
||||||
|
let previewTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
watch(url, (newUrl) => {
|
||||||
|
if (previewTimeout) {
|
||||||
|
clearTimeout(previewTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
linkPreview.value = null
|
||||||
|
previewError.value = null
|
||||||
|
|
||||||
|
if (!newUrl.trim() || postType.value !== 'link') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate URL format
|
||||||
|
try {
|
||||||
|
new URL(newUrl)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce the preview fetch
|
||||||
|
previewTimeout = setTimeout(async () => {
|
||||||
|
await fetchLinkPreview(newUrl)
|
||||||
|
}, 500)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchLinkPreview(urlToFetch: string) {
|
||||||
|
if (!linkPreviewService) {
|
||||||
|
previewError.value = 'Link preview service not available'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingPreview.value = true
|
||||||
|
previewError.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const preview = await linkPreviewService.fetchPreview(urlToFetch)
|
||||||
|
linkPreview.value = preview
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to fetch link preview:', err)
|
||||||
|
previewError.value = err.message || 'Failed to load preview'
|
||||||
|
} finally {
|
||||||
|
isLoadingPreview.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPreview() {
|
||||||
|
linkPreview.value = null
|
||||||
|
previewError.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!isValid.value || !isAuthenticated.value || !submissionService) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
submitError.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
let form: SubmissionForm
|
||||||
|
|
||||||
|
if (postType.value === 'link') {
|
||||||
|
const linkForm: LinkSubmissionForm = {
|
||||||
|
postType: 'link',
|
||||||
|
title: title.value.trim(),
|
||||||
|
url: url.value.trim(),
|
||||||
|
body: body.value.trim() || undefined,
|
||||||
|
communityRef: props.community,
|
||||||
|
nsfw: nsfw.value
|
||||||
|
}
|
||||||
|
form = linkForm
|
||||||
|
} else if (postType.value === 'self') {
|
||||||
|
const selfForm: SelfSubmissionForm = {
|
||||||
|
postType: 'self',
|
||||||
|
title: title.value.trim(),
|
||||||
|
body: body.value.trim(),
|
||||||
|
communityRef: props.community,
|
||||||
|
nsfw: nsfw.value
|
||||||
|
}
|
||||||
|
form = selfForm
|
||||||
|
} else if (postType.value === 'media') {
|
||||||
|
// TODO: Implement media submission with file upload
|
||||||
|
submitError.value = 'Media uploads not yet implemented'
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
submitError.value = 'Unknown post type'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const submissionId = await submissionService.createSubmission(form)
|
||||||
|
|
||||||
|
if (submissionId) {
|
||||||
|
emit('submitted', submissionId)
|
||||||
|
// Navigate to the new submission
|
||||||
|
router.push({ name: 'submission-detail', params: { id: submissionId } })
|
||||||
|
} else {
|
||||||
|
submitError.value = 'Failed to create submission'
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to submit:', err)
|
||||||
|
submitError.value = err.message || 'Failed to create submission'
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
emit('cancel')
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPostType(type: SubmissionType) {
|
||||||
|
postType.value = type
|
||||||
|
// Clear URL when switching away from link type
|
||||||
|
if (type !== 'link') {
|
||||||
|
url.value = ''
|
||||||
|
clearPreview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="max-w-2xl mx-auto p-4">
|
||||||
|
<h1 class="text-xl font-semibold mb-6">Create Post</h1>
|
||||||
|
|
||||||
|
<!-- Auth warning -->
|
||||||
|
<div v-if="!isAuthenticated" class="mb-4 p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||||
|
<div class="flex items-center gap-2 text-destructive">
|
||||||
|
<AlertCircle class="h-4 w-4" />
|
||||||
|
<span class="text-sm font-medium">You must be logged in to create a post</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Post type selector -->
|
||||||
|
<div class="flex gap-2 mb-6">
|
||||||
|
<Button
|
||||||
|
:variant="postType === 'self' ? 'default' : 'outline'"
|
||||||
|
size="sm"
|
||||||
|
@click="selectPostType('self')"
|
||||||
|
>
|
||||||
|
<FileText class="h-4 w-4 mr-2" />
|
||||||
|
Text
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
:variant="postType === 'link' ? 'default' : 'outline'"
|
||||||
|
size="sm"
|
||||||
|
@click="selectPostType('link')"
|
||||||
|
>
|
||||||
|
<Link2 class="h-4 w-4 mr-2" />
|
||||||
|
Link
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
:variant="postType === 'media' ? 'default' : 'outline'"
|
||||||
|
size="sm"
|
||||||
|
@click="selectPostType('media')"
|
||||||
|
disabled
|
||||||
|
title="Coming soon"
|
||||||
|
>
|
||||||
|
<ImageIcon class="h-4 w-4 mr-2" />
|
||||||
|
Image
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||||
|
<!-- Title -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1.5">
|
||||||
|
Title <span class="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="title"
|
||||||
|
type="text"
|
||||||
|
placeholder="An interesting title"
|
||||||
|
maxlength="300"
|
||||||
|
class="w-full px-3 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
:disabled="!isAuthenticated"
|
||||||
|
/>
|
||||||
|
<div class="text-xs text-muted-foreground mt-1 text-right">
|
||||||
|
{{ title.length }}/300
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- URL (for link posts) -->
|
||||||
|
<div v-if="postType === 'link'">
|
||||||
|
<label class="block text-sm font-medium mb-1.5">
|
||||||
|
URL <span class="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="url"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://example.com/article"
|
||||||
|
class="w-full px-3 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
:disabled="!isAuthenticated"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Link preview -->
|
||||||
|
<div v-if="isLoadingPreview" class="mt-3 p-4 border rounded-lg bg-muted/30">
|
||||||
|
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 class="h-4 w-4 animate-spin" />
|
||||||
|
Loading preview...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="previewError" class="mt-3 p-3 border border-destructive/20 rounded-lg bg-destructive/5">
|
||||||
|
<div class="flex items-center gap-2 text-sm text-destructive">
|
||||||
|
<AlertCircle class="h-4 w-4" />
|
||||||
|
{{ previewError }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="linkPreview" class="mt-3 p-4 border rounded-lg bg-muted/30">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<!-- Preview image -->
|
||||||
|
<div v-if="linkPreview.image" class="flex-shrink-0 w-24 h-18 rounded overflow-hidden bg-muted">
|
||||||
|
<img
|
||||||
|
:src="linkPreview.image"
|
||||||
|
:alt="linkPreview.title || ''"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview content -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 text-xs text-muted-foreground mb-1">
|
||||||
|
<ExternalLink class="h-3 w-3" />
|
||||||
|
<span>{{ linkPreview.domain }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ml-auto p-1 hover:bg-accent rounded"
|
||||||
|
@click="clearPreview"
|
||||||
|
>
|
||||||
|
<X class="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<h4 v-if="linkPreview.title" class="font-medium text-sm line-clamp-2">
|
||||||
|
{{ linkPreview.title }}
|
||||||
|
</h4>
|
||||||
|
<p v-if="linkPreview.description" class="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||||
|
{{ linkPreview.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Thumbnail URL (optional) -->
|
||||||
|
<div v-if="postType === 'link'">
|
||||||
|
<label class="block text-sm font-medium mb-1.5">
|
||||||
|
Thumbnail URL
|
||||||
|
<span class="text-muted-foreground font-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="thumbnailUrl"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://example.com/image.jpg"
|
||||||
|
class="w-full px-3 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
:disabled="!isAuthenticated"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1.5">
|
||||||
|
Body
|
||||||
|
<span v-if="postType === 'self'" class="text-destructive">*</span>
|
||||||
|
<span v-else class="text-muted-foreground font-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="body"
|
||||||
|
:placeholder="postType === 'self' ? 'Write your post content...' : 'Optional description or commentary...'"
|
||||||
|
rows="6"
|
||||||
|
class="w-full px-3 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary resize-y min-h-[120px]"
|
||||||
|
:disabled="!isAuthenticated"
|
||||||
|
/>
|
||||||
|
<div class="text-xs text-muted-foreground mt-1">
|
||||||
|
Markdown supported
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NSFW toggle -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="nsfw"
|
||||||
|
v-model="nsfw"
|
||||||
|
type="checkbox"
|
||||||
|
class="rounded border-muted-foreground"
|
||||||
|
:disabled="!isAuthenticated"
|
||||||
|
/>
|
||||||
|
<label for="nsfw" class="text-sm font-medium cursor-pointer">
|
||||||
|
NSFW
|
||||||
|
</label>
|
||||||
|
<Badge v-if="nsfw" variant="destructive" class="text-xs">
|
||||||
|
Not Safe For Work
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error message -->
|
||||||
|
<div v-if="submitError" class="p-3 border border-destructive/20 rounded-lg bg-destructive/5">
|
||||||
|
<div class="flex items-center gap-2 text-sm text-destructive">
|
||||||
|
<AlertCircle class="h-4 w-4" />
|
||||||
|
{{ submitError }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex items-center gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:disabled="!isValid || isSubmitting || !isAuthenticated"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="isSubmitting" class="h-4 w-4 animate-spin mr-2" />
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
@click="handleCancel"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -6,6 +6,7 @@ import SubmissionList from './components/SubmissionList.vue'
|
||||||
import SubmissionRow from './components/SubmissionRow.vue'
|
import SubmissionRow from './components/SubmissionRow.vue'
|
||||||
import SubmissionDetail from './components/SubmissionDetail.vue'
|
import SubmissionDetail from './components/SubmissionDetail.vue'
|
||||||
import SubmissionComment from './components/SubmissionComment.vue'
|
import SubmissionComment from './components/SubmissionComment.vue'
|
||||||
|
import SubmitComposer from './components/SubmitComposer.vue'
|
||||||
import VoteControls from './components/VoteControls.vue'
|
import VoteControls from './components/VoteControls.vue'
|
||||||
import SortTabs from './components/SortTabs.vue'
|
import SortTabs from './components/SortTabs.vue'
|
||||||
import { useFeed } from './composables/useFeed'
|
import { useFeed } from './composables/useFeed'
|
||||||
|
|
@ -34,6 +35,15 @@ export const nostrFeedModule: ModulePlugin = {
|
||||||
title: 'Submission',
|
title: 'Submission',
|
||||||
requiresAuth: false
|
requiresAuth: false
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/submit',
|
||||||
|
name: 'submit-post',
|
||||||
|
component: () => import('./views/SubmitPage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Create Post',
|
||||||
|
requiresAuth: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
@ -92,6 +102,7 @@ export const nostrFeedModule: ModulePlugin = {
|
||||||
SubmissionRow,
|
SubmissionRow,
|
||||||
SubmissionDetail,
|
SubmissionDetail,
|
||||||
SubmissionComment,
|
SubmissionComment,
|
||||||
|
SubmitComposer,
|
||||||
VoteControls,
|
VoteControls,
|
||||||
SortTabs
|
SortTabs
|
||||||
},
|
},
|
||||||
|
|
|
||||||
35
src/modules/nostr-feed/views/SubmitPage.vue
Normal file
35
src/modules/nostr-feed/views/SubmitPage.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* SubmitPage - Page wrapper for submission composer
|
||||||
|
* Handles route query params for community pre-selection
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import SubmitComposer from '../components/SubmitComposer.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// Get community from query param if provided (e.g., /submit?community=...)
|
||||||
|
const community = computed(() => route.query.community as string | undefined)
|
||||||
|
|
||||||
|
// Handle submission completion
|
||||||
|
function onSubmitted(submissionId: string) {
|
||||||
|
// Navigation is handled by SubmitComposer
|
||||||
|
console.log('Submission created:', submissionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle cancel - go back
|
||||||
|
function onCancel() {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SubmitComposer
|
||||||
|
:community="community"
|
||||||
|
@submitted="onSubmitted"
|
||||||
|
@cancel="onCancel"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue