feat(nostr-feed): Add Reddit/Lemmy-style UI components (Phase 2/3)

Implement minimal, information-dense submission feed UI inspired by
old Reddit and Lemmy designs.

New components:
- VoteControls.vue: Compact vertical upvote/downvote arrows with score
- SubmissionThumbnail.vue: Small square thumbnail with fallback icons
- SubmissionRow.vue: Single submission row with title, metadata, actions
- SortTabs.vue: Sort tabs (hot, new, top, controversial) with time range
- SubmissionList.vue: Main container composing all components

UI features:
- Dense layout showing many items at once
- Hover-reveal secondary actions (share, save, hide, report)
- Domain display for link posts
- NSFW blur/badge support
- Rank numbers (optional)
- Time-ago formatting
- Score formatting (e.g., 1.2k)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-01-01 18:03:35 +01:00
parent 078220c2f0
commit e533eafa89
6 changed files with 806 additions and 2 deletions

View file

@ -0,0 +1,96 @@
<script setup lang="ts">
/**
* SortTabs - Sort/filter tabs for submission list
* Minimal tab row like old Reddit: hot | new | top | controversial
*/
import { computed } from 'vue'
import { Flame, Clock, TrendingUp, Swords } from 'lucide-vue-next'
import type { SortType, TimeRange } from '../types/submission'
interface Props {
currentSort: SortType
currentTimeRange?: TimeRange
showTimeRange?: boolean
}
interface Emits {
(e: 'update:sort', sort: SortType): void
(e: 'update:timeRange', range: TimeRange): void
}
const props = withDefaults(defineProps<Props>(), {
currentTimeRange: 'day',
showTimeRange: false
})
const emit = defineEmits<Emits>()
const sortOptions: { id: SortType; label: string; icon: any }[] = [
{ id: 'hot', label: 'hot', icon: Flame },
{ id: 'new', label: 'new', icon: Clock },
{ id: 'top', label: 'top', icon: TrendingUp },
{ id: 'controversial', label: 'controversial', icon: Swords }
]
const timeRangeOptions: { id: TimeRange; label: string }[] = [
{ id: 'hour', label: 'hour' },
{ id: 'day', label: 'day' },
{ id: 'week', label: 'week' },
{ id: 'month', label: 'month' },
{ id: 'year', label: 'year' },
{ id: 'all', label: 'all time' }
]
// Show time range dropdown when top is selected
const showTimeDropdown = computed(() =>
props.showTimeRange && props.currentSort === 'top'
)
function selectSort(sort: SortType) {
emit('update:sort', sort)
}
function selectTimeRange(range: TimeRange) {
emit('update:timeRange', range)
}
</script>
<template>
<div class="flex items-center gap-1 text-sm border-b border-border pb-2 mb-2">
<!-- Sort tabs -->
<template v-for="option in sortOptions" :key="option.id">
<button
type="button"
:class="[
'px-2 py-1 rounded transition-colors flex items-center gap-1',
currentSort === option.id
? 'bg-accent text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
]"
@click="selectSort(option.id)"
>
<component :is="option.icon" class="h-3.5 w-3.5" />
<span>{{ option.label }}</span>
</button>
</template>
<!-- Time range dropdown (for top) -->
<template v-if="showTimeDropdown">
<span class="text-muted-foreground mx-1">·</span>
<select
:value="currentTimeRange"
class="bg-transparent text-sm text-muted-foreground hover:text-foreground cursor-pointer border-none outline-none"
@change="selectTimeRange(($event.target as HTMLSelectElement).value as TimeRange)"
>
<option
v-for="range in timeRangeOptions"
:key="range.id"
:value="range.id"
>
{{ range.label }}
</option>
</select>
</template>
</div>
</template>

View file

@ -0,0 +1,229 @@
<script setup lang="ts">
/**
* SubmissionList - Main container for Reddit/Lemmy style submission feed
* Includes sort tabs, submission rows, and loading states
*/
import { ref, computed, onMounted, watch } from 'vue'
import { Loader2 } from 'lucide-vue-next'
import SortTabs from './SortTabs.vue'
import SubmissionRow from './SubmissionRow.vue'
import { useSubmissions } from '../composables/useSubmissions'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ProfileService } from '../services/ProfileService'
import type { SubmissionWithMeta, SortType, TimeRange, CommunityRef } from '../types/submission'
interface Props {
/** Community to filter by */
community?: CommunityRef | null
/** Show rank numbers */
showRanks?: boolean
/** Show time range selector for top sort */
showTimeRange?: boolean
/** Initial sort */
initialSort?: SortType
/** Max submissions to show */
limit?: number
}
interface Emits {
(e: 'submission-click', submission: SubmissionWithMeta): void
}
const props = withDefaults(defineProps<Props>(), {
showRanks: false,
showTimeRange: true,
initialSort: 'hot',
limit: 50
})
const emit = defineEmits<Emits>()
// Inject profile service for display names
const profileService = tryInjectService<ProfileService>(SERVICE_TOKENS.PROFILE_SERVICE)
// Auth service for checking authentication
const authService = tryInjectService<any>(SERVICE_TOKENS.AUTH_SERVICE)
// Use submissions composable
const {
submissions,
isLoading,
error,
currentSort,
currentTimeRange,
subscribe,
upvote,
downvote,
setSort
} = useSubmissions({
autoSubscribe: false,
config: {
community: props.community,
limit: props.limit
}
})
// Set initial sort
currentSort.value = props.initialSort
// Current user pubkey
const currentUserPubkey = computed(() => authService?.user?.value?.pubkey || null)
// Is user authenticated
const isAuthenticated = computed(() => authService?.isAuthenticated?.value || false)
// Get display name for a pubkey
function getDisplayName(pubkey: string): string {
if (profileService) {
const profile = profileService.getProfile(pubkey)
if (profile?.display_name) return profile.display_name
if (profile?.name) return profile.name
}
// Fallback to truncated pubkey
return `${pubkey.slice(0, 8)}...`
}
// Handle sort change
function onSortChange(sort: SortType) {
setSort(sort, currentTimeRange.value)
}
// Handle time range change
function onTimeRangeChange(range: TimeRange) {
setSort(currentSort.value, range)
}
// Handle upvote
async function onUpvote(submission: SubmissionWithMeta) {
try {
await upvote(submission.id)
} catch (err) {
console.error('Failed to upvote:', err)
}
}
// Handle downvote
async function onDownvote(submission: SubmissionWithMeta) {
try {
await downvote(submission.id)
} catch (err) {
console.error('Failed to downvote:', err)
}
}
// Handle submission click
function onSubmissionClick(submission: SubmissionWithMeta) {
emit('submission-click', submission)
}
// Handle share
function onShare(submission: SubmissionWithMeta) {
// Copy link to clipboard or open share dialog
const url = `${window.location.origin}/post/${submission.id}`
navigator.clipboard?.writeText(url)
// TODO: Show toast notification
}
// Handle save
function onSave(submission: SubmissionWithMeta) {
// TODO: Implement save functionality
console.log('Save:', submission.id)
}
// Handle hide
function onHide(submission: SubmissionWithMeta) {
// TODO: Implement hide functionality
console.log('Hide:', submission.id)
}
// Handle report
function onReport(submission: SubmissionWithMeta) {
// TODO: Implement report functionality
console.log('Report:', submission.id)
}
// Subscribe when community changes
watch(() => props.community, () => {
subscribe({
community: props.community,
limit: props.limit
})
}, { immediate: false })
// Initial subscribe
onMounted(() => {
subscribe({
community: props.community,
limit: props.limit
})
})
</script>
<template>
<div class="submission-list">
<!-- Sort tabs -->
<SortTabs
:current-sort="currentSort"
:current-time-range="currentTimeRange"
:show-time-range="showTimeRange"
@update:sort="onSortChange"
@update:time-range="onTimeRangeChange"
/>
<!-- Loading state -->
<div v-if="isLoading && submissions.length === 0" class="flex items-center justify-center py-8">
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
<span class="ml-2 text-sm text-muted-foreground">Loading submissions...</span>
</div>
<!-- Error state -->
<div v-else-if="error" class="text-center py-8">
<p class="text-sm text-destructive">{{ error }}</p>
<button
type="button"
class="mt-2 text-sm text-primary hover:underline"
@click="subscribe({ community, limit })"
>
Try again
</button>
</div>
<!-- Empty state -->
<div v-else-if="submissions.length === 0" class="text-center py-8">
<p class="text-sm text-muted-foreground">No submissions yet</p>
</div>
<!-- Submission list -->
<div v-else class="divide-y divide-border">
<SubmissionRow
v-for="(submission, index) in submissions"
:key="submission.id"
:submission="submission"
:rank="showRanks ? index + 1 : undefined"
:get-display-name="getDisplayName"
:current-user-pubkey="currentUserPubkey"
:is-authenticated="isAuthenticated"
@upvote="onUpvote"
@downvote="onDownvote"
@click="onSubmissionClick"
@share="onShare"
@save="onSave"
@hide="onHide"
@report="onReport"
/>
</div>
<!-- Loading more indicator -->
<div v-if="isLoading && submissions.length > 0" class="flex items-center justify-center py-4">
<Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
<span class="ml-2 text-xs text-muted-foreground">Loading more...</span>
</div>
</div>
</template>
<style scoped>
.submission-list {
max-width: 100%;
}
</style>

View file

@ -0,0 +1,247 @@
<script setup lang="ts">
/**
* SubmissionRow - Single submission row in Reddit/Lemmy style
* Compact, information-dense layout with votes, thumbnail, title, metadata
*/
import { computed } from 'vue'
import { formatDistanceToNow } from 'date-fns'
import { MessageSquare, Share2, Bookmark, EyeOff, Flag, Link2 } from 'lucide-vue-next'
import VoteControls from './VoteControls.vue'
import SubmissionThumbnail from './SubmissionThumbnail.vue'
import { Badge } from '@/components/ui/badge'
import type { SubmissionWithMeta, LinkSubmission, MediaSubmission } from '../types/submission'
import { extractDomain } from '../types/submission'
interface Props {
submission: SubmissionWithMeta
/** Display name resolver */
getDisplayName: (pubkey: string) => string
/** Current user pubkey for "own post" detection */
currentUserPubkey?: string | null
/** Show rank number */
rank?: number
/** Whether user is authenticated (for voting) */
isAuthenticated?: boolean
}
interface Emits {
(e: 'upvote', submission: SubmissionWithMeta): void
(e: 'downvote', submission: SubmissionWithMeta): void
(e: 'click', submission: SubmissionWithMeta): void
(e: 'save', submission: SubmissionWithMeta): void
(e: 'hide', submission: SubmissionWithMeta): void
(e: 'report', submission: SubmissionWithMeta): void
(e: 'share', submission: SubmissionWithMeta): void
}
const props = withDefaults(defineProps<Props>(), {
isAuthenticated: false
})
const emit = defineEmits<Emits>()
// Extract thumbnail URL based on post type
const thumbnailUrl = computed(() => {
const s = props.submission
if (s.postType === 'link') {
const link = s as LinkSubmission
return link.preview?.image || undefined
}
if (s.postType === 'media') {
const media = s as MediaSubmission
return media.media.thumbnail || media.media.url
}
return undefined
})
// Extract domain for link posts
const domain = computed(() => {
if (props.submission.postType === 'link') {
const link = props.submission as LinkSubmission
return extractDomain(link.url)
}
return null
})
// Format timestamp
const timeAgo = computed(() => {
return formatDistanceToNow(props.submission.created_at * 1000, { addSuffix: true })
})
// Author display name
const authorName = computed(() => {
return props.getDisplayName(props.submission.pubkey)
})
// Is this the user's own post?
const isOwnPost = computed(() => {
return props.currentUserPubkey === props.submission.pubkey
})
// Community name (if any)
const communityName = computed(() => {
const ref = props.submission.communityRef
if (!ref) return null
// Extract identifier from "34550:pubkey:identifier"
const parts = ref.split(':')
return parts.length >= 3 ? parts.slice(2).join(':') : null
})
// Post type indicator for self posts
const postTypeLabel = computed(() => {
if (props.submission.postType === 'self') return 'self'
return null
})
function onTitleClick() {
if (props.submission.postType === 'link') {
// Open external link
const link = props.submission as LinkSubmission
window.open(link.url, '_blank', 'noopener,noreferrer')
} else {
// Navigate to post detail
emit('click', props.submission)
}
}
function onCommentsClick() {
emit('click', props.submission)
}
</script>
<template>
<div class="flex items-start gap-2 py-2 px-1 hover:bg-accent/30 transition-colors group">
<!-- Rank number (optional) -->
<div v-if="rank" class="w-6 text-right text-sm text-muted-foreground font-medium pt-1">
{{ rank }}
</div>
<!-- Vote controls -->
<VoteControls
:score="submission.votes.score"
:user-vote="submission.votes.userVote"
:disabled="!isAuthenticated"
@upvote="emit('upvote', submission)"
@downvote="emit('downvote', submission)"
/>
<!-- Thumbnail -->
<SubmissionThumbnail
:src="thumbnailUrl"
:post-type="submission.postType"
:nsfw="submission.nsfw"
:size="70"
class="cursor-pointer"
@click="onCommentsClick"
/>
<!-- Content -->
<div class="flex-1 min-w-0">
<!-- Title row -->
<div class="flex items-start gap-2">
<h3
class="text-sm font-medium leading-snug cursor-pointer hover:underline"
@click="onTitleClick"
>
{{ submission.title }}
</h3>
<!-- Domain for link posts -->
<span v-if="domain" class="text-xs text-muted-foreground flex-shrink-0">
({{ domain }})
</span>
<!-- Self post indicator -->
<span v-if="postTypeLabel" class="text-xs text-muted-foreground flex-shrink-0">
({{ postTypeLabel }})
</span>
<!-- External link icon for link posts -->
<Link2
v-if="submission.postType === 'link'"
class="h-3 w-3 text-muted-foreground flex-shrink-0 mt-0.5"
/>
</div>
<!-- Flair badges -->
<div v-if="submission.nsfw || submission.flair" class="flex items-center gap-1 mt-0.5">
<Badge v-if="submission.nsfw" variant="destructive" class="text-[10px] px-1 py-0">
NSFW
</Badge>
<Badge v-if="submission.flair" variant="secondary" class="text-[10px] px-1 py-0">
{{ submission.flair }}
</Badge>
</div>
<!-- Metadata row -->
<div class="text-xs text-muted-foreground mt-1">
<span>submitted {{ timeAgo }}</span>
<span> by </span>
<span class="hover:underline cursor-pointer">{{ authorName }}</span>
<template v-if="communityName">
<span> to </span>
<span class="hover:underline cursor-pointer font-medium">{{ communityName }}</span>
</template>
</div>
<!-- Actions row -->
<div class="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
<!-- Comments -->
<button
type="button"
class="flex items-center gap-1 hover:text-foreground transition-colors"
@click="onCommentsClick"
>
<MessageSquare class="h-3.5 w-3.5" />
<span>{{ submission.commentCount }} comments</span>
</button>
<!-- Share -->
<button
type="button"
class="flex items-center gap-1 hover:text-foreground transition-colors opacity-0 group-hover:opacity-100"
@click="emit('share', submission)"
>
<Share2 class="h-3.5 w-3.5" />
<span>share</span>
</button>
<!-- Save -->
<button
type="button"
class="flex items-center gap-1 hover:text-foreground transition-colors opacity-0 group-hover:opacity-100"
:class="{ 'text-yellow-500': submission.isSaved }"
@click="emit('save', submission)"
>
<Bookmark class="h-3.5 w-3.5" :class="{ 'fill-current': submission.isSaved }" />
<span>{{ submission.isSaved ? 'saved' : 'save' }}</span>
</button>
<!-- Hide -->
<button
type="button"
class="flex items-center gap-1 hover:text-foreground transition-colors opacity-0 group-hover:opacity-100"
@click="emit('hide', submission)"
>
<EyeOff class="h-3.5 w-3.5" />
<span>hide</span>
</button>
<!-- Report (not for own posts) -->
<button
v-if="!isOwnPost"
type="button"
class="flex items-center gap-1 hover:text-foreground transition-colors opacity-0 group-hover:opacity-100"
@click="emit('report', submission)"
>
<Flag class="h-3.5 w-3.5" />
<span>report</span>
</button>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,114 @@
<script setup lang="ts">
/**
* SubmissionThumbnail - Small square thumbnail for submissions
* Shows preview image, video indicator, or placeholder icon
*/
import { computed } from 'vue'
import { Link, FileText, Image, Film, MessageSquare, ExternalLink } from 'lucide-vue-next'
import type { SubmissionType } from '../types/submission'
interface Props {
/** Thumbnail URL */
src?: string
/** Submission type for fallback icon */
postType: SubmissionType
/** Alt text */
alt?: string
/** Whether this is NSFW content */
nsfw?: boolean
/** Size in pixels */
size?: number
}
const props = withDefaults(defineProps<Props>(), {
size: 70,
nsfw: false
})
// Determine fallback icon based on post type
const FallbackIcon = computed(() => {
switch (props.postType) {
case 'link':
return ExternalLink
case 'media':
return Image
case 'self':
default:
return FileText
}
})
// Background color for fallback
const fallbackBgClass = computed(() => {
switch (props.postType) {
case 'link':
return 'bg-blue-500/10'
case 'media':
return 'bg-purple-500/10'
case 'self':
default:
return 'bg-muted'
}
})
// Icon color for fallback
const fallbackIconClass = computed(() => {
switch (props.postType) {
case 'link':
return 'text-blue-500'
case 'media':
return 'text-purple-500'
case 'self':
default:
return 'text-muted-foreground'
}
})
</script>
<template>
<div
class="flex-shrink-0 rounded overflow-hidden"
:style="{ width: `${size}px`, height: `${size}px` }"
>
<!-- NSFW blur overlay -->
<template v-if="nsfw && src">
<div class="relative w-full h-full">
<img
:src="src"
:alt="alt || 'Thumbnail'"
class="w-full h-full object-cover blur-lg"
/>
<div class="absolute inset-0 flex items-center justify-center bg-black/50">
<span class="text-[10px] font-bold text-red-500 uppercase">NSFW</span>
</div>
</div>
</template>
<!-- Image thumbnail -->
<template v-else-if="src">
<img
:src="src"
:alt="alt || 'Thumbnail'"
class="w-full h-full object-cover bg-muted"
loading="lazy"
@error="($event.target as HTMLImageElement).style.display = 'none'"
/>
</template>
<!-- Fallback icon -->
<template v-else>
<div
:class="[
'w-full h-full flex items-center justify-center',
fallbackBgClass
]"
>
<component
:is="FallbackIcon"
:class="['h-6 w-6', fallbackIconClass]"
/>
</div>
</template>
</div>
</template>

View file

@ -0,0 +1,107 @@
<script setup lang="ts">
/**
* VoteControls - Compact upvote/downvote arrows with score
* Lemmy/Reddit style vertical layout
*/
import { computed } from 'vue'
import { ChevronUp, ChevronDown } from 'lucide-vue-next'
interface Props {
score: number
userVote: 'upvote' | 'downvote' | null
disabled?: boolean
}
interface Emits {
(e: 'upvote'): void
(e: 'downvote'): void
}
const props = withDefaults(defineProps<Props>(), {
disabled: false
})
const emit = defineEmits<Emits>()
// Format score for display (e.g., 1.2k for 1200)
const displayScore = computed(() => {
const score = props.score
if (Math.abs(score) >= 10000) {
return (score / 1000).toFixed(0) + 'k'
}
if (Math.abs(score) >= 1000) {
return (score / 1000).toFixed(1) + 'k'
}
return score.toString()
})
// Score color based on value
const scoreClass = computed(() => {
if (props.userVote === 'upvote') return 'text-orange-500'
if (props.userVote === 'downvote') return 'text-blue-500'
if (props.score > 0) return 'text-foreground'
if (props.score < 0) return 'text-muted-foreground'
return 'text-muted-foreground'
})
function onUpvote() {
if (!props.disabled) {
emit('upvote')
}
}
function onDownvote() {
if (!props.disabled) {
emit('downvote')
}
}
</script>
<template>
<div class="flex flex-col items-center gap-0 min-w-[40px]">
<!-- Upvote button -->
<button
type="button"
:disabled="disabled"
:class="[
'p-1 rounded transition-colors',
'hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed',
userVote === 'upvote' ? 'text-orange-500' : 'text-muted-foreground hover:text-orange-500'
]"
@click="onUpvote"
>
<ChevronUp
class="h-5 w-5"
:class="{ 'fill-current': userVote === 'upvote' }"
/>
</button>
<!-- Score -->
<span
:class="[
'text-xs font-bold tabular-nums min-w-[24px] text-center',
scoreClass
]"
>
{{ displayScore }}
</span>
<!-- Downvote button -->
<button
type="button"
:disabled="disabled"
:class="[
'p-1 rounded transition-colors',
'hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed',
userVote === 'downvote' ? 'text-blue-500' : 'text-muted-foreground hover:text-blue-500'
]"
@click="onDownvote"
>
<ChevronDown
class="h-5 w-5"
:class="{ 'fill-current': userVote === 'downvote' }"
/>
</button>
</div>
</template>

View file

@ -2,7 +2,12 @@ import type { App } from 'vue'
import type { ModulePlugin } from '@/core/types' import type { ModulePlugin } from '@/core/types'
import { container, SERVICE_TOKENS } from '@/core/di-container' import { container, SERVICE_TOKENS } from '@/core/di-container'
import NostrFeed from './components/NostrFeed.vue' import NostrFeed from './components/NostrFeed.vue'
import SubmissionList from './components/SubmissionList.vue'
import SubmissionRow from './components/SubmissionRow.vue'
import VoteControls from './components/VoteControls.vue'
import SortTabs from './components/SortTabs.vue'
import { useFeed } from './composables/useFeed' import { useFeed } from './composables/useFeed'
import { useSubmissions } from './composables/useSubmissions'
import { FeedService } from './services/FeedService' import { FeedService } from './services/FeedService'
import { ProfileService } from './services/ProfileService' import { ProfileService } from './services/ProfileService'
import { ReactionService } from './services/ReactionService' import { ReactionService } from './services/ReactionService'
@ -63,15 +68,21 @@ export const nostrFeedModule: ModulePlugin = {
// Register components globally // Register components globally
app.component('NostrFeed', NostrFeed) app.component('NostrFeed', NostrFeed)
app.component('SubmissionList', SubmissionList)
console.log('nostr-feed module: Installation complete') console.log('nostr-feed module: Installation complete')
}, },
components: { components: {
NostrFeed NostrFeed,
SubmissionList,
SubmissionRow,
VoteControls,
SortTabs
}, },
composables: { composables: {
useFeed useFeed,
useSubmissions
} }
} }