feat(app): add design system viewer, nostr feed, stop generation, and content refactor
- Design system browser with grid/detail views for tokens and components - Nostr feed tab with note/article/zap filtering and relay status - Stop generation button to abort AI streaming mid-response - Paste & extract content without sending to AI - Refactor useContentPanel into contentExtraction.ts and contentFiltering.ts - Banner fallback composable for 3-stage image loading - Wikipedia and Google Books as fallback image sources - Loading skeletons with variant-specific shapes - Mobile UX: auto-switch to content, back button, detail flow - Project grid with breadcrumb nav and inline creation - Filesystem Vite plugin for local project browsing - Magazine text cleanup and song grid polish Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e8fc54cade
commit
00bdc055ba
@@ -156,6 +156,21 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Design System -->
|
||||
<div class="border-t mt-2 pt-2" :class="isDark ? 'border-white/10' : 'border-black/10'">
|
||||
<button
|
||||
class="w-full text-left px-3 py-2 rounded-lg text-xs transition-all duration-200 flex items-center gap-2"
|
||||
:class="isDark
|
||||
? 'text-white/60 hover:text-white hover:bg-white/10'
|
||||
: 'text-gray-500 hover:text-gray-900 hover:bg-black/5'"
|
||||
@click="openDesignSystem"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||
</svg>
|
||||
Design System
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
@@ -167,6 +182,7 @@ import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useAI } from '@/composables/useAI'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useContentPanel } from '@/composables/useContentPanel'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
@@ -250,6 +266,13 @@ function selectModel(providerId: string, modelId: string) {
|
||||
setModel(modelId)
|
||||
showModelPicker.value = false
|
||||
}
|
||||
|
||||
const { enterDesignSystemMode } = useContentPanel()
|
||||
|
||||
function openDesignSystem() {
|
||||
enterDesignSystemMode()
|
||||
showModelPicker.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="p-3 md:p-4">
|
||||
<div
|
||||
class="path-glass-bubble rounded-2xl px-4 py-3 flex items-end gap-3 transition-all duration-300"
|
||||
class="path-glass-bubble rounded-2xl px-4 py-3 flex items-end gap-2 transition-all duration-300"
|
||||
:class="focused
|
||||
? isDark
|
||||
? 'border-white/30'
|
||||
@@ -19,22 +19,50 @@
|
||||
: 'text-gray-800 placeholder:text-gray-400'"
|
||||
@keydown.enter.exact.prevent="send"
|
||||
@input="autoResize"
|
||||
@paste="onPaste"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
/>
|
||||
<button
|
||||
:disabled="!canSend"
|
||||
class="shrink-0 path-glass-button path-glass-button-sm rounded-xl px-3 transition-all duration-200"
|
||||
:class="canSend
|
||||
? 'hover:opacity-80 active:scale-95'
|
||||
: 'opacity-30 cursor-not-allowed'"
|
||||
aria-label="Send message"
|
||||
@click="send"
|
||||
v-if="streaming"
|
||||
class="shrink-0 path-glass-button path-glass-button-sm rounded-xl px-3 transition-all duration-200 hover:opacity-80 active:scale-95"
|
||||
aria-label="Stop generation"
|
||||
@click="$emit('stop')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||||
</svg>
|
||||
</button>
|
||||
<template v-else>
|
||||
<!-- Extract/contextualize button — shown after paste -->
|
||||
<button
|
||||
v-if="hasPasted && canSend"
|
||||
:disabled="!canSend"
|
||||
class="shrink-0 path-glass-button path-glass-button-sm rounded-xl px-3 transition-all duration-200 hover:opacity-80 active:scale-95"
|
||||
:class="isDark ? 'text-accent/80' : 'text-accent'"
|
||||
aria-label="Extract content"
|
||||
title="Contextualize — extract media without sending to AI"
|
||||
@click="extract"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Send button -->
|
||||
<button
|
||||
:disabled="!canSend"
|
||||
class="shrink-0 path-glass-button path-glass-button-sm rounded-xl px-3 transition-all duration-200"
|
||||
:class="canSend
|
||||
? 'hover:opacity-80 active:scale-95'
|
||||
: 'opacity-30 cursor-not-allowed'"
|
||||
aria-label="Send message"
|
||||
@click="send"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -46,21 +74,26 @@ import { useTheme } from '@/composables/useTheme'
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
disabled?: boolean
|
||||
streaming?: boolean
|
||||
placeholder?: string
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
streaming: false,
|
||||
placeholder: 'Message AIUI...',
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
send: [text: string]
|
||||
extract: [text: string]
|
||||
stop: []
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const text = ref('')
|
||||
const focused = ref(false)
|
||||
const hasPasted = ref(false)
|
||||
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
const canSend = computed(() => text.value.trim().length > 0 && !props.disabled)
|
||||
@@ -69,9 +102,22 @@ function send() {
|
||||
if (!canSend.value) return
|
||||
emit('send', text.value.trim())
|
||||
text.value = ''
|
||||
hasPasted.value = false
|
||||
nextTick(autoResize)
|
||||
}
|
||||
|
||||
function extract() {
|
||||
if (!canSend.value) return
|
||||
emit('extract', text.value.trim())
|
||||
text.value = ''
|
||||
hasPasted.value = false
|
||||
nextTick(autoResize)
|
||||
}
|
||||
|
||||
function onPaste() {
|
||||
hasPasted.value = true
|
||||
}
|
||||
|
||||
function autoResize() {
|
||||
const el = textareaRef.value
|
||||
if (!el) return
|
||||
|
||||
@@ -47,8 +47,11 @@
|
||||
|
||||
<ChatInput
|
||||
:disabled="isStreaming"
|
||||
:streaming="isStreaming"
|
||||
:placeholder="isStreaming ? 'Waiting for response...' : 'Message AIUI...'"
|
||||
@send="handleSend"
|
||||
@extract="handleExtract"
|
||||
@stop="handleStop"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -85,7 +88,7 @@ defineEmits<{
|
||||
}>()
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const { sendMessage } = useAI()
|
||||
const { sendMessage, stopGeneration } = useAI()
|
||||
const { isDark } = useTheme()
|
||||
const { updatePanelFromText, panelOpen, activeTab, availableTabs, setActiveTab } = useContentPanel()
|
||||
import { useCodeContext } from '@/composables/useCodeContext'
|
||||
@@ -122,6 +125,18 @@ function handleNewChat() {
|
||||
chatStore.createConversation()
|
||||
}
|
||||
|
||||
function handleStop() {
|
||||
stopGeneration()
|
||||
}
|
||||
|
||||
function handleExtract(text: string) {
|
||||
// Run content extraction without sending to AI
|
||||
const convId = chatStore.activeConversationId ?? chatStore.createConversation()
|
||||
chatStore.addMessage(convId, { role: 'user', content: `[Extract] ${text.slice(0, 80)}${text.length > 80 ? '…' : ''}` })
|
||||
chatStore.addMessage(convId, { role: 'assistant', content: text })
|
||||
updatePanelFromText(text, '', [])
|
||||
}
|
||||
|
||||
async function handleSend(text: string) {
|
||||
// Command handling
|
||||
const trimmed = text.trim().toLowerCase()
|
||||
@@ -148,6 +163,20 @@ async function handleSend(text: string) {
|
||||
return
|
||||
}
|
||||
|
||||
if (trimmed === '/nostr') {
|
||||
panelOpen.value = true
|
||||
if (!availableTabs.value.includes('nostr')) {
|
||||
availableTabs.value = [...availableTabs.value, 'nostr']
|
||||
}
|
||||
setActiveTab('nostr')
|
||||
const convId = chatStore.activeConversationId
|
||||
if (convId) {
|
||||
chatStore.addMessage(convId, { role: 'user', content: '/nostr' })
|
||||
chatStore.addMessage(convId, { role: 'assistant', content: 'Nostr feed opened. Browse notes, articles, and zaps from the network.' })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (trimmed === '/code exit' || trimmed === '/exit') {
|
||||
if (codeContext.isCodeMode.value) {
|
||||
codeContext.exitCodeMode()
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
<div class="relative w-full overflow-hidden">
|
||||
<div class="w-full aspect-[16/7] flex items-center justify-center overflow-hidden bg-black/20">
|
||||
<img
|
||||
v-if="coverSrc"
|
||||
:src="coverSrc"
|
||||
v-if="bannerSrc"
|
||||
:src="bannerSrc"
|
||||
:alt="book.title"
|
||||
class="w-full h-full object-cover object-center block"
|
||||
@error="coverFailed = true"
|
||||
@error="onBannerError"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-full bg-cover bg-center"
|
||||
:style="{ backgroundImage: `url(${fallbackCover})` }"
|
||||
class="w-full h-full"
|
||||
:style="{ background: fallbackGradient }"
|
||||
/>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent pointer-events-none" />
|
||||
@@ -111,27 +111,27 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import type { Book } from '@aiui/core/types/content'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { generateBookCoverFallback, fetchBookCover } from '@/composables/useImageFallback'
|
||||
import { useBannerFallback } from '@/composables/useBannerFallback'
|
||||
import { fetchBookImage } from '@/composables/useImageFallback'
|
||||
|
||||
const props = defineProps<{ book: Book }>()
|
||||
defineEmits<{ back: [] }>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const coverFailed = ref(false)
|
||||
const fetchedCover = ref<string | null>(null)
|
||||
|
||||
const coverSrc = computed(() => {
|
||||
if (coverFailed.value) return null
|
||||
return props.book.coverUrl || fetchedCover.value
|
||||
const { bannerSrc, fallbackGradient, onBannerError } = useBannerFallback({
|
||||
primaryUrls: () => [props.book.coverUrl],
|
||||
apiFetch: async () => {
|
||||
const url = await fetchBookImage(props.book.title, props.book.author)
|
||||
return { posterUrl: url, backdropUrl: null }
|
||||
},
|
||||
title: () => props.book.title,
|
||||
gradientSeed: () => props.book.title + (props.book.author ?? ''),
|
||||
})
|
||||
|
||||
const fallbackCover = computed(() =>
|
||||
generateBookCoverFallback(props.book.title, props.book.author)
|
||||
)
|
||||
|
||||
const q = computed(() =>
|
||||
`${props.book.title} ${props.book.author}`.trim().replace(/\s+/g, '+'),
|
||||
)
|
||||
@@ -155,10 +155,4 @@ function sourceIcon(type: string): string {
|
||||
return icons[type] ?? '📚'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.book.coverUrl) return
|
||||
fetchBookCover(props.book.title, props.book.author).then((url) => {
|
||||
if (url) fetchedCover.value = url
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -5,131 +5,73 @@
|
||||
:films="panelFilms"
|
||||
:title="panelTitle"
|
||||
@select-film="openFilmDetail"
|
||||
>
|
||||
<template #header-actions>
|
||||
<slot name="header-actions">
|
||||
<CloseButton @click="$emit('close')" />
|
||||
</slot>
|
||||
</template>
|
||||
</FilmGrid>
|
||||
/>
|
||||
<BookGrid
|
||||
v-else-if="activeTab === 'book'"
|
||||
:books="panelBooks"
|
||||
:title="panelTitle"
|
||||
@select-book="openBookDetail"
|
||||
>
|
||||
<template #header-actions>
|
||||
<slot name="header-actions">
|
||||
<CloseButton @click="$emit('close')" />
|
||||
</slot>
|
||||
</template>
|
||||
</BookGrid>
|
||||
/>
|
||||
<TVSeriesGrid
|
||||
v-else-if="activeTab === 'tvshow'"
|
||||
:series="panelTVSeries"
|
||||
:title="panelTitle"
|
||||
@select-series="openTVSeriesDetail"
|
||||
>
|
||||
<template #header-actions>
|
||||
<slot name="header-actions">
|
||||
<CloseButton @click="$emit('close')" />
|
||||
</slot>
|
||||
</template>
|
||||
</TVSeriesGrid>
|
||||
/>
|
||||
<ImageGrid
|
||||
v-else-if="activeTab === 'image'"
|
||||
:images="panelImages"
|
||||
:title="panelTitle"
|
||||
@select-image="openImageDetail"
|
||||
>
|
||||
<template #header-actions>
|
||||
<slot name="header-actions">
|
||||
<CloseButton @click="$emit('close')" />
|
||||
</slot>
|
||||
</template>
|
||||
</ImageGrid>
|
||||
/>
|
||||
<PlaceGrid
|
||||
v-else-if="activeTab === 'place'"
|
||||
:places="panelPlaces"
|
||||
:title="panelTitle"
|
||||
@select-place="openPlaceDetail"
|
||||
>
|
||||
<template #header-actions>
|
||||
<slot name="header-actions">
|
||||
<CloseButton @click="$emit('close')" />
|
||||
</slot>
|
||||
</template>
|
||||
</PlaceGrid>
|
||||
/>
|
||||
<SongGrid
|
||||
v-else-if="activeTab === 'song'"
|
||||
:songs="panelSongs"
|
||||
:title="panelTitle"
|
||||
@select-song="openSongDetail"
|
||||
>
|
||||
<template #header-actions>
|
||||
<slot name="header-actions">
|
||||
<CloseButton @click="$emit('close')" />
|
||||
</slot>
|
||||
</template>
|
||||
</SongGrid>
|
||||
/>
|
||||
<MagazineGrid
|
||||
v-else-if="activeTab === 'magazine'"
|
||||
:sections="panelMagazineSections"
|
||||
:hero-image-url="panelMagazineHeroImage"
|
||||
:title="panelTitle"
|
||||
:query="panelQuery"
|
||||
>
|
||||
<template #header-actions>
|
||||
<slot name="header-actions">
|
||||
<CloseButton @click="$emit('close')" />
|
||||
</slot>
|
||||
</template>
|
||||
</MagazineGrid>
|
||||
/>
|
||||
<NewsGrid
|
||||
v-else-if="activeTab === 'news'"
|
||||
:articles="panelWebResults"
|
||||
:title="panelTitle"
|
||||
:query="panelQuery"
|
||||
>
|
||||
<template #header-actions>
|
||||
<slot name="header-actions">
|
||||
<CloseButton @click="$emit('close')" />
|
||||
</slot>
|
||||
</template>
|
||||
</NewsGrid>
|
||||
/>
|
||||
<NewsGrid
|
||||
v-else-if="activeTab === 'websites'"
|
||||
:articles="panelWebsites"
|
||||
:title="panelTitle"
|
||||
variant="websites"
|
||||
>
|
||||
<template #header-actions>
|
||||
<slot name="header-actions">
|
||||
<CloseButton @click="$emit('close')" />
|
||||
</slot>
|
||||
</template>
|
||||
</NewsGrid>
|
||||
/>
|
||||
<PodcastGrid
|
||||
v-else-if="activeTab === 'podcast'"
|
||||
:podcasts="panelPodcasts"
|
||||
:title="panelTitle"
|
||||
@select-podcast="openPodcastDetail"
|
||||
>
|
||||
<template #header-actions>
|
||||
<slot name="header-actions">
|
||||
<CloseButton @click="$emit('close')" />
|
||||
</slot>
|
||||
</template>
|
||||
</PodcastGrid>
|
||||
/>
|
||||
<ProjectGrid
|
||||
v-else-if="activeTab === 'code'"
|
||||
>
|
||||
<template #header-actions>
|
||||
<slot name="header-actions">
|
||||
<CloseButton @click="$emit('close')" />
|
||||
</slot>
|
||||
</template>
|
||||
</ProjectGrid>
|
||||
:is-wide-desktop="isWideDesktop"
|
||||
:is-mobile="isMobile"
|
||||
/>
|
||||
<DesignSystemGrid
|
||||
v-else-if="activeTab === 'design-system'"
|
||||
/>
|
||||
<NostrGrid
|
||||
v-else-if="activeTab === 'nostr'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -147,11 +89,14 @@ import SongGrid from './SongGrid.vue'
|
||||
import PodcastGrid from './PodcastGrid.vue'
|
||||
import MagazineGrid from './MagazineGrid.vue'
|
||||
import NewsGrid from './NewsGrid.vue'
|
||||
import CloseButton from './CloseButton.vue'
|
||||
import ProjectGrid from './ProjectGrid.vue'
|
||||
import DesignSystemGrid from './DesignSystemGrid.vue'
|
||||
import NostrGrid from './NostrGrid.vue'
|
||||
|
||||
defineProps<{
|
||||
activeTab: ContentTab
|
||||
isWideDesktop?: boolean
|
||||
isMobile?: boolean
|
||||
panelFilms: Film[]
|
||||
panelBooks: Book[]
|
||||
panelTVSeries: TVSeries[]
|
||||
@@ -167,8 +112,6 @@ defineProps<{
|
||||
panelQuery: string
|
||||
}>()
|
||||
|
||||
defineEmits<{ close: [] }>()
|
||||
|
||||
const {
|
||||
openFilmDetail,
|
||||
openBookDetail,
|
||||
|
||||
@@ -140,6 +140,9 @@
|
||||
<ProjectGrid
|
||||
v-else-if="activeTab === 'code'"
|
||||
/>
|
||||
<NostrGrid
|
||||
v-else-if="activeTab === 'nostr'"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</Transition>
|
||||
@@ -163,6 +166,7 @@ import NewsGrid from './NewsGrid.vue'
|
||||
import ArticleDetail from './ArticleDetail.vue'
|
||||
import MagazineGrid from './MagazineGrid.vue'
|
||||
import ProjectGrid from './ProjectGrid.vue'
|
||||
import NostrGrid from './NostrGrid.vue'
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const {
|
||||
@@ -186,6 +190,7 @@ const {
|
||||
selectedSong,
|
||||
selectedPodcast,
|
||||
selectedArticle,
|
||||
selectedDesignSystemItem,
|
||||
setActiveTab,
|
||||
openFilmDetail,
|
||||
closeFilmDetail,
|
||||
@@ -202,7 +207,7 @@ const {
|
||||
} = useContentPanel()
|
||||
|
||||
const hasDetailOpen = computed(() =>
|
||||
!!(selectedFilm.value || selectedBook.value || selectedTVSeries.value || selectedSong.value || selectedPodcast.value || selectedArticle.value)
|
||||
!!(selectedFilm.value || selectedBook.value || selectedTVSeries.value || selectedSong.value || selectedPodcast.value || selectedArticle.value || selectedDesignSystemItem.value)
|
||||
)
|
||||
|
||||
const windowWidth = ref(window.innerWidth)
|
||||
@@ -224,6 +229,8 @@ const TAB_LABELS: Record<ContentTab, string> = {
|
||||
websites: 'Web',
|
||||
magazine: 'Brief',
|
||||
code: 'Code',
|
||||
'design-system': 'Design',
|
||||
nostr: 'Nostr',
|
||||
}
|
||||
|
||||
function tabLabel(tab: ContentTab): string {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<slot name="header-actions" />
|
||||
</div>
|
||||
</div>
|
||||
<LoadingFilmGrid :count="12" />
|
||||
<LoadingContentGrid :variant="skeletonVariant" :count="skeletonCount" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -71,7 +71,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import LoadingFilmGrid from './LoadingFilmGrid.vue'
|
||||
import LoadingContentGrid from './LoadingContentGrid.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -94,6 +94,19 @@ const contextLabel = computed(() => {
|
||||
if (props.contextType === 'magazine') return 'Brief'
|
||||
return 'Content'
|
||||
})
|
||||
|
||||
const skeletonVariant = computed<'poster' | 'square' | 'list' | 'magazine'>(() => {
|
||||
if (['song', 'podcast', 'image'].includes(props.contextType)) return 'square'
|
||||
if (['news', 'websites'].includes(props.contextType)) return 'list'
|
||||
if (props.contextType === 'magazine') return 'magazine'
|
||||
return 'poster'
|
||||
})
|
||||
|
||||
const skeletonCount = computed(() => {
|
||||
if (props.contextType === 'magazine') return 6
|
||||
if (['news', 'websites'].includes(props.contextType)) return 6
|
||||
return 12
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
<template>
|
||||
<div class="h-full overflow-y-auto overflow-x-hidden scrollbar-hide">
|
||||
<!-- Header -->
|
||||
<div class="shrink-0 px-4 py-3 flex items-center gap-3"
|
||||
:style="isDark
|
||||
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
|
||||
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'">
|
||||
<button
|
||||
class="w-7 h-7 rounded-lg path-glass-icon flex items-center justify-center transition-colors shrink-0"
|
||||
:class="isDark ? 'hover:bg-white/10' : 'hover:bg-black/5'"
|
||||
@click="$emit('back')"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" :class="isDark ? 'text-white/70' : 'text-gray-500'"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-sm font-semibold truncate"
|
||||
:class="isDark ? 'text-white/90' : 'text-gray-900'">
|
||||
{{ item.name }}
|
||||
</h2>
|
||||
<p class="text-[10px]"
|
||||
:class="isDark ? 'text-white/40' : 'text-gray-400'">
|
||||
{{ categoryLabel }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="text-[10px] px-2 py-1 rounded-md transition-colors"
|
||||
:class="copied
|
||||
? 'bg-emerald-500/20 text-emerald-400'
|
||||
: isDark
|
||||
? 'bg-white/5 text-white/50 hover:bg-white/10'
|
||||
: 'bg-black/5 text-gray-500 hover:bg-black/10'"
|
||||
@click="copyCode"
|
||||
>
|
||||
{{ copied ? 'Copied' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-4 space-y-4">
|
||||
<!-- Description -->
|
||||
<p class="text-sm leading-relaxed"
|
||||
:class="isDark ? 'text-white/60' : 'text-gray-600'">
|
||||
{{ item.description }}
|
||||
</p>
|
||||
|
||||
<!-- Live preview -->
|
||||
<div>
|
||||
<h4 class="text-[10px] uppercase tracking-[0.2em] font-semibold mb-2"
|
||||
:class="isDark ? 'text-white/30' : 'text-gray-400'">
|
||||
Preview
|
||||
</h4>
|
||||
<div class="rounded-xl p-4 overflow-hidden"
|
||||
:class="isDark ? 'bg-white/[0.03] border border-white/10' : 'bg-black/[0.02] border border-black/10'">
|
||||
<!-- Color preview -->
|
||||
<div v-if="item.category === 'colors'" class="space-y-2">
|
||||
<div class="h-12 rounded-lg border"
|
||||
:class="isDark ? 'border-white/10' : 'border-black/10'"
|
||||
:style="{ background: extractColorValue(item.code) }" />
|
||||
<p class="text-[10px] font-mono text-center"
|
||||
:class="isDark ? 'text-white/40' : 'text-gray-400'">
|
||||
{{ extractColorValue(item.code) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Typography preview -->
|
||||
<div v-else-if="item.category === 'typography'" class="space-y-2">
|
||||
<p class="text-2xl font-bold"
|
||||
:class="isDark ? 'text-white/90' : 'text-gray-900'"
|
||||
:style="fontStyle">
|
||||
Aa Bb Cc 123
|
||||
</p>
|
||||
<p class="text-sm"
|
||||
:class="isDark ? 'text-white/60' : 'text-gray-600'"
|
||||
:style="fontStyle">
|
||||
The quick brown fox jumps over the lazy dog.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Spacing preview -->
|
||||
<div v-else-if="item.category === 'spacing'" class="flex items-end gap-2">
|
||||
<div v-for="(size, i) in [4, 8, 12, 16, 20, 24, 32]" :key="i"
|
||||
class="bg-accent/30 rounded-sm flex items-center justify-center"
|
||||
:style="{ width: `${size}px`, height: `${size}px` }">
|
||||
<span v-if="size >= 16" class="text-[7px] text-accent font-mono">{{ size }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Component preview (rendered as styled blocks) -->
|
||||
<div v-else class="space-y-2">
|
||||
<!-- Glass button preview -->
|
||||
<div v-if="item.id === 'atom-glass-btn'" class="flex gap-2">
|
||||
<button class="glass-button text-sm">Action</button>
|
||||
</div>
|
||||
<div v-else-if="item.id === 'atom-glass-btn-sm'" class="flex gap-2">
|
||||
<button class="glass-button-sm text-xs">Small</button>
|
||||
</div>
|
||||
<div v-else-if="item.id === 'atom-gradient-btn'" class="flex gap-2">
|
||||
<button class="gradient-button text-sm">Primary Action</button>
|
||||
</div>
|
||||
<div v-else-if="item.id === 'atom-icon-btn'" class="flex gap-3">
|
||||
<button class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center"
|
||||
:class="isDark ? 'text-white/70' : 'text-gray-500'">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center"
|
||||
:class="isDark ? 'text-white/70' : 'text-gray-500'">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center"
|
||||
:class="isDark ? 'text-white/70' : 'text-gray-500'">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="item.id === 'atom-badge'" class="flex flex-wrap gap-1.5">
|
||||
<span class="text-[10px] px-2 py-1 rounded-md font-medium"
|
||||
:class="isDark ? 'bg-white/10 text-white/60' : 'bg-black/5 text-gray-600'">Science Fiction</span>
|
||||
<span class="text-[10px] px-2 py-1 rounded-md font-medium"
|
||||
:class="isDark ? 'bg-white/10 text-white/60' : 'bg-black/5 text-gray-600'">Drama</span>
|
||||
<span class="text-[10px] px-2 py-1 rounded-md font-medium"
|
||||
:class="isDark ? 'bg-white/10 text-white/60' : 'bg-black/5 text-gray-600'">Thriller</span>
|
||||
</div>
|
||||
<div v-else-if="item.id === 'mol-glass-card'">
|
||||
<div class="glass-card p-4">
|
||||
<h3 class="text-sm font-semibold mb-1" :class="isDark ? 'text-white/90' : 'text-gray-900'">Glass Card</h3>
|
||||
<p class="text-xs" :class="isDark ? 'text-white/60' : 'text-gray-500'">Content with frosted glass background and subtle border.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nav tab preview -->
|
||||
<div v-else-if="item.id === 'atom-nav-tab'" class="flex gap-1.5">
|
||||
<button class="text-[10px] px-2.5 py-1 rounded-md font-medium bg-accent/20 text-accent">Films</button>
|
||||
<button class="text-[10px] px-2.5 py-1 rounded-md font-medium"
|
||||
:class="isDark ? 'bg-white/5 text-white/50' : 'bg-black/5 text-gray-500'">Songs</button>
|
||||
<button class="text-[10px] px-2.5 py-1 rounded-md font-medium"
|
||||
:class="isDark ? 'bg-white/5 text-white/50' : 'bg-black/5 text-gray-500'">Podcasts</button>
|
||||
</div>
|
||||
|
||||
<!-- Text input preview -->
|
||||
<div v-else-if="item.id === 'atom-input'">
|
||||
<input
|
||||
class="w-full px-3 py-2 rounded-lg text-xs outline-none transition-colors"
|
||||
:class="isDark
|
||||
? 'bg-white/5 text-white/80 placeholder:text-white/25 focus:bg-white/10'
|
||||
: 'bg-black/5 text-gray-800 placeholder:text-gray-400 focus:bg-black/10'"
|
||||
placeholder="Search..."
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Scrollbar preview -->
|
||||
<div v-else-if="item.id === 'atom-scrollbar'" class="space-y-2">
|
||||
<div class="h-16 overflow-y-auto rounded-lg px-3 py-2"
|
||||
:class="isDark ? 'bg-white/5' : 'bg-black/5'"
|
||||
style="scrollbar-width: thin;">
|
||||
<p v-for="n in 8" :key="n" class="text-[10px] py-0.5"
|
||||
:class="isDark ? 'text-white/40' : 'text-gray-400'">
|
||||
Scrollable content line {{ n }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-[10px] text-center" :class="isDark ? 'text-white/30' : 'text-gray-400'">
|
||||
4px wide, translucent thumb
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Gradient card preview -->
|
||||
<div v-else-if="item.id === 'mol-gradient-card'">
|
||||
<div class="gradient-card p-4 rounded-2xl">
|
||||
<h3 class="text-sm font-semibold mb-1 text-white">Featured</h3>
|
||||
<p class="text-xs text-white/70">Gradient background card for highlights.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Source link row preview -->
|
||||
<div v-else-if="item.id === 'mol-source-link'" class="space-y-1.5">
|
||||
<div class="flex items-center justify-between p-3 rounded-xl transition-colors"
|
||||
:class="isDark ? 'bg-white/5' : 'bg-black/5'">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span class="text-sm">🎬</span>
|
||||
<div>
|
||||
<p class="text-xs font-medium" :class="isDark ? 'text-white/80' : 'text-gray-800'">Netflix</p>
|
||||
<p class="text-[10px]" :class="isDark ? 'text-white/30' : 'text-gray-400'">Stream now</p>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="w-3.5 h-3.5" :class="isDark ? 'text-white/30' : 'text-gray-400'"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Banner hero preview -->
|
||||
<div v-else-if="item.id === 'mol-banner-hero'">
|
||||
<div class="relative w-full aspect-[16/7] rounded-lg overflow-hidden">
|
||||
<div class="absolute inset-0"
|
||||
:style="{ background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)' }" />
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
|
||||
<div class="absolute bottom-0 left-0 p-3">
|
||||
<h3 class="text-sm font-bold text-white/90">Banner Title</h3>
|
||||
<p class="text-[10px] text-white/50">Subtitle text</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cover card preview -->
|
||||
<div v-else-if="item.id === 'mol-cover-card'" class="flex gap-2">
|
||||
<div v-for="n in 3" :key="n"
|
||||
class="flex-1 rounded-xl overflow-hidden">
|
||||
<div class="aspect-[2/3] relative"
|
||||
:style="{ background: `linear-gradient(${120 * n}deg, ${['#2d1b69','#1b3a4b','#3b1b2b'][n-1]}, ${['#1a0a3e','#0a2030','#200a1a'][n-1]})` }">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
|
||||
<div class="absolute bottom-0 left-0 right-0 p-1.5">
|
||||
<p class="text-[9px] text-white/80 font-medium truncate">{{ ['Film', 'Album', 'Series'][n-1] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat bubble preview -->
|
||||
<div v-else-if="item.id === 'org-chat-bubble'" class="space-y-2">
|
||||
<div class="flex justify-end">
|
||||
<div class="max-w-[80%] px-3 py-2 rounded-2xl text-[11px]"
|
||||
:class="isDark ? 'bg-white/10 text-white/90' : 'bg-black/10 text-gray-800'">
|
||||
What films should I watch?
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-start">
|
||||
<div class="max-w-[80%] px-3 py-2 text-[11px]"
|
||||
:class="isDark ? 'text-white/70' : 'text-gray-600'">
|
||||
Here are some great picks from your library...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content panel preview -->
|
||||
<div v-else-if="item.id === 'org-content-panel'" class="space-y-2">
|
||||
<div class="flex gap-1 pb-1.5"
|
||||
:style="isDark ? 'border-bottom: 1px solid rgba(255,255,255,0.08)' : 'border-bottom: 1px solid rgba(0,0,0,0.06)'">
|
||||
<span class="text-[9px] px-2 py-0.5 rounded font-medium bg-accent/20 text-accent">Films</span>
|
||||
<span class="text-[9px] px-2 py-0.5 rounded font-medium"
|
||||
:class="isDark ? 'text-white/40' : 'text-gray-400'">Songs</span>
|
||||
<span class="text-[9px] px-2 py-0.5 rounded font-medium"
|
||||
:class="isDark ? 'text-white/40' : 'text-gray-400'">Books</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-1">
|
||||
<div v-for="n in 6" :key="n" class="aspect-[2/3] rounded-md"
|
||||
:class="isDark ? 'bg-white/5' : 'bg-black/5'" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail view preview -->
|
||||
<div v-else-if="item.id === 'org-detail-view'" class="space-y-2">
|
||||
<div class="relative aspect-[16/7] rounded-lg overflow-hidden"
|
||||
:class="isDark ? 'bg-white/5' : 'bg-black/5'">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
|
||||
<div class="absolute top-1.5 left-1.5 w-4 h-4 rounded-md flex items-center justify-center"
|
||||
:class="isDark ? 'bg-white/10' : 'bg-black/10'">
|
||||
<svg class="w-2.5 h-2.5" :class="isDark ? 'text-white/60' : 'text-gray-500'"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="absolute bottom-1 left-2">
|
||||
<p class="text-[10px] font-bold text-white/90">Title</p>
|
||||
<p class="text-[8px] text-white/50">Meta</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1 px-1">
|
||||
<div class="h-1.5 rounded-full w-full" :class="isDark ? 'bg-white/5' : 'bg-black/5'" />
|
||||
<div class="h-1.5 rounded-full w-3/4" :class="isDark ? 'bg-white/5' : 'bg-black/5'" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Magazine grid preview -->
|
||||
<div v-else-if="item.id === 'org-magazine'">
|
||||
<div class="grid grid-cols-2 gap-px rounded-lg overflow-hidden"
|
||||
:class="isDark ? 'bg-white/[0.12]' : 'bg-black/[0.08]'">
|
||||
<div class="col-span-2 px-3 py-3"
|
||||
:class="isDark ? 'bg-[#0a0a0a]' : 'bg-white'">
|
||||
<p class="text-[7px] uppercase tracking-[0.3em] mb-0.5"
|
||||
:class="isDark ? 'text-white/30' : 'text-gray-400'">Editorial</p>
|
||||
<p class="text-[11px] font-serif font-bold"
|
||||
:class="isDark ? 'text-white/90' : 'text-gray-900'">Hero Headline</p>
|
||||
</div>
|
||||
<div class="px-2 py-2" :class="isDark ? 'bg-[#0a0a0a]' : 'bg-white'">
|
||||
<p class="text-[9px] font-serif font-bold"
|
||||
:class="isDark ? 'text-white/80' : 'text-gray-800'">Half Tile</p>
|
||||
</div>
|
||||
<div class="px-2 py-2" :class="isDark ? 'bg-[#0a0a0a]' : 'bg-white'">
|
||||
<p class="text-[9px] font-serif font-bold"
|
||||
:class="isDark ? 'text-white/80' : 'text-gray-800'">Half Tile</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nostr note preview -->
|
||||
<div v-else-if="item.id === 'org-nostr-note'">
|
||||
<div class="p-3 rounded-xl"
|
||||
:class="isDark ? 'bg-white/[0.03] border border-white/5' : 'bg-black/[0.02] border border-black/5'">
|
||||
<div class="flex items-start gap-2.5">
|
||||
<div class="w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0"
|
||||
style="background: rgba(168, 85, 247, 0.2); color: rgba(168, 85, 247, 0.8);">
|
||||
F
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-[11px] font-semibold" :class="isDark ? 'text-white/80' : 'text-gray-800'">fiatjaf</span>
|
||||
<span class="text-[9px]" :class="isDark ? 'text-white/25' : 'text-gray-300'">2h</span>
|
||||
</div>
|
||||
<p class="text-[10px] mt-0.5 leading-relaxed"
|
||||
:class="isDark ? 'text-white/50' : 'text-gray-500'">
|
||||
Nostr is the simplest open protocol...
|
||||
</p>
|
||||
<div class="flex gap-3 mt-1.5">
|
||||
<span class="text-[9px]" :class="isDark ? 'text-white/25' : 'text-gray-300'">3 replies</span>
|
||||
<span class="text-[9px] text-amber-500/70">21000 sats</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fade up animation preview -->
|
||||
<div v-else-if="item.id === 'anim-fade-up'" class="flex flex-col items-center gap-2">
|
||||
<div :key="fadeUpKey" class="animate-fade-up px-4 py-2 rounded-lg text-xs font-medium"
|
||||
:class="isDark ? 'bg-white/10 text-white/70' : 'bg-black/10 text-gray-600'">
|
||||
Fade Up (900ms)
|
||||
</div>
|
||||
<button class="text-[10px] px-2 py-0.5 rounded transition-colors"
|
||||
:class="isDark ? 'text-white/40 hover:text-white/60' : 'text-gray-400 hover:text-gray-600'"
|
||||
@click="fadeUpKey++">
|
||||
Replay
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Scale in animation preview -->
|
||||
<div v-else-if="item.id === 'anim-scale-in'" class="flex flex-col items-center gap-2">
|
||||
<div :key="scaleInKey" class="animate-scale-in px-4 py-2 rounded-lg text-xs font-medium"
|
||||
:class="isDark ? 'bg-white/10 text-white/70' : 'bg-black/10 text-gray-600'">
|
||||
Scale In (250ms)
|
||||
</div>
|
||||
<button class="text-[10px] px-2 py-0.5 rounded transition-colors"
|
||||
:class="isDark ? 'text-white/40 hover:text-white/60' : 'text-gray-400 hover:text-gray-600'"
|
||||
@click="scaleInKey++">
|
||||
Replay
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Generic preview fallback -->
|
||||
<div v-else class="text-center py-4">
|
||||
<p class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">
|
||||
See code below for usage pattern
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Used In -->
|
||||
<div v-if="item.usedIn">
|
||||
<h4 class="text-[10px] uppercase tracking-[0.2em] font-semibold mb-2"
|
||||
:class="isDark ? 'text-white/30' : 'text-gray-400'">
|
||||
Used In
|
||||
</h4>
|
||||
<div class="rounded-xl px-3 py-2.5"
|
||||
:class="isDark ? 'bg-white/[0.03] border border-white/10' : 'bg-black/[0.02] border border-black/10'">
|
||||
<p class="text-xs leading-relaxed"
|
||||
:class="isDark ? 'text-white/50' : 'text-gray-500'">
|
||||
{{ item.usedIn }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Code block -->
|
||||
<div>
|
||||
<h4 class="text-[10px] uppercase tracking-[0.2em] font-semibold mb-2"
|
||||
:class="isDark ? 'text-white/30' : 'text-gray-400'">
|
||||
Code
|
||||
</h4>
|
||||
<pre class="rounded-xl p-4 text-xs leading-relaxed font-mono overflow-x-auto"
|
||||
:class="isDark
|
||||
? 'bg-black/40 text-white/70 border border-white/10'
|
||||
: 'bg-gray-50 text-gray-700 border border-gray-200'">{{ item.code }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import type { DesignSystemItem } from '@/composables/useContentPanel'
|
||||
|
||||
const props = defineProps<{ item: DesignSystemItem }>()
|
||||
defineEmits<{ back: [] }>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const copied = ref(false)
|
||||
const fadeUpKey = ref(0)
|
||||
const scaleInKey = ref(0)
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
colors: 'Colors',
|
||||
typography: 'Typography',
|
||||
spacing: 'Spacing',
|
||||
atoms: 'Atoms',
|
||||
molecules: 'Molecules',
|
||||
organisms: 'Organisms',
|
||||
}
|
||||
|
||||
const categoryLabel = computed(() => categoryLabels[props.item.category] ?? props.item.category)
|
||||
|
||||
const fontStyle = computed(() => {
|
||||
if (props.item.id === 'type-mono') return { fontFamily: 'Menlo, Monaco, "Courier New", monospace' }
|
||||
if (props.item.id === 'type-serif') return { fontFamily: 'Georgia, "Times New Roman", Times, serif' }
|
||||
return { fontFamily: 'Inter, system-ui, -apple-system, sans-serif' }
|
||||
})
|
||||
|
||||
function extractColorValue(code: string): string {
|
||||
const match = /(?:background-color|color|background):\s*([^;]+)/i.exec(code)
|
||||
if (!match) return '#333'
|
||||
return match[1].trim()
|
||||
}
|
||||
|
||||
async function copyCode() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.item.code)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="shrink-0 px-4 py-3 flex items-center justify-between gap-2"
|
||||
:style="isDark
|
||||
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
|
||||
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'">
|
||||
<span class="text-sm font-semibold"
|
||||
:class="isDark ? 'text-white/90' : 'text-gray-900'">
|
||||
Design System
|
||||
</span>
|
||||
<p class="text-[10px]"
|
||||
:class="isDark ? 'text-white/30' : 'text-gray-400'">
|
||||
{{ filteredItems.length }} items
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Category filter -->
|
||||
<div class="shrink-0 px-4 py-2 flex gap-1.5 overflow-x-auto scrollbar-hide">
|
||||
<button
|
||||
v-for="cat in categories"
|
||||
:key="cat.id"
|
||||
class="text-[10px] px-2.5 py-1 rounded-md font-medium whitespace-nowrap transition-colors"
|
||||
:class="activeCategory === cat.id
|
||||
? 'bg-accent/20 text-accent'
|
||||
: isDark
|
||||
? 'bg-white/5 text-white/50 hover:bg-white/10'
|
||||
: 'bg-black/5 text-gray-500 hover:bg-black/10'"
|
||||
@click="activeCategory = cat.id"
|
||||
>
|
||||
{{ cat.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Items grid -->
|
||||
<div class="flex-1 overflow-y-auto px-4 py-3">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
v-for="item in filteredItems"
|
||||
:key="item.id"
|
||||
class="text-left p-3 rounded-xl transition-colors group cursor-pointer"
|
||||
:class="isDark
|
||||
? 'bg-white/[0.03] hover:bg-white/[0.07]'
|
||||
: 'bg-black/[0.02] hover:bg-black/[0.05]'"
|
||||
@click="selectItem(item)"
|
||||
>
|
||||
<!-- Preview swatch for colors -->
|
||||
<div v-if="item.category === 'colors' && item.preview === 'inline'"
|
||||
class="h-8 rounded-md mb-2 border"
|
||||
:class="isDark ? 'border-white/10' : 'border-black/10'"
|
||||
:style="{ background: extractColorValue(item.code) }" />
|
||||
|
||||
<!-- Preview for spacing -->
|
||||
<div v-else-if="item.category === 'spacing' && item.preview === 'inline'"
|
||||
class="h-8 flex items-end gap-0.5 mb-2">
|
||||
<div class="bg-accent/40 rounded-sm" style="width: 4px; height: 30%" />
|
||||
<div class="bg-accent/40 rounded-sm" style="width: 4px; height: 50%" />
|
||||
<div class="bg-accent/40 rounded-sm" style="width: 4px; height: 70%" />
|
||||
<div class="bg-accent/40 rounded-sm" style="width: 4px; height: 100%" />
|
||||
</div>
|
||||
|
||||
<!-- Generic icon for components -->
|
||||
<div v-else class="h-8 flex items-center mb-2">
|
||||
<svg class="w-5 h-5 transition-colors"
|
||||
:class="isDark ? 'text-white/20 group-hover:text-white/40' : 'text-black/15 group-hover:text-black/30'"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path v-if="item.category === 'atoms'" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
<path v-else-if="item.category === 'molecules'" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
<path v-else-if="item.category === 'organisms'" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
|
||||
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 class="text-xs font-semibold leading-tight mb-0.5"
|
||||
:class="isDark ? 'text-white/80' : 'text-gray-800'">
|
||||
{{ item.name }}
|
||||
</h3>
|
||||
<p class="text-[10px] leading-snug line-clamp-2"
|
||||
:class="isDark ? 'text-white/40' : 'text-gray-400'">
|
||||
{{ item.description }}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useContentPanel, type DesignSystemItem } from '@/composables/useContentPanel'
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const { openDesignSystemItem } = useContentPanel()
|
||||
|
||||
const activeCategory = ref<string>('all')
|
||||
|
||||
const categories = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'colors', label: 'Colors' },
|
||||
{ id: 'typography', label: 'Typography' },
|
||||
{ id: 'spacing', label: 'Spacing' },
|
||||
{ id: 'atoms', label: 'Atoms' },
|
||||
{ id: 'molecules', label: 'Molecules' },
|
||||
{ id: 'organisms', label: 'Organisms' },
|
||||
]
|
||||
|
||||
const items: DesignSystemItem[] = [
|
||||
// Colors
|
||||
{ id: 'color-bg', name: 'Background', category: 'colors', preview: 'inline', description: 'Primary app background', code: 'background-color: #0a0a0a;\n/* Tailwind: bg-[#0a0a0a] */', usedIn: 'ChatPage, all panels, base layout' },
|
||||
{ id: 'color-accent', name: 'Accent / Bitcoin', category: 'colors', preview: 'inline', description: 'Primary action color, Bitcoin orange', code: 'color: #F7931A;\n/* Tailwind: text-accent */', usedIn: 'Gradient buttons, active tabs, zap counts, CTA elements' },
|
||||
{ id: 'color-primary', name: 'Primary', category: 'colors', preview: 'inline', description: 'Primary neutral tone', code: 'color: #606060;\n/* Tailwind: text-primary */', usedIn: 'Secondary text, borders, muted elements' },
|
||||
{ id: 'color-surface', name: 'Glass Surface', category: 'colors', preview: 'inline', description: 'Glass morphism panel background', code: 'background: rgba(0, 0, 0, 0.35);\nbackdrop-filter: blur(18px);\nborder: 1px solid rgba(255, 255, 255, 0.18);\n/* Tailwind: .glass */', usedIn: 'ChatInput, ContentPanel, all overlay panels' },
|
||||
{ id: 'color-text-scale', name: 'Text Opacity Scale', category: 'colors', preview: 'inline', description: '/25 placeholder, /40 muted, /60 secondary, /80 body, /90 emphasis', code: '/* Text opacity scale */\n.placeholder { color: rgba(255,255,255, 0.25); }\n.muted { color: rgba(255,255,255, 0.40); }\n.secondary { color: rgba(255,255,255, 0.60); }\n.body { color: rgba(255,255,255, 0.80); }\n.emphasis { color: rgba(255,255,255, 0.90); }\n.heading { color: rgba(255,255,255, 0.96); }', usedIn: 'Every component — consistent hierarchy across the system' },
|
||||
|
||||
// Typography
|
||||
{ id: 'type-body', name: 'Body Font', category: 'typography', description: 'Inter / system-ui for all body text', code: 'font-family: Inter, system-ui, -apple-system, sans-serif;\n/* Applied globally */', usedIn: 'Global default — ChatMessage, grids, detail views' },
|
||||
{ id: 'type-mono', name: 'Monospace Font', category: 'typography', description: 'Menlo / Monaco for code and IDs', code: 'font-family: Menlo, Monaco, "Courier New", monospace;\n/* Tailwind: font-mono */', usedIn: 'CodeDetail, conversation IDs, relay URLs, metadata' },
|
||||
{ id: 'type-serif', name: 'Serif Font', category: 'typography', description: 'Georgia for magazine/editorial layouts', code: 'font-family: Georgia, "Times New Roman", Times, serif;\n/* Used in MagazineGrid, AI Brief */', usedIn: 'MagazineGrid, MagazineSectionDetail, AI Brief' },
|
||||
{ id: 'type-sizes', name: 'Text Sizes', category: 'typography', description: 'Compact scale: 10px labels to 2xl headings', code: '/* Key sizes used */\ntext-[10px] /* labels, metadata */\ntext-xs /* 12px - secondary text */\ntext-sm /* 14px - body text */\ntext-base /* 16px - primary text */\ntext-lg /* 18px - section headings */\ntext-xl /* 20px - page headings */\ntext-2xl /* 24px - hero text */', usedIn: 'Globally — see specific usage in each size bracket' },
|
||||
|
||||
// Spacing
|
||||
{ id: 'space-grid', name: '4px Grid', category: 'spacing', preview: 'inline', description: 'All spacing follows a 4px base grid', code: '/* 4px grid system */\n1 = 4px /* micro gap */\n2 = 8px /* tight gap */\n3 = 12px /* small padding */\n4 = 16px /* standard padding */\n5 = 20px /* section padding */\n6 = 24px /* large gap */\n8 = 32px /* section spacing */\n12 = 48px /* large sections */', usedIn: 'Every layout — padding, margins, gaps between elements' },
|
||||
{ id: 'space-radius', name: 'Border Radius', category: 'spacing', preview: 'inline', description: 'Rounded corners from subtle to full', code: '/* Border radius scale */\nrounded-md /* 6px - badges, tags */\nrounded-lg /* 8px - buttons, inputs */\nrounded-xl /* 12px - cards, panels */\nrounded-2xl /* 16px - large panels */\nrounded-full /* pill buttons */', usedIn: 'Badges (md), buttons (lg), cards (xl), panels (2xl)' },
|
||||
|
||||
// Atoms
|
||||
{ id: 'atom-glass-btn', name: 'Glass Button', category: 'atoms', description: '48px height, glass morphism background', code: '<button class="glass-button">\n Action\n</button>\n\n/* glass-button:\n height: 48px\n background: rgba(0,0,0,0.6)\n backdrop-filter: blur(18px)\n border-radius: 12px\n border: 1px solid rgba(255,255,255,0.12)\n*/', usedIn: 'ChatInput send, modal actions, primary controls' },
|
||||
{ id: 'atom-glass-btn-sm', name: 'Glass Button Small', category: 'atoms', description: 'Compact glass button variant', code: '<button class="glass-button-sm">\n Small\n</button>\n\n/* Compact variant of glass-button */', usedIn: 'ChatInput send/stop buttons, inline actions' },
|
||||
{ id: 'atom-gradient-btn', name: 'Gradient Button', category: 'atoms', description: 'Primary CTA with accent gradient', code: '<button class="gradient-button">\n Primary Action\n</button>\n\n/* gradient-button:\n background: linear-gradient(135deg, #F7931A, #e8850f)\n height: 48px\n border-radius: 12px\n font-weight: 600\n*/', usedIn: 'Primary CTAs, onboarding, confirmation dialogs' },
|
||||
{ id: 'atom-icon-btn', name: 'Icon Button', category: 'atoms', description: 'Path glass icon, 32-36px square', code: '<button class="w-9 h-9 rounded-xl path-glass-icon\n flex items-center justify-center">\n <svg class="w-4 h-4" ...>\n</button>\n\n/* path-glass-icon:\n background: transparent\n transition: colors\n hover: bg-white/10\n*/', usedIn: 'ChatHeader toolbar, detail back buttons, close buttons' },
|
||||
{ id: 'atom-badge', name: 'Genre Badge', category: 'atoms', description: 'Tiny pill badge for tags/genres', code: '<span class="text-[10px] px-2 py-1 rounded-md\n font-medium bg-white/10 text-white/60">\n Science Fiction\n</span>', usedIn: 'FilmGrid, SongGrid, BookGrid, TVSeriesGrid genre filters' },
|
||||
{ id: 'atom-nav-tab', name: 'Nav Tab', category: 'atoms', description: 'Content panel tab with active state', code: '<button class="nav-tab-active">\n Films\n</button>\n\n/* Active: accent underline\n Inactive: text-white/50 hover:text-white\n Transition: 200ms */', usedIn: 'ContentPanel tab bar, mobile content tab filters' },
|
||||
{ id: 'atom-input', name: 'Text Input', category: 'atoms', description: 'Search/filter input field', code: '<input\n class="w-full px-3 py-2 rounded-lg text-xs\n outline-none transition-colors\n bg-white/5 text-white/80\n placeholder:text-white/25\n focus:bg-white/10"\n placeholder="Search..."\n/>', usedIn: 'All grid search bars, ProjectGrid new project' },
|
||||
{ id: 'atom-scrollbar', name: 'Custom Scrollbar', category: 'atoms', description: 'Thin translucent scrollbar for scroll areas', code: '.custom-scrollbar::-webkit-scrollbar {\n width: 4px;\n}\n.custom-scrollbar::-webkit-scrollbar-thumb {\n background: rgba(255,255,255, 0.1);\n border-radius: 2px;\n}\n/* Also: .scrollbar-hide hides completely */', usedIn: 'Content grids, chat message list, file trees' },
|
||||
|
||||
// Molecules
|
||||
{ id: 'mol-glass-card', name: 'Glass Card', category: 'molecules', description: 'Frosted glass card with border', code: '<div class="glass-card">\n <h3>Title</h3>\n <p>Content</p>\n</div>\n\n/* glass-card:\n background: rgba(0,0,0,0.65)\n backdrop-filter: blur(18px)\n border: 1px solid rgba(255,255,255,0.12)\n border-radius: 16px\n padding: 16px\n*/', usedIn: 'ChatWindow container, content panel wrapper' },
|
||||
{ id: 'mol-gradient-card', name: 'Gradient Card', category: 'molecules', description: 'Card with gradient background', code: '<div class="gradient-card">\n <h3>Featured</h3>\n <p>Content</p>\n</div>\n\n/* gradient-card:\n background: linear-gradient(135deg, ...)\n border-radius: 16px\n*/', usedIn: 'Featured content highlights, promotional sections' },
|
||||
{ id: 'mol-source-link', name: 'Source Link Row', category: 'molecules', description: 'Icon + label + external link arrow', code: '<a class="flex items-center justify-between\n p-3 rounded-xl bg-white/5\n hover:bg-white/10 transition-colors">\n <div class="flex items-center gap-2.5">\n <span class="text-sm">icon</span>\n <div>\n <p class="text-xs font-medium\n text-white/80">Name</p>\n <p class="text-[10px]\n text-white/30">Description</p>\n </div>\n </div>\n <svg><!-- external link icon --></svg>\n</a>', usedIn: 'FilmDetail, SongDetail, PodcastDetail sources' },
|
||||
{ id: 'mol-banner-hero', name: 'Banner Hero', category: 'molecules', description: 'Aspect 16/7 image with gradient overlay', code: '<div class="relative w-full aspect-[16/7]\n overflow-hidden">\n <img :src="url" class="absolute inset-0\n w-full h-full object-cover" />\n <div class="absolute inset-0\n bg-gradient-to-t from-black/80\n via-black/30 to-transparent" />\n <div class="absolute bottom-0 p-4">\n <h2 class="text-lg font-bold\n text-white">Title</h2>\n </div>\n</div>', usedIn: 'FilmDetail, TVSeriesDetail, BookDetail banners' },
|
||||
{ id: 'mol-cover-card', name: 'Cover Card', category: 'molecules', description: 'Poster/cover image card with overlay text', code: '<button class="group rounded-2xl overflow-hidden">\n <div class="aspect-[2/3] relative">\n <img class="w-full h-full object-cover\n group-hover:scale-110\n transition-transform duration-300" />\n <div class="absolute inset-0\n bg-gradient-to-t from-black/60\n to-transparent" />\n <div class="absolute bottom-0 p-2">\n <p class="text-[11px] text-white/90">\n Title</p>\n </div>\n </div>\n</button>', usedIn: 'FilmGrid, TVSeriesGrid, SongGrid, BookGrid cards' },
|
||||
|
||||
// Organisms
|
||||
{ id: 'org-chat-bubble', name: 'Chat Bubble', category: 'organisms', description: 'AI/User message bubble with streaming', code: '<!-- User bubble -->\n<div class="flex justify-end">\n <div class="glass-card max-w-[85%]\n px-4 py-3 text-sm text-white/90">\n Message text\n </div>\n</div>\n\n<!-- AI bubble -->\n<div class="flex justify-start">\n <div class="max-w-[85%] px-4 py-3\n text-sm text-white/80">\n Response with markdown\n </div>\n</div>', usedIn: 'ChatMessage.vue — the primary chat interface' },
|
||||
{ id: 'org-content-panel', name: 'Content Panel', category: 'organisms', description: 'Tabs + grid + detail navigation', code: '<!-- Structure -->\n<div class="flex flex-col h-full">\n <!-- Tab bar -->\n <div class="flex gap-1 px-3 py-2">\n <button class="nav-tab">Tab</button>\n </div>\n <!-- Grid view -->\n <ContentGridView />\n <!-- or Detail view -->\n <DetailView />\n</div>', usedIn: 'ChatPage middle column, mobile Content tab' },
|
||||
{ id: 'org-detail-view', name: 'Detail View', category: 'organisms', description: 'Full detail with banner, back button, metadata', code: '<!-- Pattern: Banner → Meta → Content -->\n<div class="h-full overflow-y-auto">\n <!-- Banner with back button -->\n <div class="relative aspect-[16/7]">\n <img class="object-cover" />\n <div class="gradient-overlay" />\n <button class="absolute top-3 left-3\n path-glass-icon">Back</button>\n <div class="absolute bottom-0 p-4">\n <h2>Title</h2>\n <div>Metadata</div>\n </div>\n </div>\n <!-- Body -->\n <div class="p-4 space-y-4">\n <p>Description</p>\n <div>Genre badges</div>\n <div>Source links</div>\n </div>\n</div>', usedIn: 'FilmDetail, BookDetail, TVSeriesDetail, SongDetail, PodcastDetail' },
|
||||
{ id: 'org-magazine', name: 'Magazine Grid', category: 'organisms', description: 'Editorial tile layout with hero, wide, and half tiles', code: '<!-- Magazine structure -->\n<div class="grid grid-cols-2 gap-px\n bg-white/12">\n <!-- Wide tile (col-span-2) -->\n <button class="col-span-2 px-5 py-5\n bg-[#0a0a0a]">\n <p class="text-[9px] uppercase\n tracking-[0.3em]">Label</p>\n <h2 class="font-serif text-lg\n font-bold">Title</h2>\n <p class="font-serif text-sm">Text</p>\n </button>\n <!-- Half tiles -->\n <button class="px-4 py-4 bg-[#0a0a0a]">\n <h3 class="font-serif text-sm\n font-bold">Title</h3>\n <p class="font-serif text-xs">Text</p>\n </button>\n</div>', usedIn: 'MagazineGrid.vue — AI Brief editorial view' },
|
||||
{ id: 'org-nostr-note', name: 'Nostr Note', category: 'organisms', description: 'Note card with avatar, author, content, zaps', code: '<div class="p-3 rounded-xl bg-white/[0.03]\n border border-white/5">\n <div class="flex items-start gap-2.5">\n <div class="w-8 h-8 rounded-full\n bg-purple-500/20 text-purple-400">\n F\n </div>\n <div class="flex-1">\n <span class="text-xs font-semibold">\n author</span>\n <p class="text-[11px] text-white/60">\n Note content...</p>\n <span class="text-[9px]\n text-amber-500/70">21000 sats</span>\n </div>\n </div>\n</div>', usedIn: 'NostrGrid.vue — Nostr feed tab' },
|
||||
|
||||
// Animations
|
||||
{ id: 'anim-fade-up', name: 'Fade Up', category: 'atoms', description: 'Entry animation: translate + opacity', code: '.animate-fade-up {\n animation: fadeUp 900ms ease-out;\n}\n@keyframes fadeUp {\n from {\n opacity: 0;\n transform: translateY(16px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n/* Also: animate-fade-up-fast (400ms) */', usedIn: 'Empty states, initial load elements, ChatWindow' },
|
||||
{ id: 'anim-scale-in', name: 'Scale In', category: 'atoms', description: 'Micro entrance with scale and opacity', code: '.animate-scale-in {\n animation: scaleIn 250ms ease-out;\n}\n@keyframes scaleIn {\n from {\n opacity: 0;\n transform: scale(0.95);\n }\n to {\n opacity: 1;\n transform: scale(1);\n }\n}', usedIn: 'Modal entries, tooltip appearances, popovers' },
|
||||
]
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
if (activeCategory.value === 'all') return items
|
||||
return items.filter(i => i.category === activeCategory.value)
|
||||
})
|
||||
|
||||
function selectItem(item: DesignSystemItem) {
|
||||
openDesignSystemItem(item)
|
||||
}
|
||||
|
||||
function extractColorValue(code: string): string {
|
||||
const match = /(?:background-color|color|background):\s*([^;]+)/i.exec(code)
|
||||
if (!match) return '#333'
|
||||
const val = match[1].trim()
|
||||
if (val.startsWith('#') || val.startsWith('rgb') || val.startsWith('hsl')) return val
|
||||
return '#333'
|
||||
}
|
||||
</script>
|
||||
@@ -56,6 +56,11 @@
|
||||
v-else-if="isCodeMode && activeCodeFile"
|
||||
@back="closeCodeFile"
|
||||
/>
|
||||
<DesignSystemDetail
|
||||
v-else-if="selectedDesignSystemItem"
|
||||
:item="selectedDesignSystemItem"
|
||||
@back="closeDesignSystemItem"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -71,6 +76,7 @@ import ArticleDetail from './ArticleDetail.vue'
|
||||
import WebsiteDetail from './WebsiteDetail.vue'
|
||||
import MagazineSectionDetail from './MagazineSectionDetail.vue'
|
||||
import CodeDetail from './CodeDetail.vue'
|
||||
import DesignSystemDetail from './DesignSystemDetail.vue'
|
||||
import { useCodeContext } from '@/composables/useCodeContext'
|
||||
|
||||
const { isCodeMode, activeFile: activeCodeFile } = useCodeContext()
|
||||
@@ -105,5 +111,7 @@ const {
|
||||
panelMagazineSections,
|
||||
closeMagazineSectionDetail,
|
||||
navigateMagazineSection,
|
||||
selectedDesignSystemItem,
|
||||
closeDesignSystemItem,
|
||||
} = useContentPanel()
|
||||
</script>
|
||||
|
||||
@@ -103,10 +103,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import type { Film } from '@aiui/core/types/content'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { fetchTmdbPoster } from '@/composables/useImageFallback'
|
||||
import { useBannerFallback } from '@/composables/useBannerFallback'
|
||||
import { fetchFilmImage } from '@/composables/useImageFallback'
|
||||
|
||||
const props = defineProps<{ film: Film }>()
|
||||
defineEmits<{ back: [] }>()
|
||||
@@ -115,43 +116,12 @@ const { isDark } = useTheme()
|
||||
|
||||
const isExternal = computed(() => props.film.id.startsWith('ext-'))
|
||||
|
||||
// Fallback order: backdrop (mock) → TMDB API → poster (mock) → gradient
|
||||
const bannerTry = ref<'backdrop' | 'tmdb' | 'poster' | 'done'>('backdrop')
|
||||
const tmdbBannerUrl = ref<string | null>(null)
|
||||
|
||||
const bannerSrc = computed(() => {
|
||||
if (bannerTry.value === 'done') return null
|
||||
if (bannerTry.value === 'backdrop' && props.film.backdropUrl) return props.film.backdropUrl
|
||||
if (bannerTry.value === 'tmdb' && tmdbBannerUrl.value) return tmdbBannerUrl.value
|
||||
if (bannerTry.value === 'poster' && props.film.posterUrl) return props.film.posterUrl
|
||||
if (bannerTry.value === 'backdrop' && !props.film.backdropUrl) return props.film.posterUrl || null
|
||||
return null
|
||||
const { bannerSrc, fallbackGradient, onBannerError } = useBannerFallback({
|
||||
primaryUrls: () => [props.film.backdropUrl, props.film.posterUrl],
|
||||
apiFetch: () => fetchFilmImage(props.film.title, props.film.year),
|
||||
title: () => props.film.title,
|
||||
})
|
||||
|
||||
const fallbackGradient = computed(() => {
|
||||
const hue = [...props.film.title].reduce((acc, c) => acc + c.charCodeAt(0), 0) % 360
|
||||
return `linear-gradient(135deg, hsl(${hue}, 25%, 12%) 0%, hsl(${(hue + 40) % 360}, 20%, 8%) 100%)`
|
||||
})
|
||||
|
||||
async function onBannerError() {
|
||||
if (bannerTry.value === 'backdrop') {
|
||||
const { backdropUrl, posterUrl } = await fetchTmdbPoster(props.film.title, props.film.year)
|
||||
const url = backdropUrl ?? posterUrl
|
||||
if (url) {
|
||||
tmdbBannerUrl.value = url
|
||||
bannerTry.value = 'tmdb'
|
||||
return
|
||||
}
|
||||
bannerTry.value = props.film.posterUrl ? 'poster' : 'done'
|
||||
return
|
||||
}
|
||||
if (bannerTry.value === 'tmdb') {
|
||||
bannerTry.value = props.film.posterUrl ? 'poster' : 'done'
|
||||
return
|
||||
}
|
||||
bannerTry.value = 'done'
|
||||
}
|
||||
|
||||
function sourceIcon(type: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
plex: '🟧',
|
||||
|
||||
@@ -54,11 +54,17 @@
|
||||
<div class="poster-card flex-1 min-h-0">
|
||||
<div class="aspect-[2/3] relative w-full overflow-hidden rounded-[10px]">
|
||||
<img
|
||||
:src="film.posterUrl"
|
||||
v-if="coverSrc(film)"
|
||||
:src="coverSrc(film)!"
|
||||
:alt="film.title"
|
||||
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
@error="(e) => handleImgError(e, film.title, film.year)"
|
||||
@error="onCoverError(film)"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-full bg-cover bg-center"
|
||||
:style="{ backgroundImage: `url(${fallbackFor(film)})` }"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
|
||||
|
||||
@@ -96,10 +102,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, reactive, onMounted, watch } from 'vue'
|
||||
import type { Film } from '@aiui/core/types/content'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { handleImgError } from '@/composables/useImageFallback'
|
||||
import { handleImgError, fetchFilmImage, generatePosterFallback } from '@/composables/useImageFallback'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
films: Film[]
|
||||
@@ -113,6 +119,35 @@ defineEmits<{ selectFilm: [film: Film] }>()
|
||||
const { isDark } = useTheme()
|
||||
const search = ref('')
|
||||
const activeGenre = ref<string | null>(null)
|
||||
const failedCovers = ref<Set<string>>(new Set())
|
||||
const fetchedCovers = reactive<Map<string, string>>(new Map())
|
||||
|
||||
function coverSrc(film: Film): string | null {
|
||||
if (failedCovers.value.has(film.id)) return null
|
||||
const url = film.posterUrl || fetchedCovers.get(film.id)
|
||||
return url || null
|
||||
}
|
||||
|
||||
function fallbackFor(film: Film): string {
|
||||
return generatePosterFallback(film.title, film.year)
|
||||
}
|
||||
|
||||
function onCoverError(film: Film) {
|
||||
failedCovers.value.add(film.id)
|
||||
failedCovers.value = new Set(failedCovers.value)
|
||||
}
|
||||
|
||||
function fetchCoversFor(films: Film[]) {
|
||||
for (const film of films) {
|
||||
if (film.posterUrl || fetchedCovers.has(film.id)) continue
|
||||
fetchFilmImage(film.title, film.year).then((result) => {
|
||||
if (result.posterUrl) fetchedCovers.set(film.id, result.posterUrl)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => fetchCoversFor(props.films))
|
||||
watch(() => props.films, (films) => fetchCoversFor(films), { immediate: false })
|
||||
|
||||
const topGenres = computed(() => {
|
||||
const counts = new Map<string, number>()
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<!-- Poster grid: films, TV, books -->
|
||||
<div v-if="variant === 'poster'" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
<div
|
||||
v-for="i in count"
|
||||
:key="i"
|
||||
class="aspect-[2/3] rounded-xl animate-pulse"
|
||||
:class="isDark ? 'bg-white/10' : 'bg-black/6'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Square grid: songs, podcasts, images -->
|
||||
<div v-else-if="variant === 'square'" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
<div v-for="i in count" :key="i" class="space-y-2">
|
||||
<div
|
||||
class="aspect-square rounded-xl animate-pulse"
|
||||
:class="isDark ? 'bg-white/10' : 'bg-black/6'"
|
||||
/>
|
||||
<div
|
||||
class="h-3 rounded animate-pulse w-3/4"
|
||||
:class="isDark ? 'bg-white/8' : 'bg-black/5'"
|
||||
/>
|
||||
<div
|
||||
class="h-2.5 rounded animate-pulse w-1/2"
|
||||
:class="isDark ? 'bg-white/5' : 'bg-black/3'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List: news, websites -->
|
||||
<div v-else-if="variant === 'list'" class="space-y-3">
|
||||
<div
|
||||
v-for="i in count"
|
||||
:key="i"
|
||||
class="flex gap-3 p-3 rounded-xl animate-pulse"
|
||||
:class="isDark ? 'bg-white/[0.04]' : 'bg-black/[0.03]'"
|
||||
>
|
||||
<div
|
||||
class="w-20 h-14 rounded-lg shrink-0"
|
||||
:class="isDark ? 'bg-white/10' : 'bg-black/6'"
|
||||
/>
|
||||
<div class="flex-1 space-y-2 py-1">
|
||||
<div
|
||||
class="h-3 rounded w-4/5"
|
||||
:class="isDark ? 'bg-white/10' : 'bg-black/6'"
|
||||
/>
|
||||
<div
|
||||
class="h-2.5 rounded w-3/5"
|
||||
:class="isDark ? 'bg-white/6' : 'bg-black/4'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Magazine: tile-style skeleton -->
|
||||
<div v-else-if="variant === 'magazine'" class="space-y-0">
|
||||
<!-- Hero skeleton -->
|
||||
<div
|
||||
class="h-44 animate-pulse mb-px"
|
||||
:class="isDark ? 'bg-white/[0.04]' : 'bg-black/[0.03]'"
|
||||
/>
|
||||
<!-- Tile grid skeleton -->
|
||||
<div class="grid grid-cols-2 gap-px"
|
||||
:class="isDark ? 'bg-white/12' : 'bg-black/10'">
|
||||
<div
|
||||
v-for="i in count"
|
||||
:key="i"
|
||||
class="p-4 animate-pulse"
|
||||
:class="[
|
||||
isDark ? 'bg-[#0a0a0a]' : 'bg-[#faf9f6]',
|
||||
i <= 1 ? 'col-span-2' : ''
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="h-2.5 rounded w-1/3 mb-2"
|
||||
:class="isDark ? 'bg-white/8' : 'bg-black/5'"
|
||||
/>
|
||||
<div
|
||||
class="h-4 rounded w-4/5 mb-2"
|
||||
:class="isDark ? 'bg-white/10' : 'bg-black/6'"
|
||||
/>
|
||||
<div
|
||||
class="h-2.5 rounded w-full"
|
||||
:class="isDark ? 'bg-white/6' : 'bg-black/4'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'poster' | 'square' | 'list' | 'magazine'
|
||||
count?: number
|
||||
}>(),
|
||||
{ variant: 'poster', count: 8 }
|
||||
)
|
||||
|
||||
const { isDark } = useTheme()
|
||||
</script>
|
||||
@@ -179,11 +179,19 @@ const bannerLabels = ['Perspectives', 'Worth Noting', 'Key Signals', 'Analysis']
|
||||
|
||||
function cleanText(text: string): string {
|
||||
return text
|
||||
.replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '')
|
||||
.replace(/\[([^\]]*)\]\([^)]+\)/g, '$1') // [text](url) → text
|
||||
.replace(/https?:\/\/\S+/g, '') // bare URLs
|
||||
.replace(/\uFE0F/g, '') // variation selectors
|
||||
.replace(/(?:^|(?<=\s))[\p{Emoji_Presentation}\p{Extended_Pictographic}]+\s*/gu, '') // standalone emojis
|
||||
.replace(/---+/g, '') // horizontal rules
|
||||
.replace(/^#+\s*/gm, '')
|
||||
.replace(/\*\*/g, '')
|
||||
.replace(/[-•]\s*/g, '')
|
||||
.replace(/\*([^*\n]+)\*/g, '$1') // *italic* → italic
|
||||
.replace(/\|/g, ', ') // pipes → comma-space
|
||||
.replace(/,\s*,+/g, ',') // collapse multiple commas
|
||||
.replace(/^\s*[-•]\s+/gm, '') // bullets at line start only
|
||||
.replace(/\n+/g, ' ')
|
||||
.replace(/(^|\s),\s*/g, '$1') // trim stray leading commas
|
||||
.trim()
|
||||
}
|
||||
|
||||
|
||||
@@ -59,11 +59,7 @@
|
||||
<div class="space-y-4">
|
||||
<p v-for="(paragraph, i) in paragraphs" :key="i"
|
||||
class="text-base md:text-lg leading-relaxed"
|
||||
:class="[
|
||||
isDark ? 'text-white/75' : 'text-black/65',
|
||||
i === 0 ? 'first-letter:text-3xl first-letter:font-bold first-letter:float-left first-letter:mr-1.5 first-letter:leading-none' : ''
|
||||
]"
|
||||
:style="i === 0 ? `first-letter { color: ${isDark ? 'rgba(255,255,255,0.95)' : 'rgba(0,0,0,0.9)'} }` : ''">
|
||||
:class="isDark ? 'text-white/75' : 'text-black/65'">
|
||||
{{ paragraph }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -147,11 +143,18 @@ const { isDark } = useTheme()
|
||||
const paragraphs = computed(() => {
|
||||
const text = props.section.content
|
||||
return text
|
||||
.replace(/\*\*/g, '')
|
||||
.replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '')
|
||||
.replace(/^#+\s*/gm, '')
|
||||
.replace(/\[([^\]]*)\]\([^)]+\)/g, '$1') // [text](url) → text
|
||||
.replace(/https?:\/\/\S+/g, '') // bare URLs
|
||||
.replace(/\uFE0F/g, '') // variation selectors
|
||||
.replace(/\*\*/g, '') // bold markers
|
||||
.replace(/\*([^*\n]+)\*/g, '$1') // *italic* → italic
|
||||
.replace(/(?:^|(?<=\s))[\p{Emoji_Presentation}\p{Extended_Pictographic}]+\s*/gu, '') // standalone emojis
|
||||
.replace(/---+/g, '') // horizontal rules
|
||||
.replace(/^#+\s*/gm, '') // heading markers
|
||||
.replace(/\|/g, ', ') // pipes → comma-space
|
||||
.replace(/,\s*,+/g, ',') // collapse multiple commas
|
||||
.split(/\n{2,}|\n\s*[-•]\s+/)
|
||||
.map(p => p.replace(/^[-•]\s*/, '').trim())
|
||||
.map(p => p.replace(/^\s*[-•]\s+/, '').replace(/(^|\n)\s*,\s*/g, '$1').trim())
|
||||
.filter(p => p.length > 0)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="p-4 space-y-3" :style="isDark
|
||||
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
|
||||
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
|
||||
Nostr Feed
|
||||
</h3>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="text-[10px] font-mono" :class="isDark ? 'text-white/30' : 'text-gray-400'">
|
||||
{{ filteredNotes.length }} notes
|
||||
</span>
|
||||
<slot name="header-actions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Search notes, npubs..."
|
||||
class="w-full px-3 py-2 rounded-lg text-xs outline-none transition-colors"
|
||||
:class="isDark
|
||||
? 'bg-white/5 text-white/80 placeholder:text-white/25 focus:bg-white/10'
|
||||
: 'bg-black/3 text-gray-800 placeholder:text-gray-400 focus:bg-black/5'"
|
||||
/>
|
||||
|
||||
<div class="flex gap-1.5">
|
||||
<button
|
||||
v-for="kind in noteKinds"
|
||||
:key="kind.id"
|
||||
class="text-[10px] px-2 py-1 rounded-md transition-all duration-150"
|
||||
:class="activeKind === kind.id
|
||||
? 'nav-tab-active'
|
||||
: isDark
|
||||
? 'text-white/40 hover:text-white/70 hover:bg-white/5'
|
||||
: 'text-gray-500 hover:text-gray-800 hover:bg-black/5'"
|
||||
@click="activeKind = activeKind === kind.id ? null : kind.id"
|
||||
>
|
||||
{{ kind.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-3 pb-16 space-y-2">
|
||||
<button
|
||||
v-for="note in filteredNotes"
|
||||
:key="note.id"
|
||||
class="w-full text-left p-3 rounded-xl transition-all duration-150"
|
||||
:class="isDark
|
||||
? 'bg-white/[0.03] hover:bg-white/[0.07] border border-white/5'
|
||||
: 'bg-black/[0.02] hover:bg-black/[0.05] border border-black/5'"
|
||||
@click="$emit('selectNote', note)"
|
||||
>
|
||||
<div class="flex items-start gap-2.5">
|
||||
<!-- Avatar -->
|
||||
<div class="w-8 h-8 rounded-full shrink-0 flex items-center justify-center text-[10px] font-bold"
|
||||
:class="isDark ? 'bg-purple-500/20 text-purple-400' : 'bg-purple-100 text-purple-600'">
|
||||
{{ note.authorName?.charAt(0)?.toUpperCase() ?? '?' }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs font-semibold truncate"
|
||||
:class="isDark ? 'text-white/80' : 'text-gray-800'">
|
||||
{{ note.authorName ?? 'anon' }}
|
||||
</span>
|
||||
<span v-if="note.nip05" class="text-[9px] truncate"
|
||||
:class="isDark ? 'text-purple-400/60' : 'text-purple-500/60'">
|
||||
{{ note.nip05 }}
|
||||
</span>
|
||||
<span class="text-[9px] ml-auto shrink-0"
|
||||
:class="isDark ? 'text-white/20' : 'text-gray-300'">
|
||||
{{ formatTime(note.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-[11px] mt-1 leading-relaxed line-clamp-3"
|
||||
:class="isDark ? 'text-white/60' : 'text-gray-600'">
|
||||
{{ note.content }}
|
||||
</p>
|
||||
<div class="flex items-center gap-3 mt-2">
|
||||
<span v-if="note.kind !== 1" class="text-[9px] px-1.5 py-0.5 rounded"
|
||||
:class="isDark ? 'bg-white/5 text-white/30' : 'bg-black/5 text-gray-400'">
|
||||
kind:{{ note.kind }}
|
||||
</span>
|
||||
<span v-if="note.replies" class="text-[9px]"
|
||||
:class="isDark ? 'text-white/25' : 'text-gray-400'">
|
||||
{{ note.replies }} replies
|
||||
</span>
|
||||
<span v-if="note.zaps" class="text-[9px] text-amber-500/70">
|
||||
{{ note.zaps }} sats
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Relay status -->
|
||||
<div class="mt-4 pt-4" :style="isDark
|
||||
? 'border-top: 1px solid rgba(255, 255, 255, 0.05)'
|
||||
: 'border-top: 1px solid rgba(0, 0, 0, 0.05)'"
|
||||
>
|
||||
<p class="text-[10px] font-medium mb-2"
|
||||
:class="isDark ? 'text-white/30' : 'text-gray-400'">
|
||||
Relays
|
||||
</p>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="relay in relays"
|
||||
:key="relay.url"
|
||||
class="flex items-center gap-2 text-[10px] px-2 py-1 rounded-lg"
|
||||
:class="isDark ? 'bg-white/[0.02]' : 'bg-black/[0.02]'"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
:class="relay.connected ? 'bg-emerald-500' : 'bg-red-400/60'" />
|
||||
<span class="truncate font-mono"
|
||||
:class="isDark ? 'text-white/40' : 'text-gray-500'">
|
||||
{{ relay.url }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredNotes.length === 0" class="flex items-center justify-center py-12">
|
||||
<p class="text-sm" :class="isDark ? 'text-white/30' : 'text-gray-400'">
|
||||
No notes match your search
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
|
||||
export interface NostrNote {
|
||||
id: string
|
||||
pubkey: string
|
||||
authorName?: string
|
||||
nip05?: string
|
||||
kind: number
|
||||
content: string
|
||||
created_at: number
|
||||
replies?: number
|
||||
zaps?: number
|
||||
tags?: string[][]
|
||||
}
|
||||
|
||||
interface Relay {
|
||||
url: string
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
defineEmits<{ selectNote: [note: NostrNote] }>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const search = ref('')
|
||||
const activeKind = ref<number | null>(null)
|
||||
|
||||
const noteKinds = [
|
||||
{ id: 1, label: 'Notes' },
|
||||
{ id: 30023, label: 'Articles' },
|
||||
{ id: 9735, label: 'Zaps' },
|
||||
{ id: 6, label: 'Reposts' },
|
||||
]
|
||||
|
||||
const relays: Relay[] = [
|
||||
{ url: 'wss://relay.damus.io', connected: true },
|
||||
{ url: 'wss://relay.nostr.band', connected: true },
|
||||
{ url: 'wss://nos.lol', connected: true },
|
||||
{ url: 'wss://relay.snort.social', connected: false },
|
||||
]
|
||||
|
||||
// Mock notes for demo — will be replaced with real Nostr websocket data
|
||||
const mockNotes: NostrNote[] = [
|
||||
{
|
||||
id: 'note1abc',
|
||||
pubkey: 'npub1abc',
|
||||
authorName: 'fiatjaf',
|
||||
nip05: 'fiatjaf@fiatjaf.com',
|
||||
kind: 1,
|
||||
content: 'nostr is the simplest open protocol that is able to create a censorship-resistant global "social" network once and for all.',
|
||||
created_at: Date.now() / 1000 - 3600,
|
||||
replies: 42,
|
||||
zaps: 21000,
|
||||
},
|
||||
{
|
||||
id: 'note2def',
|
||||
pubkey: 'npub2def',
|
||||
authorName: 'jb55',
|
||||
nip05: 'jb55@jb55.com',
|
||||
kind: 1,
|
||||
content: 'Just shipped a new update to Damus. Lots of performance improvements and better zap UX. Let me know what you think!',
|
||||
created_at: Date.now() / 1000 - 7200,
|
||||
replies: 18,
|
||||
zaps: 5000,
|
||||
},
|
||||
{
|
||||
id: 'note3ghi',
|
||||
pubkey: 'npub3ghi',
|
||||
authorName: 'ODELL',
|
||||
nip05: 'odell@citadeldispatch.com',
|
||||
kind: 1,
|
||||
content: 'Bitcoin and nostr are complementary protocols. One provides sound money, the other provides sound communication. Together they form the foundation of a free and open internet.',
|
||||
created_at: Date.now() / 1000 - 14400,
|
||||
replies: 31,
|
||||
zaps: 15000,
|
||||
},
|
||||
{
|
||||
id: 'note4jkl',
|
||||
pubkey: 'npub4jkl',
|
||||
authorName: 'Gigi',
|
||||
nip05: 'gigi@dergigi.com',
|
||||
kind: 30023,
|
||||
content: 'New long-form article: "Why Nostr Matters" — exploring the implications of a truly censorship-resistant communication layer and what it means for the future of online discourse.',
|
||||
created_at: Date.now() / 1000 - 28800,
|
||||
replies: 12,
|
||||
zaps: 50000,
|
||||
},
|
||||
{
|
||||
id: 'note5mno',
|
||||
pubkey: 'npub5mno',
|
||||
authorName: 'Karnage',
|
||||
kind: 1,
|
||||
content: 'Building on nostr feels like the early web. Permissionless innovation, interoperable clients, and a community that actually cares about freedom. This is what I signed up for.',
|
||||
created_at: Date.now() / 1000 - 36000,
|
||||
replies: 8,
|
||||
zaps: 3000,
|
||||
},
|
||||
{
|
||||
id: 'note6pqr',
|
||||
pubkey: 'npub6pqr',
|
||||
authorName: 'Pablo',
|
||||
nip05: 'pablo@fountain.fm',
|
||||
kind: 1,
|
||||
content: 'Podcasting 2.0 + Nostr = the future of content discovery. Imagine your podcast app pulling recommendations from your nostr social graph. We are building this.',
|
||||
created_at: Date.now() / 1000 - 43200,
|
||||
replies: 25,
|
||||
zaps: 8000,
|
||||
},
|
||||
{
|
||||
id: 'note7stu',
|
||||
pubkey: 'npub7stu',
|
||||
authorName: 'Calle',
|
||||
nip05: 'calle@cashu.space',
|
||||
kind: 1,
|
||||
content: 'Cashu + Nostr ecash tokens make micropayments frictionless. Zap someone a few sats with zero fees and instant settlement. The Lightning Network as a settlement layer.',
|
||||
created_at: Date.now() / 1000 - 50400,
|
||||
replies: 15,
|
||||
zaps: 12000,
|
||||
},
|
||||
]
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
const diff = Math.floor(Date.now() / 1000 - ts)
|
||||
if (diff < 60) return 'now'
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`
|
||||
return `${Math.floor(diff / 86400)}d`
|
||||
}
|
||||
|
||||
const filteredNotes = computed(() => {
|
||||
let result = mockNotes
|
||||
if (activeKind.value !== null) {
|
||||
result = result.filter(n => n.kind === activeKind.value)
|
||||
}
|
||||
if (search.value) {
|
||||
const q = search.value.toLowerCase()
|
||||
result = result.filter(n =>
|
||||
n.content.toLowerCase().includes(q) ||
|
||||
(n.authorName ?? '').toLowerCase().includes(q) ||
|
||||
(n.nip05 ?? '').toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
return result
|
||||
})
|
||||
</script>
|
||||
@@ -1,50 +1,112 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Header -->
|
||||
<div class="shrink-0 px-4 py-3 flex items-center justify-between"
|
||||
<!-- Header with breadcrumb -->
|
||||
<div class="shrink-0 px-4 py-3 flex items-center justify-between gap-2"
|
||||
:style="isDark
|
||||
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
|
||||
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold"
|
||||
:class="isDark ? 'text-white/90' : 'text-gray-900'">
|
||||
{{ activeProject ? activeProject.name : 'Projects' }}
|
||||
</h2>
|
||||
<p class="text-[10px] mt-0.5"
|
||||
:class="isDark ? 'text-white/30' : 'text-gray-400'">
|
||||
{{ activeProject ? activeProject.language : `${projectList.length} repositories` }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<!-- Breadcrumb: "Projects" root (clickable when deeper) -->
|
||||
<button
|
||||
v-if="activeProject"
|
||||
class="text-[10px] px-2.5 py-1 rounded-lg font-medium transition-colors"
|
||||
v-if="viewState !== 'projects'"
|
||||
class="text-[10px] shrink-0 transition-colors"
|
||||
:class="isDark
|
||||
? 'text-white/40 hover:text-white/70 hover:bg-white/5'
|
||||
: 'text-gray-500 hover:text-gray-800 hover:bg-black/5'"
|
||||
? 'text-white/40 hover:text-white/70 hover:underline'
|
||||
: 'text-gray-400 hover:text-gray-700 hover:underline'"
|
||||
@click="backToProjects"
|
||||
>
|
||||
All Projects
|
||||
Projects
|
||||
</button>
|
||||
<span v-if="viewState !== 'projects'" class="text-[10px] shrink-0"
|
||||
:class="isDark ? 'text-white/20' : 'text-gray-300'">/</span>
|
||||
|
||||
<!-- Current location (not clickable) -->
|
||||
<span class="text-sm font-semibold truncate"
|
||||
:class="isDark ? 'text-white/90' : 'text-gray-900'">
|
||||
{{ currentTitle }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<!-- Subtitle info -->
|
||||
<p v-if="viewState === 'projects'" class="text-[10px]"
|
||||
:class="isDark ? 'text-white/30' : 'text-gray-400'">
|
||||
{{ projectList.length }} repos
|
||||
</p>
|
||||
<p v-else-if="viewState === 'filetree'" class="text-[10px]"
|
||||
:class="isDark ? 'text-white/30' : 'text-gray-400'">
|
||||
{{ activeProject?.language }}
|
||||
</p>
|
||||
<slot name="header-actions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project list (when no project selected) -->
|
||||
<div v-if="!activeProject" class="flex-1 overflow-y-auto custom-scrollbar p-3">
|
||||
<!-- Search -->
|
||||
<div class="mb-3">
|
||||
<!-- Project list -->
|
||||
<div v-if="viewState === 'projects'" class="flex-1 overflow-y-auto custom-scrollbar p-3">
|
||||
<!-- Search + New Project -->
|
||||
<div class="mb-3 flex gap-2">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Search projects..."
|
||||
class="w-full px-3 py-2 rounded-lg text-xs bg-transparent outline-none"
|
||||
class="flex-1 min-w-0 px-3 py-2 rounded-lg text-xs bg-transparent outline-none"
|
||||
:class="isDark
|
||||
? 'text-white/80 placeholder:text-white/20 border border-white/10 focus:border-white/25'
|
||||
: 'text-gray-800 placeholder:text-gray-400 border border-black/10 focus:border-black/20'"
|
||||
/>
|
||||
<button
|
||||
class="shrink-0 px-3 py-2 rounded-lg text-xs font-medium transition-colors flex items-center gap-1.5"
|
||||
:class="isDark
|
||||
? 'bg-accent/20 text-accent hover:bg-accent/30'
|
||||
: 'bg-accent/10 text-accent hover:bg-accent/20'"
|
||||
@click="showNewProjectDialog"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- New project inline dialog -->
|
||||
<div v-if="isCreatingProject" class="mb-3 p-3 rounded-xl"
|
||||
:class="isDark ? 'bg-white/[0.05] border border-white/10' : 'bg-black/[0.03] border border-black/8'">
|
||||
<p class="text-xs font-medium mb-2"
|
||||
:class="isDark ? 'text-white/70' : 'text-gray-700'">
|
||||
New Project
|
||||
</p>
|
||||
<input
|
||||
ref="newProjectInputRef"
|
||||
v-model="newProjectName"
|
||||
type="text"
|
||||
placeholder="Project name..."
|
||||
class="w-full px-3 py-2 rounded-lg text-xs bg-transparent outline-none mb-2"
|
||||
:class="isDark
|
||||
? 'text-white/80 placeholder:text-white/20 border border-white/10 focus:border-white/25'
|
||||
: 'text-gray-800 placeholder:text-gray-400 border border-black/10 focus:border-black/20'"
|
||||
@keydown.enter="confirmCreateProject"
|
||||
@keydown.escape="cancelCreateProject"
|
||||
/>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
class="text-[10px] px-2.5 py-1 rounded-lg transition-colors"
|
||||
:class="isDark ? 'text-white/40 hover:text-white/70' : 'text-gray-500 hover:text-gray-800'"
|
||||
@click="cancelCreateProject"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="text-[10px] px-2.5 py-1 rounded-lg font-medium transition-colors"
|
||||
:class="isDark ? 'bg-accent/20 text-accent hover:bg-accent/30' : 'bg-accent/10 text-accent hover:bg-accent/20'"
|
||||
:disabled="!newProjectName.trim()"
|
||||
@click="confirmCreateProject"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project grid -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
v-for="project in filteredProjects"
|
||||
@@ -55,12 +117,10 @@
|
||||
: 'bg-black/[0.02] hover:bg-black/[0.05] border border-black/5'"
|
||||
@click="selectProject(project)"
|
||||
>
|
||||
<!-- Folder icon -->
|
||||
<div class="w-8 h-8 rounded-lg flex items-center justify-center mb-2"
|
||||
:class="isDark ? 'bg-white/5' : 'bg-black/5'">
|
||||
<svg class="w-4 h-4" :class="project.isGit ? 'text-accent' : isDark ? 'text-white/40' : 'text-gray-400'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path v-if="project.isGit" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-xs font-medium truncate"
|
||||
@@ -75,8 +135,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File tree (when project is selected) -->
|
||||
<div v-else class="flex-1 overflow-y-auto custom-scrollbar p-2">
|
||||
<!-- File tree -->
|
||||
<div v-else-if="viewState === 'filetree'" class="flex-1 overflow-y-auto custom-scrollbar p-2">
|
||||
<FileTreeNode
|
||||
v-for="entry in fileTree"
|
||||
:key="entry.path"
|
||||
@@ -91,15 +151,21 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useCodeContext, type ProjectInfo } from '@/composables/useCodeContext'
|
||||
import FileTreeNode from './FileTreeNode.vue'
|
||||
|
||||
defineProps<{
|
||||
isWideDesktop?: boolean
|
||||
isMobile?: boolean
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const {
|
||||
projectList,
|
||||
@@ -108,10 +174,26 @@ const {
|
||||
activeFile,
|
||||
selectProject: doSelectProject,
|
||||
openFile,
|
||||
exitCodeMode,
|
||||
createProject,
|
||||
clearActiveFile,
|
||||
} = useCodeContext()
|
||||
|
||||
const search = ref('')
|
||||
const isCreatingProject = ref(false)
|
||||
const newProjectName = ref('')
|
||||
const newProjectInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// View state: projects | filetree (file content always opens in Context panel)
|
||||
type CodeViewState = 'projects' | 'filetree'
|
||||
const viewState = computed<CodeViewState>(() => {
|
||||
if (!activeProject.value) return 'projects'
|
||||
return 'filetree'
|
||||
})
|
||||
|
||||
const currentTitle = computed(() => {
|
||||
if (viewState.value === 'projects') return 'Projects'
|
||||
return activeProject.value?.name ?? 'Projects'
|
||||
})
|
||||
|
||||
const filteredProjects = computed(() => {
|
||||
const q = search.value.toLowerCase()
|
||||
@@ -126,15 +208,34 @@ function selectProject(project: ProjectInfo) {
|
||||
}
|
||||
|
||||
function backToProjects() {
|
||||
// Clear active project to go back to project list
|
||||
const { activeProject: ap, fileTree: ft, activeFile: af, activeFileContent: afc } = useCodeContext()
|
||||
const { activeProject: ap, fileTree: ft } = useCodeContext()
|
||||
ap.value = null
|
||||
ft.value = []
|
||||
af.value = null
|
||||
afc.value = ''
|
||||
clearActiveFile()
|
||||
}
|
||||
|
||||
function handleFileSelect(filePath: string) {
|
||||
openFile(filePath)
|
||||
}
|
||||
|
||||
// New project dialog
|
||||
function showNewProjectDialog() {
|
||||
isCreatingProject.value = true
|
||||
newProjectName.value = ''
|
||||
nextTick(() => newProjectInputRef.value?.focus())
|
||||
}
|
||||
|
||||
function cancelCreateProject() {
|
||||
isCreatingProject.value = false
|
||||
newProjectName.value = ''
|
||||
}
|
||||
|
||||
function confirmCreateProject() {
|
||||
const name = newProjectName.value.trim()
|
||||
if (!name) return
|
||||
createProject(name)
|
||||
isCreatingProject.value = false
|
||||
newProjectName.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -51,10 +51,10 @@
|
||||
class="group flex flex-col items-stretch text-left w-full path-glass-bubble rounded-2xl overflow-hidden transition-all duration-200 hover:brightness-105"
|
||||
@click="$emit('selectSong', song)"
|
||||
>
|
||||
<div class="cover-card flex-1 min-h-0 relative">
|
||||
<div class="cover-card flex-1 min-h-0 relative flex items-center justify-center">
|
||||
<div class="aspect-square relative w-full overflow-hidden rounded-[10px]">
|
||||
<button
|
||||
class="absolute inset-0 flex items-center justify-center z-10 backdrop-blur-sm bg-black/30 opacity-0 group-hover:opacity-100 transition-all duration-200"
|
||||
class="absolute inset-0 bottom-10 flex items-center justify-center z-10 backdrop-blur-sm bg-black/30 opacity-0 group-hover:opacity-100 transition-all duration-200"
|
||||
aria-label="Play"
|
||||
@click.stop="onPlayClick(song)"
|
||||
>
|
||||
@@ -97,10 +97,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs font-semibold mt-2 truncate px-0.5"
|
||||
:class="isDark ? 'text-white/90' : 'text-gray-900'">
|
||||
{{ song.title }}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
<div class="relative w-full overflow-hidden">
|
||||
<div class="w-full aspect-[16/7] flex items-center justify-center overflow-hidden bg-black/20">
|
||||
<img
|
||||
v-if="coverSrc"
|
||||
:src="coverSrc"
|
||||
v-if="bannerSrc"
|
||||
:src="bannerSrc"
|
||||
:alt="series.title"
|
||||
class="w-full h-full object-cover object-center block"
|
||||
@error="coverFailed = true"
|
||||
@error="onBannerError"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-full bg-cover bg-center"
|
||||
:style="{ backgroundImage: `url(${fallbackCover})` }"
|
||||
class="w-full h-full"
|
||||
:style="{ background: fallbackGradient }"
|
||||
/>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent pointer-events-none" />
|
||||
@@ -124,27 +124,23 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import type { TVSeries } from '@aiui/core/types/content'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { generateTVSeriesFallback, fetchTmdbTVPoster } from '@/composables/useImageFallback'
|
||||
import { useBannerFallback } from '@/composables/useBannerFallback'
|
||||
import { fetchTVImage } from '@/composables/useImageFallback'
|
||||
|
||||
const props = defineProps<{ series: TVSeries }>()
|
||||
defineEmits<{ back: [] }>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
const coverFailed = ref(false)
|
||||
const fetchedCover = ref<string | null>(null)
|
||||
|
||||
const coverSrc = computed(() => {
|
||||
if (coverFailed.value) return null
|
||||
return props.series.posterUrl || props.series.backdropUrl || fetchedCover.value
|
||||
const { bannerSrc, fallbackGradient, onBannerError } = useBannerFallback({
|
||||
primaryUrls: () => [props.series.posterUrl, props.series.backdropUrl],
|
||||
apiFetch: () => fetchTVImage(props.series.title, props.series.year),
|
||||
title: () => props.series.title,
|
||||
})
|
||||
|
||||
const fallbackCover = computed(() =>
|
||||
generateTVSeriesFallback(props.series.title, props.series.year)
|
||||
)
|
||||
|
||||
const yearDisplay = computed(() => {
|
||||
if (!props.series.year) return ''
|
||||
if (props.series.endYear && props.series.endYear !== props.series.year) {
|
||||
@@ -180,11 +176,4 @@ function sourceIcon(type: string): string {
|
||||
return icons[type] ?? '📺'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.series.posterUrl || props.series.backdropUrl) return
|
||||
fetchTmdbTVPoster(props.series.title, props.series.year).then((result) => {
|
||||
if (result.backdropUrl) fetchedCover.value = result.backdropUrl
|
||||
else if (result.posterUrl) fetchedCover.value = result.posterUrl
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
import { ref, computed, reactive, onMounted, watch } from 'vue'
|
||||
import type { TVSeries } from '@aiui/core/types/content'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { generateTVSeriesFallback, fetchTmdbTVPoster } from '@/composables/useImageFallback'
|
||||
import { generateTVSeriesFallback, fetchTVImage } from '@/composables/useImageFallback'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
series: TVSeries[]
|
||||
@@ -157,7 +157,7 @@ function yearDisplay(s: TVSeries): string {
|
||||
function fetchCoversFor(list: TVSeries[]) {
|
||||
for (const s of list) {
|
||||
if (s.posterUrl || fetchedCovers.has(s.id)) continue
|
||||
fetchTmdbTVPoster(s.title, s.year).then((result) => {
|
||||
fetchTVImage(s.title, s.year).then((result) => {
|
||||
if (result.posterUrl) fetchedCovers.set(s.id, result.posterUrl)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user