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 { container, SERVICE_TOKENS } from '@/core/di-container'
|
||||
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 { useSubmissions } from './composables/useSubmissions'
|
||||
import { FeedService } from './services/FeedService'
|
||||
import { ProfileService } from './services/ProfileService'
|
||||
import { ReactionService } from './services/ReactionService'
|
||||
|
|
@ -63,15 +68,21 @@ export const nostrFeedModule: ModulePlugin = {
|
|||
|
||||
// Register components globally
|
||||
app.component('NostrFeed', NostrFeed)
|
||||
app.component('SubmissionList', SubmissionList)
|
||||
console.log('nostr-feed module: Installation complete')
|
||||
},
|
||||
|
||||
components: {
|
||||
NostrFeed
|
||||
NostrFeed,
|
||||
SubmissionList,
|
||||
SubmissionRow,
|
||||
VoteControls,
|
||||
SortTabs
|
||||
},
|
||||
|
||||
composables: {
|
||||
useFeed
|
||||
useFeed,
|
||||
useSubmissions
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue