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:
parent
078220c2f0
commit
e533eafa89
6 changed files with 806 additions and 2 deletions
96
src/modules/nostr-feed/components/SortTabs.vue
Normal file
96
src/modules/nostr-feed/components/SortTabs.vue
Normal 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>
|
||||||
229
src/modules/nostr-feed/components/SubmissionList.vue
Normal file
229
src/modules/nostr-feed/components/SubmissionList.vue
Normal 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>
|
||||||
247
src/modules/nostr-feed/components/SubmissionRow.vue
Normal file
247
src/modules/nostr-feed/components/SubmissionRow.vue
Normal 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>
|
||||||
114
src/modules/nostr-feed/components/SubmissionThumbnail.vue
Normal file
114
src/modules/nostr-feed/components/SubmissionThumbnail.vue
Normal 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>
|
||||||
107
src/modules/nostr-feed/components/VoteControls.vue
Normal file
107
src/modules/nostr-feed/components/VoteControls.vue
Normal 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>
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue