baseline: 保留 07 风格重写前的 Vue 前端原件
This commit is contained in:
+23
@@ -0,0 +1,23 @@
|
||||
# 依赖与构建产物
|
||||
node_modules/
|
||||
dist/
|
||||
frontend/dist/
|
||||
|
||||
# 运行时数据
|
||||
data/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# 开发进程日志 / pid
|
||||
.run/
|
||||
|
||||
# 二进制
|
||||
one-server
|
||||
backend/one-server
|
||||
|
||||
# 本地环境变量
|
||||
.env
|
||||
.env.local
|
||||
|
||||
.DS_Store
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>xLog</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "one-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.34.4",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@egoist/tailwindcss-icons": "^1.8.0",
|
||||
"@iconify-json/mingcute": "^1.1.17",
|
||||
"dompurify": "^3.1.6",
|
||||
"marked": "^12.0.2",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.1.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.40",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.4.0",
|
||||
"vue-tsc": "^2.1.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": ["esbuild"]
|
||||
}
|
||||
}
|
||||
Generated
+1619
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 128.81 128.17">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: url(#gradient3);
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #000;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.cls-2 {
|
||||
fill: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<linearGradient id="gradient3" x1="57.54" y1="31.44" x2="128.81" y2="31.44" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#ff4d4d"/>
|
||||
<stop offset=".99" stop-color="#f9cb28"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="b">
|
||||
<g>
|
||||
<path class="cls-2" d="M56.32,100.47c0,15.98-11.84,27.61-27.85,27.61S.33,116.45,.33,100.47s11.83-27.45,28.08-27.45,27.91,11.63,27.91,27.45Z"/>
|
||||
<path class="cls-2" d="M120,101.26v19.31c-4.87,4.63-12.9,7.6-21.65,7.6-18.33,0-30-10.49-30-26.7s12.92-28.34,31.07-28.34v28.13h20.58Z"/>
|
||||
<polygon class="cls-1" points="119.7 21.48 128.81 38.51 83.27 62.88 57.54 14.79 85.17 0 101.79 31.06 119.7 21.48"/>
|
||||
<polygon class="cls-2" points="0 63.11 16.54 32.82 .68 8.17 55.98 8.17 40.12 32.82 56.65 63.11 0 63.11"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 根组件:仅承载路由出口
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ src?: string; name?: string; size?: number }>(),
|
||||
{ size: 40, name: '' },
|
||||
)
|
||||
|
||||
const failed = ref(false)
|
||||
const initial = computed(() => (props.name || '?').slice(0, 1).toUpperCase())
|
||||
const sizeStyle = computed(() => ({
|
||||
width: `${props.size}px`,
|
||||
height: `${props.size}px`,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-block rounded-full overflow-hidden shrink-0 bg-zinc-100" :style="sizeStyle">
|
||||
<img
|
||||
v-if="src && !failed"
|
||||
:src="src"
|
||||
:alt="name"
|
||||
class="size-full object-cover"
|
||||
loading="lazy"
|
||||
@error="failed = true"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="size-full flex items-center justify-center bg-accent text-white font-medium select-none"
|
||||
:style="{ fontSize: `${Math.round(size * 0.42)}px` }"
|
||||
>
|
||||
{{ initial }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
|
||||
const { dark, toggle } = useTheme()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="inline-flex items-center justify-center text-zinc-500 hover:text-accent transition-colors"
|
||||
:aria-label="dark ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||
@click="toggle"
|
||||
>
|
||||
<i :class="dark ? 'i-mingcute-moon-line' : 'i-mingcute-sun-line'" class="text-xl" />
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{ icon?: string; text?: string }>(),
|
||||
{
|
||||
icon: 'i-mingcute-inbox-line',
|
||||
text: 'No posts yet',
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center py-20 text-zinc-400">
|
||||
<i :class="icon" class="text-6xl mb-4" />
|
||||
<span>{{ text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { formatNumber } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ value: number }>()
|
||||
|
||||
const text = computed(() => formatNumber(props.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span>{{ text }}</span>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const locales = ['en', 'zh'] as const
|
||||
type Locale = (typeof locales)[number]
|
||||
|
||||
const locale = ref<Locale>('en')
|
||||
|
||||
function toggle() {
|
||||
locale.value = locale.value === 'en' ? 'zh' : 'en'
|
||||
document.documentElement.lang = locale.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="inline-flex items-center justify-center text-zinc-500 hover:text-accent transition-colors"
|
||||
aria-label="Switch language"
|
||||
@click="toggle"
|
||||
>
|
||||
<i class="i-mingcute-translate-2-line text-xl" />
|
||||
<span class="ml-1 text-xs uppercase">{{ locale }}</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ text?: string }>(), { text: 'Loading...' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center py-20 text-zinc-400 space-y-3">
|
||||
<i class="i-mingcute-loading-3-line text-3xl animate-spin" />
|
||||
<span class="text-sm">{{ text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
// xLog Logo:内联 SVG,保留原始渐变与暗色适配
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{ size?: number }>(), { size: 36 })
|
||||
|
||||
const gradientId = `xlog-gradient-${Math.random().toString(36).slice(2, 8)}`
|
||||
const style = computed(() => ({ width: `${props.size}px`, height: `${props.size}px` }))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:style="style"
|
||||
viewBox="0 0 128.81 128.17"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
:id="gradientId"
|
||||
x1="57.54"
|
||||
y1="31.44"
|
||||
x2="128.81"
|
||||
y2="31.44"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stop-color="#ff4d4d" />
|
||||
<stop offset=".99" stop-color="#f9cb28" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
class="xlog-logo-dark"
|
||||
d="M56.32,100.47c0,15.98-11.84,27.61-27.85,27.61S.33,116.45,.33,100.47s11.83-27.45,28.08-27.45,27.91,11.63,27.91,27.45Z"
|
||||
/>
|
||||
<path
|
||||
class="xlog-logo-dark"
|
||||
d="M120,101.26v19.31c-4.87,4.63-12.9,7.6-21.65,7.6-18.33,0-30-10.49-30-26.7s12.92-28.34,31.07-28.34v28.13h20.58Z"
|
||||
/>
|
||||
<polygon
|
||||
:fill="`url(#${gradientId})`"
|
||||
points="119.7 21.48 128.81 38.51 83.27 62.88 57.54 14.79 85.17 0 101.79 31.06 119.7 21.48"
|
||||
/>
|
||||
<polygon
|
||||
class="xlog-logo-dark"
|
||||
points="0 63.11 16.54 32.82 .68 8.17 55.98 8.17 40.12 32.82 56.65 63.11 0 63.11"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.xlog-logo-dark {
|
||||
fill: #000;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.xlog-logo-dark {
|
||||
fill: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
// Markdown 渲染:marked 解析 + DOMPurify 消毒,输出到 .prose 容器
|
||||
import { computed } from 'vue'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { marked } from 'marked'
|
||||
|
||||
const props = defineProps<{ content: string }>()
|
||||
|
||||
marked.setOptions({ gfm: true, breaks: true })
|
||||
|
||||
const html = computed(() => {
|
||||
const raw = marked.parse(props.content, { async: false }) as string
|
||||
return DOMPurify.sanitize(raw)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prose" v-html="html" />
|
||||
</template>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
// 文章卡片:整体为一个链接,封面 + 标题/摘要 + 元信息 + 作者
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { Post } from '@/mock/data'
|
||||
import { estimateReadingTime } from '@/lib/utils'
|
||||
|
||||
import Avatar from './Avatar.vue'
|
||||
import FormattedNumber from './FormattedNumber.vue'
|
||||
import Time from './Time.vue'
|
||||
|
||||
const props = defineProps<{ post: Post; isShort?: boolean }>()
|
||||
|
||||
const readingTime = computed(() => estimateReadingTime(props.post.content))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink
|
||||
:to="`/post/${post.siteId}/${post.slug}`"
|
||||
class="xlog-post rounded-2xl flex flex-col items-center group relative border sm:hover:bg-hover transition-all hover:opacity-100"
|
||||
>
|
||||
<!-- 置顶标记 -->
|
||||
<span
|
||||
v-if="post.pinned"
|
||||
class="absolute top-2 right-2 z-10 text-xs border transition-colors text-zinc-500 inline-flex items-center bg-zinc-100 rounded-full px-2 py-[1.5px]"
|
||||
>
|
||||
<i class="i-mingcute-pin-2-fill mr-1" />
|
||||
Pinned
|
||||
</span>
|
||||
|
||||
<!-- 封面图 -->
|
||||
<div class="xlog-post-cover rounded-t-2xl overflow-hidden flex items-center relative w-full aspect-video border-b">
|
||||
<img
|
||||
:src="post.cover"
|
||||
:alt="post.title"
|
||||
class="object-cover size-full sm:group-hover:scale-105 sm:transition-transform sm:duration-400 sm:ease-in-out bg-white"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<div class="px-3 py-2 w-full min-w-0 flex flex-col text-sm space-y-2 sm:px-5 sm:py-4 h-auto sm:h-[163px]">
|
||||
<!-- 标题 + 摘要 -->
|
||||
<div class="space-y-2 line-clamp-3 h-[75px]">
|
||||
<h2 class="xlog-post-title font-bold text-zinc-700 text-base">
|
||||
{{ post.title }}
|
||||
</h2>
|
||||
<div class="xlog-post-excerpt text-zinc-500 line-clamp-3" style="word-break: break-word">
|
||||
{{ post.excerpt }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部元信息 -->
|
||||
<div class="xlog-post-meta text-zinc-400 flex items-center text-[13px] truncate space-x-2">
|
||||
<span
|
||||
v-if="post.tags[0]"
|
||||
class="hover:text-zinc-600 hover:bg-zinc-200 border transition-colors text-zinc-500 inline-flex items-center bg-zinc-100 rounded-full px-2 py-[1.5px] truncate text-xs sm:text-[13px] h-5"
|
||||
>
|
||||
<i class="i-mingcute-tag-line mr-[2px]" />
|
||||
{{ post.tags[0] }}
|
||||
</span>
|
||||
<span class="xlog-post-views inline-flex items-center">
|
||||
<i class="i-mingcute-eye-line mr-[2px] text-base" />
|
||||
<FormattedNumber :value="post.views" />
|
||||
</span>
|
||||
<span class="xlog-post-views inline-flex items-center">
|
||||
<i class="i-mingcute-comment-line mr-[2px] text-base" />
|
||||
<FormattedNumber :value="post.comments" />
|
||||
</span>
|
||||
<span class="xlog-post-word-count sm:inline-flex items-center hidden">
|
||||
<i class="i-mingcute-sandglass-line mr-[2px] text-sm" />
|
||||
<span style="word-spacing: -.2ch">{{ readingTime }} min</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 作者 + 时间 -->
|
||||
<div class="flex items-center space-x-1 text-xs sm:text-sm overflow-hidden">
|
||||
<span class="flex items-center cursor-pointer">
|
||||
<span class="size-5 inline-block mr-[6px]">
|
||||
<Avatar :src="post.author.avatar" :name="post.author.name" :size="20" />
|
||||
</span>
|
||||
<span class="font-medium truncate text-zinc-600">{{ post.author.name }}</span>
|
||||
</span>
|
||||
<span class="text-zinc-400 hidden sm:inline-block">·</span>
|
||||
<time class="xlog-post-date whitespace-nowrap text-zinc-400 hidden sm:inline-block">
|
||||
<Time :date="post.date" />
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
// 文章卡片骨架屏(配合 HomeFeed 的 grid 使用)
|
||||
withDefaults(defineProps<{ count?: number }>(), { count: 6 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-3 sm:gap-6 grid-cols-1 sm:grid-cols-3 my-8">
|
||||
<div
|
||||
v-for="i in count"
|
||||
:key="i"
|
||||
class="rounded-2xl border animate-pulse"
|
||||
>
|
||||
<!-- 封面图骨架 -->
|
||||
<div class="h-auto rounded-t-2xl rounded-b-none w-full aspect-video border-b bg-gray-100" />
|
||||
<!-- 内容骨架 -->
|
||||
<div class="rounded-t-none rounded-b-2xl p-3 pt-2 sm:p-5 sm:pt-4 h-[168px] sm:h-[204px]">
|
||||
<div class="flex items-center space-x-1 sm:space-x-2 mb-2 sm:mb-4 text-xs sm:text-sm">
|
||||
<span class="flex items-center space-x-1 sm:space-x-2">
|
||||
<span class="w-5 h-5 sm:w-6 sm:h-6 rounded-full bg-gray-100 block" />
|
||||
<span class="w-[120px] h-5 bg-gray-100 block rounded" />
|
||||
</span>
|
||||
</div>
|
||||
<span class="w-full h-28 bg-gray-100 block rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { formatTime } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ date: string | Date }>()
|
||||
|
||||
const text = computed(() => formatTime(props.date))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<time :datetime="typeof date === 'string' ? date : date.toISOString()">{{ text }}</time>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
// 仪表盘主内容容器
|
||||
defineProps<{ title?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-w-[270px] relative p-5 md:px-10 md:py-8 min-h-full flex flex-col bg-white"
|
||||
id="dashboard-main"
|
||||
>
|
||||
<h2 v-if="title" class="text-2xl font-bold mb-8">{{ title }}</h2>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
// 仪表盘侧边栏:Logo + 连接钱包 + 导航 + 底部帮助链接
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import Logo from '@/components/common/Logo.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const links = [
|
||||
{
|
||||
key: 'dashboard',
|
||||
label: 'Dashboard',
|
||||
to: '/dashboard',
|
||||
icon: 'i-mingcute-grid-line',
|
||||
level: 0,
|
||||
},
|
||||
{
|
||||
key: 'posts',
|
||||
label: 'Posts',
|
||||
to: '/dashboard/posts',
|
||||
icon: 'i-mingcute-news-line',
|
||||
level: 1,
|
||||
},
|
||||
{
|
||||
key: 'pages',
|
||||
label: 'Pages',
|
||||
to: '/dashboard/pages',
|
||||
icon: 'i-mingcute-file-line',
|
||||
level: 1,
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
label: 'Settings',
|
||||
to: '/dashboard/settings/general',
|
||||
icon: 'i-mingcute-settings-3-line',
|
||||
level: 1,
|
||||
},
|
||||
]
|
||||
|
||||
function isActive(link: { key: string }) {
|
||||
switch (link.key) {
|
||||
case 'dashboard':
|
||||
return route.path === '/dashboard'
|
||||
case 'posts':
|
||||
return route.path.startsWith('/dashboard/posts')
|
||||
case 'pages':
|
||||
return route.path.startsWith('/dashboard/pages')
|
||||
case 'settings':
|
||||
return route.path.startsWith('/dashboard/settings')
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-sidebar fixed h-full flex flex-col bg-slate-50">
|
||||
<!-- Logo -->
|
||||
<RouterLink to="/" class="mb-2 px-5 pt-3 pb-2 text-2xl font-extrabold flex items-center">
|
||||
<div class="inline-block size-9 mr-3">
|
||||
<Logo :size="36" />
|
||||
</div>
|
||||
MyBlog
|
||||
</RouterLink>
|
||||
|
||||
<!-- 连接钱包 -->
|
||||
<div class="mb-2 px-2 pt-3 pb-2">
|
||||
<button class="button is-primary is-block">Connect Wallet</button>
|
||||
</div>
|
||||
|
||||
<!-- 导航链接 -->
|
||||
<div class="px-3 space-y-[2px] text-zinc-500 flex-1 min-h-0 overflow-y-auto">
|
||||
<RouterLink
|
||||
v-for="link in links"
|
||||
:key="link.key"
|
||||
:to="link.to"
|
||||
class="flex px-4 h-12 items-center rounded-xl space-x-2 w-full transition-colors"
|
||||
:class="
|
||||
isActive(link)
|
||||
? 'bg-white font-medium text-accent drop-shadow-sm'
|
||||
: 'hover:bg-slate-200/50'
|
||||
"
|
||||
:style="{ marginLeft: `${link.level * 20}px` }"
|
||||
>
|
||||
<i :class="link.icon" class="text-xl" />
|
||||
<span class="truncate">{{ link.label }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- 底部固定按钮 -->
|
||||
<div class="flex items-center px-4 flex-col pb-4">
|
||||
<a
|
||||
href="#"
|
||||
class="space-x-1 text-zinc-500 hover:text-zinc-800 flex w-full h-12 items-center justify-center transition-colors mb-2"
|
||||
>
|
||||
<i class="i-mingcute-question-line text-lg" />
|
||||
<span>Need help?</span>
|
||||
</a>
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="space-x-2 border rounded-lg border-slate-200 text-accent hover:scale-105 transition-transform flex w-full h-12 items-center justify-center bg-white drop-shadow-sm"
|
||||
>
|
||||
<span class="i-mingcute-home-1-line" />
|
||||
<span>View Site</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
// 仪表盘移动端顶栏:汉堡菜单 + Logo
|
||||
import Logo from '@/components/common/Logo.vue'
|
||||
|
||||
const emit = defineEmits<{ toggle: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-full top-0 h-16 bg-slate-50 z-20 transition-all flex flex-row fixed px-5 md:px-10 items-center lg:hidden"
|
||||
>
|
||||
<button class="mr-3" aria-label="Toggle menu" @click="emit('toggle')">
|
||||
<i class="i-mingcute-menu-line text-2xl text-zinc-500" />
|
||||
</button>
|
||||
<RouterLink to="/" class="text-xl font-extrabold flex items-center">
|
||||
<div class="inline-block size-8 mr-2">
|
||||
<Logo :size="30" />
|
||||
</div>
|
||||
MyBlog
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
// 文章/页面管理列表:头部操作 + 状态筛选 + 列表项
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import FormattedNumber from '@/components/common/FormattedNumber.vue'
|
||||
import Tabs from '@/components/ui/Tabs.vue'
|
||||
import type { Post } from '@/mock/data'
|
||||
import { allPosts } from '@/mock/data'
|
||||
import { estimateReadingTime } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ title?: string; itemLabel?: string }>(),
|
||||
{ title: 'Posts', itemLabel: 'Post' },
|
||||
)
|
||||
|
||||
const tabs = [
|
||||
{ key: 'all', label: 'All Posts' },
|
||||
{ key: 'published', label: 'Published' },
|
||||
{ key: 'draft', label: 'Draft' },
|
||||
{ key: 'scheduled', label: 'Scheduled' },
|
||||
]
|
||||
|
||||
const filter = ref('all')
|
||||
const list = ref<Post[]>([...allPosts])
|
||||
const menuOpenFor = ref<string | null>(null)
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (filter.value === 'all') return list.value
|
||||
return list.value.filter((p) => (p.status ?? 'published') === filter.value)
|
||||
})
|
||||
|
||||
function statusText(status?: Post['status']) {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return 'Draft'
|
||||
case 'scheduled':
|
||||
return 'Scheduled'
|
||||
default:
|
||||
return 'Published'
|
||||
}
|
||||
}
|
||||
|
||||
function remove(id: string) {
|
||||
list.value = list.value.filter((p) => p.id !== id)
|
||||
menuOpenFor.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-screen-lg">
|
||||
<!-- 头部 -->
|
||||
<header class="mb-4 space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-2xl font-bold">{{ title }}</h2>
|
||||
</div>
|
||||
<div class="space-x-4">
|
||||
<RouterLink to="/dashboard/editor" class="button is-primary space-x-2 inline-flex">
|
||||
<i class="i-mingcute-add-line inline-block" />
|
||||
<span>New {{ itemLabel }}</span>
|
||||
</RouterLink>
|
||||
<button class="button is-secondary space-x-2">
|
||||
<i class="i-mingcute-file-import-line inline-block" />
|
||||
<span>Import</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 筛选标签 -->
|
||||
<Tabs :items="tabs" :active-key="filter" @select="filter = $event" />
|
||||
|
||||
<!-- 文章列表 -->
|
||||
<div class="space-y-0">
|
||||
<RouterLink
|
||||
v-for="post in filtered"
|
||||
:key="post.id"
|
||||
:to="`/dashboard/editor?id=${post.id}`"
|
||||
class="group relative hover:bg-zinc-100 rounded-lg py-4 px-3 transition-colors -mx-3 flex max-sm:flex-col gap-4"
|
||||
>
|
||||
<!-- 封面图 -->
|
||||
<div class="rounded-lg sm:w-48 overflow-hidden shrink-0">
|
||||
<img
|
||||
class="w-full aspect-video object-cover"
|
||||
:src="post.cover"
|
||||
:alt="post.title"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 信息区 -->
|
||||
<div class="min-w-0 flex-1 flex flex-col justify-between">
|
||||
<div class="xlog-post-title font-bold text-base text-zinc-700">
|
||||
<span>{{ post.title }}</span>
|
||||
</div>
|
||||
<div class="xlog-post-excerpt text-zinc-500 line-clamp-1 text-sm">
|
||||
{{ post.excerpt }}
|
||||
</div>
|
||||
<div
|
||||
class="xlog-post-meta text-zinc-400 flex items-center text-[13px] h-[26px] truncate"
|
||||
>
|
||||
<span
|
||||
v-if="post.tags[0]"
|
||||
class="border transition-colors text-zinc-500 inline-flex items-center bg-zinc-100 rounded-full px-2 py-[1.5px] truncate text-xs mr-2"
|
||||
>
|
||||
<i class="i-mingcute-tag-line mr-[2px]" />
|
||||
{{ post.tags[0] }}
|
||||
</span>
|
||||
<span class="xlog-post-word-count sm:inline-flex items-center hidden mr-2">
|
||||
<i class="i-mingcute-time-line mr-[2px]" />
|
||||
<span style="word-spacing: -.2ch">{{ estimateReadingTime(post.content) }} min</span>
|
||||
</span>
|
||||
<span class="xlog-post-views inline-flex items-center">
|
||||
<i class="i-mingcute-eye-line mr-[2px]" />
|
||||
<span><FormattedNumber :value="post.views" /></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-zinc-400 text-sm">
|
||||
<span class="capitalize">{{ statusText(post.status) }}</span>
|
||||
<span class="mx-2">·</span>
|
||||
<span>{{ post.date }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="shrink-0 flex gap-2 sm:self-center sm:ml-auto relative">
|
||||
<button
|
||||
class="text-gray-400 size-8 rounded inline-flex hover:bg-gray-200 justify-center items-center"
|
||||
aria-label="More actions"
|
||||
@click.prevent="menuOpenFor = menuOpenFor === post.id ? null : post.id"
|
||||
>
|
||||
<i class="i-mingcute-more-1-line text-2xl" />
|
||||
</button>
|
||||
<!-- 操作菜单 -->
|
||||
<div
|
||||
v-if="menuOpenFor === post.id"
|
||||
class="absolute right-0 top-full mt-1 z-10 bg-white border rounded-xl shadow-modal p-1 min-w-[140px]"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
class="flex w-full px-3 py-2 text-sm text-zinc-600 hover:bg-zinc-100 rounded-lg items-center"
|
||||
@click="remove(post.id)"
|
||||
>
|
||||
<i class="i-mingcute-delete-2-line mr-2 text-[#f91880]" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<EmptyState v-if="!filtered.length" text="No posts yet" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import Tabs from '@/components/ui/Tabs.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const items = [
|
||||
{ key: 'featured', label: 'Featured', to: '/' },
|
||||
{ key: 'shorts', label: 'Shorts', to: '/shorts' },
|
||||
{ key: 'latest', label: 'Latest', to: '/latest' },
|
||||
{ key: 'hottest', label: 'Hottest', to: '/hottest' },
|
||||
{ key: 'following', label: 'Following', to: '/following' },
|
||||
]
|
||||
|
||||
const activeKey = computed(
|
||||
() => items.find((item) => item.to === route.path)?.key ?? 'featured',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tabs :items="items" :active-key="activeKey" class="border-none" />
|
||||
</template>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
// 主页文章流:按类型过滤/排序,含 AI 过滤开关(仅演示交互)
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import PostCard from '@/components/common/PostCard.vue'
|
||||
import Skeleton from '@/components/common/Skeleton.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import { allPosts } from '@/mock/data'
|
||||
|
||||
type FeedType = 'featured' | 'shorts' | 'latest' | 'hottest' | 'following'
|
||||
|
||||
const props = withDefaults(defineProps<{ type?: FeedType }>(), { type: 'featured' })
|
||||
|
||||
const loading = ref(false)
|
||||
const ai = ref(true)
|
||||
|
||||
const posts = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'latest':
|
||||
return [...allPosts].sort(
|
||||
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
|
||||
)
|
||||
case 'hottest':
|
||||
return [...allPosts].sort((a, b) => b.views - a.views)
|
||||
case 'featured': {
|
||||
const featured = allPosts.filter((p) => p.featured)
|
||||
return featured.length ? featured : allPosts
|
||||
}
|
||||
default:
|
||||
return []
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-10">
|
||||
<!-- AI 过滤开关 -->
|
||||
<div class="flex items-center text-zinc-500">
|
||||
<i class="i-mingcute-sparkles-line mr-2 text-lg" />
|
||||
<span class="text-sm">AI filter</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle AI filter"
|
||||
class="ml-5 relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
:class="ai ? 'bg-accent' : 'bg-gray-200'"
|
||||
@click="ai = !ai"
|
||||
>
|
||||
<span
|
||||
class="inline-block size-4 rounded-full bg-white transition"
|
||||
:class="ai ? 'translate-x-6' : 'translate-x-1'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<Skeleton v-if="loading" />
|
||||
<div v-else-if="posts.length" class="xlog-posts my-8 min-h-[1177px]">
|
||||
<div class="grid gap-3 sm:gap-6 grid-cols-1 sm:grid-cols-3">
|
||||
<PostCard v-for="post in posts" :key="post.id" :post="post" />
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState v-else text="No posts yet" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
// 主页右侧边栏:推广链接 + 搜索 + 推荐创作者 + CSB 领取
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Avatar from '@/components/common/Avatar.vue'
|
||||
import { sites } from '@/mock/data'
|
||||
|
||||
import SearchInput from './SearchInput.vue'
|
||||
|
||||
const creators = sites
|
||||
const claimed = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="w-80 pl-10 hidden lg:block space-y-10">
|
||||
<!-- 推广链接 -->
|
||||
<div class="space-y-5">
|
||||
<RouterLink to="/about" class="flex items-center text-zinc-500 hover:text-accent">
|
||||
<i class="i-mingcute-information-line mr-2" />
|
||||
About
|
||||
</RouterLink>
|
||||
<a href="#" class="flex items-center text-zinc-500 hover:text-accent">
|
||||
<i class="i-mingcute-question-line mr-2" />
|
||||
Help
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 搜索 -->
|
||||
<SearchInput />
|
||||
|
||||
<!-- 推荐创作者 -->
|
||||
<div class="text-center text-zinc-700 space-y-3">
|
||||
<p class="font-bold text-lg">Suggested creators for you</p>
|
||||
<ul class="space-y-3">
|
||||
<li v-for="creator in creators" :key="creator.handle" class="flex align-middle">
|
||||
<RouterLink class="inline-flex align-middle w-full" :to="`/site/${creator.handle}`">
|
||||
<span class="size-10 inline-block">
|
||||
<Avatar :src="creator.avatar" :name="creator.name" :size="40" />
|
||||
</span>
|
||||
<span class="ml-3 min-w-0 flex-1 justify-center inline-flex flex-col">
|
||||
<span class="truncate w-full inline-block font-medium">{{ creator.name }}</span>
|
||||
<span class="text-gray-500 text-xs truncate w-full inline-block mt-1">
|
||||
{{ creator.description }}
|
||||
</span>
|
||||
</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- CSB 领取 -->
|
||||
<div class="text-center text-zinc-700 space-y-3">
|
||||
<p class="font-bold text-lg">Need More CSB?</p>
|
||||
<button class="button is-primary is-block" @click="claimed = true">
|
||||
{{ claimed ? 'Claimed ✓' : 'Claim CSB' }}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const tabs = [
|
||||
{ key: 'home', label: 'Home', to: '/' },
|
||||
{ key: 'about', label: 'About', to: '/about' },
|
||||
{ key: 'github', label: 'GitHub Stars', to: 'https://github.com/Crossbell-Box/xLog' },
|
||||
]
|
||||
|
||||
function isActive(key: string, to: string) {
|
||||
if (to.startsWith('http')) return false
|
||||
return route.path === to
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-x-14 text-zinc-500 flex">
|
||||
<a
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:href="tab.to"
|
||||
class="hover:text-accent text-lg"
|
||||
:class="{ 'text-accent': isActive(tab.key, tab.to) }"
|
||||
:target="tab.to.startsWith('http') ? '_blank' : undefined"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
// 底部推广链接(RSS / GitHub / Discord / Twitter)
|
||||
const links = [
|
||||
{
|
||||
icon: 'i-mingcute-rss-2-fill text-2xl',
|
||||
color: 'text-[#ee832f]',
|
||||
href: '/rss.xml',
|
||||
label: 'RSS',
|
||||
},
|
||||
{
|
||||
icon: 'i-mingcute-github-fill text-2xl',
|
||||
color: 'text-[#181717] dark:text-[#e6edf3]',
|
||||
href: 'https://github.com/Crossbell-Box/xLog',
|
||||
label: 'GitHub',
|
||||
},
|
||||
{
|
||||
icon: 'i-mingcute-discord-fill text-2xl',
|
||||
color: 'text-[#7289da]',
|
||||
href: 'https://discord.gg/xLog',
|
||||
label: 'Discord',
|
||||
},
|
||||
{
|
||||
icon: 'i-mingcute-twitter-fill text-2xl',
|
||||
color: 'text-[#1DA1F2]',
|
||||
href: 'https://twitter.com/xLog',
|
||||
label: 'Twitter',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<a
|
||||
v-for="link in links"
|
||||
:key="link.label"
|
||||
:href="link.href"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="flex-1 flex items-center justify-center hover:opacity-80"
|
||||
:class="link.color"
|
||||
:aria-label="link.label"
|
||||
>
|
||||
<i :class="link.icon" />
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const query = ref('')
|
||||
|
||||
function submit() {
|
||||
if (!query.value.trim()) return
|
||||
router.push(`/search?q=${encodeURIComponent(query.value.trim())}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative xlog-search-input">
|
||||
<i class="i-mingcute-search-line absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
|
||||
<input
|
||||
v-model="query"
|
||||
class="input is-block pl-9"
|
||||
placeholder="Search"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
// 文章互动区(点赞/打赏/分享)+ 评论区
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Avatar from '@/components/common/Avatar.vue'
|
||||
import Time from '@/components/common/Time.vue'
|
||||
import type { Comment, Post } from '@/mock/data'
|
||||
import { comments } from '@/mock/data'
|
||||
|
||||
const props = defineProps<{ post: Post }>()
|
||||
|
||||
// 点赞
|
||||
const liked = ref(false)
|
||||
const likeCount = ref(props.post.likes)
|
||||
function toggleLike() {
|
||||
liked.value = !liked.value
|
||||
likeCount.value += liked.value ? 1 : -1
|
||||
}
|
||||
|
||||
// 打赏
|
||||
const tipped = ref(false)
|
||||
|
||||
// 分享(复制链接)
|
||||
const shared = ref(false)
|
||||
async function share() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href)
|
||||
} catch {
|
||||
/* ignore clipboard errors */
|
||||
}
|
||||
shared.value = true
|
||||
window.setTimeout(() => (shared.value = false), 2000)
|
||||
}
|
||||
|
||||
// 评论
|
||||
const list = ref<Comment[]>(comments)
|
||||
const draft = ref('')
|
||||
function submitComment() {
|
||||
const content = draft.value.trim()
|
||||
if (!content) return
|
||||
list.value.unshift({
|
||||
id: `c-${list.value.length + 1}`,
|
||||
author: {
|
||||
handle: 'you',
|
||||
name: 'You',
|
||||
avatar: '',
|
||||
},
|
||||
content,
|
||||
date: new Date().toISOString(),
|
||||
likes: 0,
|
||||
})
|
||||
draft.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- 互动按钮 -->
|
||||
<div
|
||||
class="xlog-reactions flex fill-gray-400 text-gray-500 sm:items-center space-x-6 sm:space-x-10 mt-14 mb-12"
|
||||
>
|
||||
<button class="button is-like" :class="{ 'text-[#f91880]': liked }" @click="toggleLike">
|
||||
<i class="i-mingcute-thumb-up-2-fill mr-2" />
|
||||
<span>{{ likeCount }}</span>
|
||||
</button>
|
||||
<button class="button is-tip" @click="tipped = !tipped">
|
||||
<i class="i-mingcute-pig-money-line mr-2" />
|
||||
<span>{{ tipped ? 'Tipped ✓' : 'Tip' }}</span>
|
||||
</button>
|
||||
<button class="button is-share" @click="share">
|
||||
<i class="i-mingcute-share-forward-line mr-2" />
|
||||
<span>{{ shared ? 'Copied!' : 'Share' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 评论区 -->
|
||||
<div class="xlog-comment mb-10" id="comments">
|
||||
<div class="xlog-comment-count border-b pb-2 mb-6 font-bold">
|
||||
Comments ({{ list.length }})
|
||||
</div>
|
||||
|
||||
<!-- 评论输入 -->
|
||||
<div class="xlog-comment-input flex mb-6">
|
||||
<span class="mr-3">
|
||||
<Avatar name="You" :size="45" />
|
||||
</span>
|
||||
<div class="flex-1">
|
||||
<textarea
|
||||
v-model="draft"
|
||||
class="input is-block min-h-[74px] py-3 resize-y"
|
||||
placeholder="Write a comment..."
|
||||
@keydown.meta.enter="submitComment"
|
||||
@keydown.ctrl.enter="submitComment"
|
||||
/>
|
||||
<div class="flex justify-end mt-2">
|
||||
<button
|
||||
class="button is-primary is-sm"
|
||||
:class="{ 'is-loading': false }"
|
||||
:disabled="!draft.trim()"
|
||||
@click="submitComment"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 评论列表 -->
|
||||
<div class="xlog-comment-list">
|
||||
<div
|
||||
v-for="comment in list"
|
||||
:key="comment.id"
|
||||
class="xlog-comment-item mt-6 flex space-x-3"
|
||||
>
|
||||
<Avatar :src="comment.author.avatar" :name="comment.author.name" :size="40" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center space-x-2 text-sm">
|
||||
<span class="font-bold">{{ comment.author.name }}</span>
|
||||
<span class="text-zinc-400 text-xs">
|
||||
<Time :date="comment.date" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-zinc-600 mt-1 leading-relaxed break-words">
|
||||
{{ comment.content }}
|
||||
</div>
|
||||
<button
|
||||
class="text-zinc-400 text-xs mt-1 hover:text-accent inline-flex items-center"
|
||||
>
|
||||
<i class="i-mingcute-thumb-up-2-line mr-1" />
|
||||
{{ comment.likes }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import type { Post } from '@/mock/data'
|
||||
|
||||
import Time from '@/components/common/Time.vue'
|
||||
|
||||
defineProps<{ post: Post }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="xlog-post-meta">
|
||||
<div class="text-zinc-400 mt-5 space-x-5 flex items-center justify-center">
|
||||
<!-- 发布日期 -->
|
||||
<Time class="xlog-post-date whitespace-nowrap" :date="post.date" />
|
||||
<!-- 标签 -->
|
||||
<span class="xlog-post-tags space-x-1 truncate min-w-0">
|
||||
<a
|
||||
v-for="tag in post.tags"
|
||||
:key="tag"
|
||||
class="hover:text-accent"
|
||||
:href="`/tag/${tag}`"
|
||||
>
|
||||
#{{ tag }}
|
||||
</a>
|
||||
</span>
|
||||
<!-- 阅读量 -->
|
||||
<span class="xlog-post-views inline-flex items-center">
|
||||
<i class="i-mingcute-eye-line mr-[2px]" />
|
||||
<span>{{ post.views }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { Post } from '@/mock/data'
|
||||
|
||||
defineProps<{ post: Post }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h2
|
||||
class="xlog-post-title mb-8 flex items-center justify-center text-center relative text-4xl font-extrabold"
|
||||
>
|
||||
<span>{{ post.title }}</span>
|
||||
</h2>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
// 站点页 Footer:版权 + 语言/暗色切换
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import DarkModeSwitch from '@/components/common/DarkModeSwitch.vue'
|
||||
import LanguageSwitch from '@/components/common/LanguageSwitch.vue'
|
||||
import Logo from '@/components/common/Logo.vue'
|
||||
import { sites } from '@/mock/data'
|
||||
|
||||
const route = useRoute()
|
||||
const site = computed(
|
||||
() => sites.find((s) => s.handle === (route.params.site as string)) ?? sites[0],
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="text-zinc-500 border-t">
|
||||
<div class="max-w-screen-lg mx-auto px-5 py-10">
|
||||
<div
|
||||
class="text-xs sm:flex justify-between sm:space-x-5 sm:space-y-0 space-y-5 sm:items-center"
|
||||
>
|
||||
<div class="font-medium text-base">
|
||||
©
|
||||
<RouterLink :to="`/site/${site.handle}`" class="hover:text-accent">
|
||||
{{ site.name }}
|
||||
</RouterLink>
|
||||
· powered by
|
||||
<RouterLink to="/" class="inline-flex items-center align-middle hover:text-accent">
|
||||
<span class="inline-block size-5 mr-1">
|
||||
<Logo :size="20" />
|
||||
</span>
|
||||
xLog
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="flex gap-x-2 items-center justify-center">
|
||||
<LanguageSwitch />
|
||||
<DarkModeSwitch />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
// 站点信息头:Banner + 头像 + 站点名/简介 + 关注按钮 + 导航
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { sites } from '@/mock/data'
|
||||
|
||||
import SiteTabs from './SiteTabs.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const site = computed(
|
||||
() => sites.find((s) => s.handle === (route.params.site as string)) ?? sites[0],
|
||||
)
|
||||
|
||||
const following = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="xlog-header border-b border-zinc-100 relative">
|
||||
<!-- 可选 Banner 图 -->
|
||||
<div v-if="site.banner" class="xlog-banner absolute inset-0 overflow-hidden">
|
||||
<img class="object-cover w-full h-full" :src="site.banner" alt="banner" />
|
||||
</div>
|
||||
|
||||
<div class="px-5 max-w-screen-lg mx-auto h-full relative flex items-center flex-col z-10">
|
||||
<div class="flex py-12 w-full">
|
||||
<div class="xlog-site-info flex space-x-6 sm:space-x-8 w-full">
|
||||
<!-- 头像 -->
|
||||
<img
|
||||
class="xlog-site-icon max-w-[100px] max-h-[100px] sm:max-w-none sm:max-h-none rounded-full bg-zinc-100"
|
||||
:src="site.avatar"
|
||||
width="150"
|
||||
height="150"
|
||||
:alt="`${site.name} avatar`"
|
||||
/>
|
||||
|
||||
<!-- 站点信息 -->
|
||||
<div class="flex-1 min-w-0 relative space-y-2 sm:space-y-3 min-h-[108px]">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1
|
||||
class="xlog-site-name text-3xl sm:text-4xl font-bold text-zinc-900 leading-snug break-words min-w-0"
|
||||
>
|
||||
{{ site.name }}
|
||||
</h1>
|
||||
<div class="ml-0 sm:ml-8 space-x-3 sm:space-x-4 flex items-center">
|
||||
<button
|
||||
class="button is-primary is-sm"
|
||||
@click="following = !following"
|
||||
>
|
||||
{{ following ? 'Following' : 'Follow' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 简介 -->
|
||||
<div
|
||||
class="xlog-site-description text-gray-500 leading-snug text-sm sm:text-base line-clamp-4 whitespace-pre-wrap"
|
||||
>
|
||||
{{ site.description }}
|
||||
</div>
|
||||
<!-- 关注数 -->
|
||||
<span class="text-sm text-zinc-400">{{ site.followers }} followers</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导航栏 -->
|
||||
<div class="text-gray-500 flex items-center justify-between w-full mt-auto">
|
||||
<SiteTabs />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
// 站点头导航:Home / Archives
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import Tabs from '@/components/ui/Tabs.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const items = computed(() => {
|
||||
const site = route.params.site as string
|
||||
return [
|
||||
{ key: 'home', label: 'Home', to: `/site/${site}` },
|
||||
{ key: 'archives', label: 'Archives', to: `/site/${site}/archives` },
|
||||
]
|
||||
})
|
||||
|
||||
const activeKey = computed(() =>
|
||||
route.path.includes('/archives') ? 'archives' : 'home',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tabs :items="items" :active-key="activeKey" class="border-none mb-0" />
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
// 通用 Tabs:underline(下划线)与 rounded(胶囊)两种类型
|
||||
// 有 `to` 的项渲染为 RouterLink,无 `to` 的项渲染为按钮并触发 select
|
||||
export interface TabItem {
|
||||
key: string
|
||||
label: string
|
||||
to?: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ items: TabItem[]; activeKey: string; type?: 'underline' | 'rounded' }>(),
|
||||
{ type: 'underline' },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ select: [key: string] }>()
|
||||
|
||||
function cls(item: TabItem) {
|
||||
const base =
|
||||
'inline-flex items-center whitespace-nowrap cursor-pointer transition-colors relative'
|
||||
if (props.type === 'rounded') {
|
||||
return [
|
||||
base,
|
||||
'rounded-full h-8 px-3',
|
||||
props.activeKey === item.key
|
||||
? 'bg-zinc-950 text-white'
|
||||
: 'bg-zinc-100 text-zinc-800 hover:bg-zinc-200',
|
||||
].join(' ')
|
||||
}
|
||||
return [
|
||||
base,
|
||||
'h-10',
|
||||
props.activeKey === item.key
|
||||
? 'text-accent font-medium border-b-2 border-accent'
|
||||
: 'text-gray-600 hover:text-accent',
|
||||
].join(' ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex mb-8 overflow-x-auto scrollbar-hide"
|
||||
:class="type === 'rounded' ? 'space-x-3 text-sm' : 'space-x-5 border-b'"
|
||||
>
|
||||
<template v-for="item in items" :key="item.key">
|
||||
<RouterLink v-if="item.to" :to="item.to" :class="cls(item)">
|
||||
<i v-if="item.icon" :class="item.icon" class="mr-2" />
|
||||
{{ item.label }}
|
||||
</RouterLink>
|
||||
<button v-else type="button" :class="cls(item)" @click="emit('select', item.key)">
|
||||
<i v-if="item.icon" :class="item.icon" class="mr-2" />
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const dark = ref(document.documentElement.classList.contains('dark'))
|
||||
|
||||
/** 暗色模式:在 <html> 上切换 .dark 类并持久化到 localStorage */
|
||||
export function useTheme() {
|
||||
function setDark(value: boolean) {
|
||||
dark.value = value
|
||||
document.documentElement.classList.toggle('dark', value)
|
||||
localStorage.setItem('theme', value ? 'dark' : 'light')
|
||||
}
|
||||
function toggle() {
|
||||
setDark(!dark.value)
|
||||
}
|
||||
return { dark, setDark, toggle }
|
||||
}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, any>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
// 仪表盘布局:桌面端侧边栏 + 主内容;移动端顶栏 + 抽屉侧边栏
|
||||
import { ref } from 'vue'
|
||||
|
||||
import DashboardSidebar from '@/components/dashboard/DashboardSidebar.vue'
|
||||
import DashboardTopbar from '@/components/dashboard/DashboardTopbar.vue'
|
||||
|
||||
const mobileNavOpen = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen bg-slate-100">
|
||||
<!-- 桌面端侧边栏占位 -->
|
||||
<div class="w-sidebar shrink-0 hidden lg:block">
|
||||
<DashboardSidebar />
|
||||
</div>
|
||||
|
||||
<!-- 移动端抽屉 -->
|
||||
<Transition name="drawer">
|
||||
<div
|
||||
v-if="mobileNavOpen"
|
||||
class="fixed inset-y-0 left-0 z-40 lg:hidden shadow-modal"
|
||||
>
|
||||
<DashboardSidebar />
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="mobileNavOpen"
|
||||
class="fixed inset-0 z-30 bg-black/30 lg:hidden"
|
||||
@click="mobileNavOpen = false"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<!-- 移动端顶栏 -->
|
||||
<DashboardTopbar @toggle="mobileNavOpen = !mobileNavOpen" />
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="lg:p-3 size-full max-w-[calc(100vw-240px)] pt-16 lg:pt-0">
|
||||
<div class="bg-white size-full lg:rounded-xl lg:drop-shadow overflow-y-auto">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.drawer-enter-from,
|
||||
.drawer-leave-to {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
// 主页布局:固定 Header + 内容区 + Footer
|
||||
import DarkModeSwitch from '@/components/common/DarkModeSwitch.vue'
|
||||
import LanguageSwitch from '@/components/common/LanguageSwitch.vue'
|
||||
import Logo from '@/components/common/Logo.vue'
|
||||
import HomeTabs from '@/components/home/HomeTabs.vue'
|
||||
import PromotionLinks from '@/components/home/PromotionLinks.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white min-h-screen flex flex-col">
|
||||
<!-- Header -->
|
||||
<header class="py-5 fixed w-full top-0 bg-white z-[2]">
|
||||
<div class="max-w-screen-xl px-5 mx-auto flex justify-between items-center">
|
||||
<!-- 左侧:Logo + 导航 -->
|
||||
<div class="space-x-14 flex items-center">
|
||||
<RouterLink to="/" class="text-2xl font-extrabold flex items-center">
|
||||
<div class="inline-block size-9 mr-3">
|
||||
<Logo :size="36" />
|
||||
</div>
|
||||
xLog
|
||||
</RouterLink>
|
||||
<div class="hidden sm:block">
|
||||
<HomeTabs />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 右侧:连接钱包按钮 -->
|
||||
<div class="space-x-14 text-zinc-500 flex items-center">
|
||||
<button class="button is-black">Connect</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<section class="pt-24 flex-1">
|
||||
<div class="max-w-screen-xl px-5 mx-auto flex">
|
||||
<router-view />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="mt-10 font-medium border-t">
|
||||
<div class="max-w-screen-xl px-5 py-14 mx-auto flex flex-col sm:flex-row justify-between">
|
||||
<div class="w-full sm:w-72 space-y-4">
|
||||
<PromotionLinks />
|
||||
<div class="space-y-4">
|
||||
<RouterLink to="/about" class="flex items-center text-zinc-500 hover:text-accent">
|
||||
<i class="i-mingcute-information-line mr-2" />
|
||||
About
|
||||
</RouterLink>
|
||||
<a href="#" class="flex items-center text-zinc-500 hover:text-accent">
|
||||
<i class="i-mingcute-question-line mr-2" />
|
||||
Help
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center space-y-4 sm:space-y-0 sm:space-x-4 mx-auto sm:mx-0 mt-10 sm:mt-0 flex-col sm:flex-row"
|
||||
>
|
||||
<DarkModeSwitch />
|
||||
<LanguageSwitch />
|
||||
<span>
|
||||
© <RouterLink to="/" class="hover:text-accent">xLog</RouterLink>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
// 站点布局:站点信息头 + 内容区 + 站点页脚
|
||||
import SiteFooter from '@/components/site/SiteFooter.vue'
|
||||
import SiteHeader from '@/components/site/SiteHeader.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="xlog-page bg-white min-h-screen flex flex-col">
|
||||
<SiteHeader />
|
||||
<main class="xlog-post-area max-w-screen-lg mx-auto px-5 pt-8 relative w-full flex-1">
|
||||
<router-view />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
/** 数字格式化:1234 → 1.2k,1234567 → 1.2M */
|
||||
export function formatNumber(n: number): string {
|
||||
if (Number.isNaN(n)) return '0'
|
||||
if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
|
||||
if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k`
|
||||
return String(n)
|
||||
}
|
||||
|
||||
/** 相对时间:3 hours ago / 3 days ago */
|
||||
export function formatTime(input: string | Date): string {
|
||||
const date = new Date(input)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
const diff = Date.now() - date.getTime()
|
||||
const minute = 60_000
|
||||
const hour = 60 * minute
|
||||
const day = 24 * hour
|
||||
if (diff < minute) return 'just now'
|
||||
if (diff < hour) return `${Math.floor(diff / minute)} minutes ago`
|
||||
if (diff < day) return `${Math.floor(diff / hour)} hours ago`
|
||||
if (diff < 7 * day) return `${Math.floor(diff / day)} days ago`
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
|
||||
/** 估算阅读时长(按中文 ~300 字/分钟,英文 ~200 词/分钟粗略换算为字符数) */
|
||||
export function estimateReadingTime(content: string): number {
|
||||
const minutes = content.length / 400
|
||||
return Math.max(1, Math.round(minutes))
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import App from './App.vue'
|
||||
import { router } from './router'
|
||||
import './styles/main.css'
|
||||
|
||||
// 初始化暗色模式(优先本地存储,其次系统偏好)
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
const dark =
|
||||
savedTheme === 'dark' ||
|
||||
(!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
if (dark) {
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
@@ -0,0 +1,402 @@
|
||||
// Mock 数据:后端暂未实现,先以本地数据驱动页面
|
||||
|
||||
export interface Author {
|
||||
handle: string
|
||||
name: string
|
||||
avatar: string
|
||||
banner?: string
|
||||
description?: string
|
||||
followers?: number
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: string
|
||||
siteId: string
|
||||
slug: string
|
||||
title: string
|
||||
excerpt: string
|
||||
content: string
|
||||
cover?: string
|
||||
tags: string[]
|
||||
views: number
|
||||
comments: number
|
||||
likes: number
|
||||
date: string
|
||||
author: Author
|
||||
featured?: boolean
|
||||
pinned?: boolean
|
||||
status?: 'published' | 'draft' | 'scheduled'
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string
|
||||
author: Author
|
||||
content: string
|
||||
date: string
|
||||
likes: number
|
||||
}
|
||||
|
||||
export const avatar = (seed: string, size = 100) =>
|
||||
`https://i.pravatar.cc/${size}?img=${seed}`
|
||||
export const cover = (seed: string, w = 640, h = 360) =>
|
||||
`https://picsum.photos/seed/${seed}/${w}/${h}`
|
||||
|
||||
const authors: Record<string, Author> = {
|
||||
dirak: {
|
||||
handle: 'dirak',
|
||||
name: 'Di',
|
||||
avatar: avatar('1'),
|
||||
banner: cover('banner-dirak', 1024, 300),
|
||||
description: 'Building a more connected, open, and elegant world.',
|
||||
followers: 1284,
|
||||
},
|
||||
she: {
|
||||
handle: 'she',
|
||||
name: 'She',
|
||||
avatar: avatar('2'),
|
||||
banner: cover('banner-she', 1024, 300),
|
||||
description: 'Frontend engineer. Loves Vue, design systems & coffee.',
|
||||
followers: 932,
|
||||
},
|
||||
kyoko: {
|
||||
handle: 'kyoko',
|
||||
name: 'Kyoko',
|
||||
avatar: avatar('3'),
|
||||
banner: cover('banner-kyoko', 1024, 300),
|
||||
description: 'Indie hacker writing about web3, notes & life.',
|
||||
followers: 2107,
|
||||
},
|
||||
vincent: {
|
||||
handle: 'vincent',
|
||||
name: 'Vincent',
|
||||
avatar: avatar('4'),
|
||||
banner: cover('banner-vincent', 1024, 300),
|
||||
description: 'Photographer & blogger. Film everywhere.',
|
||||
followers: 566,
|
||||
},
|
||||
aki: {
|
||||
handle: 'aki',
|
||||
name: 'Aki',
|
||||
avatar: avatar('5'),
|
||||
banner: cover('banner-aki', 1024, 300),
|
||||
description: 'Full-stack dev. Open source enthusiast.',
|
||||
followers: 1490,
|
||||
},
|
||||
}
|
||||
|
||||
export const sites: Author[] = Object.values(authors)
|
||||
|
||||
const posts: Post[] = [
|
||||
{
|
||||
id: 'p1',
|
||||
siteId: 'dirak',
|
||||
slug: 'welcome-to-xlog',
|
||||
title: 'Welcome to xLog — a new home for your blog',
|
||||
excerpt:
|
||||
'xLog is a decentralized blog platform built on Crossbell. It gives every creator a permanent home on the open web.',
|
||||
content: `# Welcome to xLog
|
||||
|
||||
This is an **example post** rendered from Markdown. xLog is a blog platform built on Crossbell that gives every creator a permanent home on the open web.
|
||||
|
||||
> Share your words with the world, and own them forever.
|
||||
|
||||
## Why we built it
|
||||
|
||||
- **Own your content** — everything lives on the blockchain.
|
||||
- **Open & connected** — follow, comment, tip across sites.
|
||||
- **Beautiful by default** — a clean reading experience.
|
||||
|
||||
## Getting started
|
||||
|
||||
\`\`\`bash
|
||||
npx create-xlog
|
||||
\`\`\`
|
||||
|
||||
Check out the [docs](https://xlog.app) for more.`,
|
||||
cover: cover('welcome', 640, 360),
|
||||
tags: ['intro', 'web3'],
|
||||
views: 1234,
|
||||
comments: 18,
|
||||
likes: 42,
|
||||
date: '2026-07-28',
|
||||
author: authors.dirak,
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
siteId: 'dirak',
|
||||
slug: 'designing-with-css-variables',
|
||||
title: 'Designing with CSS variables and the modern dark mode',
|
||||
excerpt:
|
||||
'CSS variables make theming feel effortless. Here is how we keep the entire UI consistent with just a few custom properties.',
|
||||
content: `# Designing with CSS variables
|
||||
|
||||
CSS custom properties are a superpower for theming. With a handful of variables you can drive the entire visual language of an app.
|
||||
|
||||
\`\`\`css
|
||||
html {
|
||||
--theme-color: #f97316;
|
||||
--border-color: #eee;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## Dark mode made easy
|
||||
|
||||
Toggle a class on \`<html>\`, and the variables re-resolve:
|
||||
|
||||
\`\`\`css
|
||||
html.dark {
|
||||
--theme-color: #ea580c;
|
||||
--border-color: #333;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
That is the whole trick.`,
|
||||
cover: cover('cssvar', 640, 360),
|
||||
tags: ['css', 'design'],
|
||||
views: 892,
|
||||
comments: 7,
|
||||
likes: 31,
|
||||
date: '2026-07-21',
|
||||
author: authors.dirak,
|
||||
},
|
||||
{
|
||||
id: 'p3',
|
||||
siteId: 'dirak',
|
||||
slug: 'building-a-blog-on-crossbell',
|
||||
title: 'Building a blog on Crossbell: notes from the field',
|
||||
excerpt:
|
||||
'A practical walkthrough of publishing posts, claiming CSB tokens, and connecting with readers on the open network.',
|
||||
content: `# Building on Crossbell
|
||||
|
||||
Crossbell gives you the primitives to build a fully on-chain blog. Let's walk through the key steps.
|
||||
|
||||
1. Create a character
|
||||
2. Publish posts
|
||||
3. Claim CSB
|
||||
4. Connect with readers`,
|
||||
cover: cover('crossbell', 640, 360),
|
||||
tags: ['tutorial', 'crossbell'],
|
||||
views: 2011,
|
||||
comments: 24,
|
||||
likes: 87,
|
||||
date: '2026-07-14',
|
||||
author: authors.dirak,
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
id: 'p4',
|
||||
siteId: 'she',
|
||||
slug: 'vue-3-composition-api-guide',
|
||||
title: 'A practical guide to Vue 3 Composition API',
|
||||
excerpt:
|
||||
'Stop copying snippets and start understanding setup functions, refs, and computed. A guide for busy developers.',
|
||||
content: `# Vue 3 Composition API
|
||||
|
||||
The Composition API gives you better code organization and reusability.
|
||||
|
||||
\`\`\`ts
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const count = ref(0)
|
||||
const double = computed(() => count.value * 2)
|
||||
\`\`\`
|
||||
|
||||
That's the essence.`,
|
||||
cover: cover('vue3', 640, 360),
|
||||
tags: ['vue', 'frontend'],
|
||||
views: 1750,
|
||||
comments: 12,
|
||||
likes: 64,
|
||||
date: '2026-07-25',
|
||||
author: authors.she,
|
||||
},
|
||||
{
|
||||
id: 'p5',
|
||||
siteId: 'she',
|
||||
slug: 'tailwind-vs-css-modules',
|
||||
title: 'Tailwind vs CSS Modules: what we actually use',
|
||||
excerpt:
|
||||
'A pragmatic comparison from someone who has shipped both. Spoiler: the answer is "it depends" — but here is our default.',
|
||||
content: `# Tailwind vs CSS Modules
|
||||
|
||||
Both are great. We default to Tailwind for speed and consistency, with CSS modules reserved for truly isolated components.`,
|
||||
cover: cover('tailwind', 640, 360),
|
||||
tags: ['css', 'tailwind'],
|
||||
views: 643,
|
||||
comments: 5,
|
||||
likes: 22,
|
||||
date: '2026-07-18',
|
||||
status: 'draft',
|
||||
author: authors.she,
|
||||
},
|
||||
{
|
||||
id: 'p6',
|
||||
siteId: 'kyoko',
|
||||
slug: 'on-chain-blogging',
|
||||
title: 'Why I moved my blog on-chain',
|
||||
excerpt:
|
||||
'Censorship resistance, portability, and an open social graph — the reasons that finally convinced me to leave the walled gardens.',
|
||||
content: `# Why on-chain?
|
||||
|
||||
When your words live on a blockchain, no platform can take them away.
|
||||
|
||||
- Censorship resistant
|
||||
- Portable across frontends
|
||||
- Own your social graph
|
||||
|
||||
It's not for everyone, but it is for writers who care about permanence.`,
|
||||
cover: cover('onchain', 640, 360),
|
||||
tags: ['web3', 'opinion'],
|
||||
views: 3310,
|
||||
comments: 45,
|
||||
likes: 132,
|
||||
date: '2026-07-22',
|
||||
author: authors.kyoko,
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
id: 'p7',
|
||||
siteId: 'kyoko',
|
||||
slug: 'indie-hacking-notes',
|
||||
title: 'Indie hacking: three months of shipping in public',
|
||||
excerpt:
|
||||
'Revenue numbers, mental models, and the mistakes I will not repeat. An honest recap of a quarter spent building in public.',
|
||||
content: `# Three months of shipping in public
|
||||
|
||||
Here's what worked, what didn't, and what I'd do differently.
|
||||
|
||||
## Wins
|
||||
- Launched the MVP in 6 weeks
|
||||
- 400 signups from a single post
|
||||
|
||||
## Losses
|
||||
- Churned a third of early users
|
||||
- Spent too long polishing internals`,
|
||||
cover: cover('indie', 640, 360),
|
||||
tags: ['indie', 'business'],
|
||||
views: 2214,
|
||||
comments: 28,
|
||||
likes: 95,
|
||||
date: '2026-07-10',
|
||||
author: authors.kyoko,
|
||||
},
|
||||
{
|
||||
id: 'p8',
|
||||
siteId: 'vincent',
|
||||
slug: 'film-vs-digital',
|
||||
title: 'Film vs digital: a photographer confession',
|
||||
excerpt:
|
||||
'After a decade of shooting digital, I picked up a film camera. Here is what it taught me about slowing down.',
|
||||
content: `# Film vs digital
|
||||
|
||||
Shooting film changed how I see. Twenty exposures per roll means every frame matters.
|
||||
|
||||
> Less is more — sometimes.`,
|
||||
cover: cover('film', 640, 360),
|
||||
tags: ['photography'],
|
||||
views: 987,
|
||||
comments: 9,
|
||||
likes: 41,
|
||||
date: '2026-07-19',
|
||||
status: 'draft',
|
||||
author: authors.vincent,
|
||||
},
|
||||
{
|
||||
id: 'p9',
|
||||
siteId: 'vincent',
|
||||
slug: 'city-of-neon',
|
||||
title: 'City of neon: a night photography walkthrough',
|
||||
excerpt:
|
||||
'Tips for shooting neon signs at night: exposure, white balance, and finding the light in the rain.',
|
||||
content: `# City of neon
|
||||
|
||||
Night photography is a game of patience and light.
|
||||
|
||||
- Bring a tripod
|
||||
- Meter for highlights
|
||||
- Embrace the rain for reflections`,
|
||||
cover: cover('neon', 640, 360),
|
||||
tags: ['photography', 'city'],
|
||||
views: 1455,
|
||||
comments: 11,
|
||||
likes: 58,
|
||||
date: '2026-07-05',
|
||||
author: authors.vincent,
|
||||
},
|
||||
{
|
||||
id: 'p10',
|
||||
siteId: 'aki',
|
||||
slug: 'open-source-lessons',
|
||||
title: 'Lessons from maintaining an open source project',
|
||||
excerpt:
|
||||
'Maintainer burn-out, issue triage, and why saying "no" is a feature — everything I learned the hard way.',
|
||||
content: `# Maintaining open source
|
||||
|
||||
Maintenance is where most projects fail, not inception.
|
||||
|
||||
1. Triage ruthlessly
|
||||
2. Document everything
|
||||
3. Automate the boring parts
|
||||
|
||||
Saying "no" is a feature, not a failure.`,
|
||||
cover: cover('oss', 640, 360),
|
||||
tags: ['opensource'],
|
||||
views: 1766,
|
||||
comments: 15,
|
||||
likes: 71,
|
||||
date: '2026-06-30',
|
||||
author: authors.aki,
|
||||
},
|
||||
{
|
||||
id: 'p11',
|
||||
siteId: 'aki',
|
||||
slug: 'serverless-edge',
|
||||
title: 'Serverless at the edge: is it worth the hype?',
|
||||
excerpt:
|
||||
'Cold starts, cost surprises, and when edge functions genuinely beat a plain old server.',
|
||||
content: `# Serverless at the edge
|
||||
|
||||
The edge is great — until it isn't.
|
||||
|
||||
Use it for: personalization, geo-routing, caching.
|
||||
Skip it for: long-running tasks, heavy compute.`,
|
||||
cover: cover('edge', 640, 360),
|
||||
tags: ['backend', 'serverless'],
|
||||
views: 1088,
|
||||
comments: 13,
|
||||
likes: 49,
|
||||
date: '2026-06-25',
|
||||
status: 'scheduled',
|
||||
author: authors.aki,
|
||||
},
|
||||
]
|
||||
|
||||
export const allPosts: Post[] = posts
|
||||
export const getPost = (siteId: string, slug: string) =>
|
||||
posts.find((p) => p.siteId === siteId && p.slug === slug)
|
||||
|
||||
export const comments: Comment[] = [
|
||||
{
|
||||
id: 'c1',
|
||||
author: authors.she,
|
||||
content:
|
||||
'Great writeup! The dark mode section was exactly what I needed. Thanks for sharing.',
|
||||
date: '2026-07-29',
|
||||
likes: 3,
|
||||
},
|
||||
{
|
||||
id: 'c2',
|
||||
author: authors.kyoko,
|
||||
content: 'Bookmarked this. CSS variables are so underrated.',
|
||||
date: '2026-07-28',
|
||||
likes: 5,
|
||||
},
|
||||
{
|
||||
id: 'c3',
|
||||
author: authors.vincent,
|
||||
content: 'Could you share the full config file? Would love to reference it.',
|
||||
date: '2026-07-27',
|
||||
likes: 1,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import DashboardLayout from '@/layouts/DashboardLayout.vue'
|
||||
import HomeLayout from '@/layouts/HomeLayout.vue'
|
||||
import SiteLayout from '@/layouts/SiteLayout.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
// 主页布局:Header + Footer
|
||||
path: '/',
|
||||
component: HomeLayout,
|
||||
children: [
|
||||
{ path: '', name: 'home', component: () => import('@/views/HomePage.vue') },
|
||||
{ path: 'shorts', name: 'shorts', component: () => import('@/views/HomePage.vue') },
|
||||
{ path: 'latest', name: 'latest', component: () => import('@/views/HomePage.vue') },
|
||||
{ path: 'hottest', name: 'hottest', component: () => import('@/views/HomePage.vue') },
|
||||
{ path: 'following', name: 'following', component: () => import('@/views/HomePage.vue') },
|
||||
{ path: 'about', name: 'about', component: () => import('@/views/AboutPage.vue') },
|
||||
{ path: 'search', name: 'search', component: () => import('@/views/SearchPage.vue') },
|
||||
],
|
||||
},
|
||||
{
|
||||
// 站点布局:站点头 + 文章内容
|
||||
path: '/',
|
||||
component: SiteLayout,
|
||||
children: [
|
||||
{
|
||||
path: 'post/:site/:slug',
|
||||
name: 'post',
|
||||
component: () => import('@/views/PostDetail.vue'),
|
||||
},
|
||||
{
|
||||
path: 'site/:site',
|
||||
name: 'site',
|
||||
component: () => import('@/views/SiteHome.vue'),
|
||||
},
|
||||
{
|
||||
path: 'site/:site/archives',
|
||||
name: 'site-archives',
|
||||
component: () => import('@/views/SiteArchives.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// 仪表盘布局:侧边栏 + 主内容
|
||||
path: '/dashboard',
|
||||
component: DashboardLayout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'dashboard',
|
||||
component: () => import('@/views/Dashboard.vue'),
|
||||
},
|
||||
{
|
||||
path: 'posts',
|
||||
name: 'dashboard-posts',
|
||||
component: () => import('@/views/PostsManager.vue'),
|
||||
},
|
||||
{
|
||||
path: 'pages',
|
||||
name: 'dashboard-pages',
|
||||
component: () => import('@/views/PagesManager.vue'),
|
||||
},
|
||||
{
|
||||
path: 'editor',
|
||||
name: 'dashboard-editor',
|
||||
component: () => import('@/views/EditorPlaceholder.vue'),
|
||||
},
|
||||
{
|
||||
path: 'settings/:tab?',
|
||||
name: 'dashboard-settings',
|
||||
component: () => import('@/views/SettingsGeneral.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
component: () => import('@/views/NotFound.vue'),
|
||||
},
|
||||
],
|
||||
scrollBehavior() {
|
||||
return { top: 0 }
|
||||
},
|
||||
})
|
||||
|
||||
export { router }
|
||||
@@ -0,0 +1,12 @@
|
||||
@import './variables.css';
|
||||
@import './tailwind.css';
|
||||
@import './prose.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* body 基础 */
|
||||
body {
|
||||
@apply text-zinc-900;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/* 文章排版 */
|
||||
|
||||
@layer components {
|
||||
.prose {
|
||||
@apply text-zinc-600 leading-loose;
|
||||
word-break: break-word;
|
||||
}
|
||||
.prose > p {
|
||||
@apply mb-5;
|
||||
}
|
||||
.prose h2 {
|
||||
@apply text-3xl font-semibold my-7 text-zinc-900;
|
||||
}
|
||||
.prose h3 {
|
||||
@apply text-2xl font-semibold my-7 text-zinc-900;
|
||||
}
|
||||
.prose h4 {
|
||||
@apply text-xl font-semibold my-7 text-zinc-900;
|
||||
}
|
||||
.prose h1 {
|
||||
@apply text-4xl font-bold my-8 text-zinc-900;
|
||||
}
|
||||
.prose blockquote {
|
||||
@apply border-l-4 border-zinc-300 pl-5 my-3 text-zinc-500;
|
||||
}
|
||||
.prose a {
|
||||
@apply text-accent hover:underline;
|
||||
}
|
||||
.prose ul {
|
||||
@apply list-disc pl-5 mb-5;
|
||||
}
|
||||
.prose ol {
|
||||
@apply list-decimal pl-6 mb-5;
|
||||
}
|
||||
.prose li {
|
||||
@apply mb-1;
|
||||
}
|
||||
.prose img {
|
||||
@apply inline-block;
|
||||
}
|
||||
.prose code {
|
||||
@apply bg-zinc-100 rounded px-1 py-0.5 text-sm font-mono;
|
||||
}
|
||||
.prose pre {
|
||||
@apply bg-zinc-900 text-zinc-100 rounded-xl p-4 my-5 overflow-x-auto text-sm;
|
||||
}
|
||||
.prose pre code {
|
||||
@apply bg-transparent p-0 text-inherit;
|
||||
}
|
||||
.prose hr {
|
||||
@apply border-zinc-200 my-8;
|
||||
}
|
||||
.prose table {
|
||||
@apply w-full text-left text-sm border-collapse my-5;
|
||||
}
|
||||
.prose th {
|
||||
@apply border-b border-zinc-200 py-2 pr-4 font-semibold text-zinc-900;
|
||||
}
|
||||
.prose td {
|
||||
@apply border-b border-zinc-200 py-2 pr-4;
|
||||
}
|
||||
|
||||
/* 代码块包裹 */
|
||||
.prose .code-wrapper {
|
||||
@apply relative text-base;
|
||||
}
|
||||
.prose .code-wrapper .copy-button {
|
||||
@apply absolute top-3 right-3 hidden text-sm space-x-1;
|
||||
@apply text-zinc-100 bg-zinc-600/50 rounded-lg h-7 px-3 items-center;
|
||||
}
|
||||
.prose .code-wrapper:hover .copy-button {
|
||||
@apply inline-flex;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/* 通用组件类:按钮 / 输入框 / 隐藏滚动条 */
|
||||
|
||||
@layer components {
|
||||
/* 按钮基础样式 */
|
||||
.button {
|
||||
@apply inline-flex items-center justify-center h-9 px-5 min-w-[100px];
|
||||
@apply whitespace-nowrap font-medium transition active:scale-95;
|
||||
@apply focus-visible:outline focus-visible:outline-accent;
|
||||
}
|
||||
.button.is-primary {
|
||||
@apply text-white bg-accent opacity-90 hover:opacity-100;
|
||||
}
|
||||
.button.is-secondary {
|
||||
@apply bg-gray-50 hover:bg-gray-100 text-gray-500;
|
||||
}
|
||||
.button.is-text {
|
||||
@apply shadow-none py-0 px-3 min-w-0 hover:bg-hover;
|
||||
}
|
||||
.button.is-sm {
|
||||
@apply h-7 px-3 text-sm min-w-[auto];
|
||||
}
|
||||
.button.is-xl {
|
||||
@apply h-10 px-6 text-xl;
|
||||
}
|
||||
.button.is-block {
|
||||
@apply w-full;
|
||||
}
|
||||
.button.is-loading {
|
||||
@apply opacity-50;
|
||||
}
|
||||
.button.is-black {
|
||||
@apply bg-black hover:bg-white focus:bg-white;
|
||||
@apply text-white hover:text-black focus:text-black;
|
||||
@apply border-black border;
|
||||
}
|
||||
.button.is-like {
|
||||
@apply hover:bg-[#f91880]/10 hover:text-[#f91880];
|
||||
}
|
||||
.button.is-collect {
|
||||
@apply hover:bg-[#ffcf55]/10 hover:text-[#ffcf55];
|
||||
}
|
||||
.button.is-tip {
|
||||
@apply hover:bg-[#facc15]/10 hover:text-[#facc15];
|
||||
}
|
||||
.button.is-share {
|
||||
@apply hover:bg-[#0ea5e9]/10 hover:text-[#0ea5e9];
|
||||
}
|
||||
.button.is-comment {
|
||||
@apply hover:bg-green-500/10 hover:text-green-500;
|
||||
}
|
||||
.button.is-outline {
|
||||
@apply border-black text-black border;
|
||||
}
|
||||
.button.is-2xl {
|
||||
@apply h-12 px-6 text-xl;
|
||||
}
|
||||
|
||||
/* 按钮组 */
|
||||
.button-group {
|
||||
@apply flex items-center;
|
||||
}
|
||||
.button-group .button:not(:first-child) {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
.button-group .button:not(:last-child) {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
/* 输入框 */
|
||||
.input {
|
||||
@apply border outline-none rounded-lg px-3 h-10 inline-flex items-center;
|
||||
@apply focus:ring-1 focus:border-accent;
|
||||
--tw-ring-color: var(--theme-color);
|
||||
}
|
||||
.input.is-block {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
/* 隐藏横向滚动条(Tabs 容器) */
|
||||
.scrollbar-hide {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
html {
|
||||
--border-color: #eee;
|
||||
--theme-color: var(--auto-theme-color, #f97316); /* orange-500 */
|
||||
--hover-color: var(--auto-hover-color, #f4f4f5); /* zinc-100 */
|
||||
--header-height: auto;
|
||||
accent-color: var(--theme-color);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
scrollbar-color: var(--theme-color) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--border-color: #333;
|
||||
--theme-color: var(--auto-theme-color, #ea580c); /* orange-600 */
|
||||
}
|
||||
|
||||
/* 暗色模式下颜色映射 */
|
||||
html.dark body {
|
||||
--tw-color-zinc-400: var(--tw-color-zinc-500);
|
||||
--tw-color-zinc-500: var(--tw-color-zinc-600);
|
||||
--tw-color-zinc-600: var(--tw-color-zinc-700);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: var(--theme-color);
|
||||
color: #fff;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
// 关于页
|
||||
import Logo from '@/components/common/Logo.vue'
|
||||
import PromotionLinks from '@/components/home/PromotionLinks.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-screen-lg mx-auto py-8">
|
||||
<div class="text-center py-10 space-y-6">
|
||||
<div class="inline-block size-16 mx-auto">
|
||||
<Logo :size="64" />
|
||||
</div>
|
||||
<h1 class="text-4xl font-extrabold">About xLog</h1>
|
||||
<p class="text-zinc-500 max-w-xl mx-auto leading-loose">
|
||||
xLog is a decentralized blog platform built on Crossbell. It gives every
|
||||
creator a permanent home on the open web — own your content, connect with
|
||||
readers, and tip creators across the network.
|
||||
</p>
|
||||
<div class="max-w-sm mx-auto">
|
||||
<PromotionLinks />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
// 仪表盘主页:统计卡片 + 最近文章
|
||||
import { computed } from 'vue'
|
||||
|
||||
import DashboardMain from '@/components/dashboard/DashboardMain.vue'
|
||||
import { allPosts } from '@/mock/data'
|
||||
|
||||
const stats = computed(() => [
|
||||
{
|
||||
label: 'Posts',
|
||||
value: String(allPosts.length),
|
||||
icon: 'i-mingcute-news-line',
|
||||
},
|
||||
{
|
||||
label: 'Total Views',
|
||||
value: allPosts.reduce((sum, p) => sum + p.views, 0).toLocaleString(),
|
||||
icon: 'i-mingcute-eye-line',
|
||||
},
|
||||
{
|
||||
label: 'Followers',
|
||||
value: '1,284',
|
||||
icon: 'i-mingcute-user-follow-line',
|
||||
},
|
||||
{
|
||||
label: 'CSB Balance',
|
||||
value: '1,024',
|
||||
icon: 'i-mingcute-pig-money-line',
|
||||
},
|
||||
])
|
||||
|
||||
const recent = computed(() =>
|
||||
[...allPosts]
|
||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||
.slice(0, 5),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardMain title="Dashboard">
|
||||
<!-- 统计卡片 -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div
|
||||
v-for="stat in stats"
|
||||
:key="stat.label"
|
||||
class="border rounded-xl p-4 flex items-center space-x-3"
|
||||
>
|
||||
<span class="size-10 rounded-lg bg-accent/10 text-accent flex items-center justify-center shrink-0">
|
||||
<i :class="stat.icon" class="text-xl" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-2xl font-bold">{{ stat.value }}</div>
|
||||
<div class="text-zinc-500 text-sm truncate">{{ stat.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近文章 -->
|
||||
<div class="mt-8">
|
||||
<h3 class="font-bold text-lg mb-3">Recent Posts</h3>
|
||||
<div class="border rounded-xl divide-y">
|
||||
<RouterLink
|
||||
v-for="post in recent"
|
||||
:key="post.id"
|
||||
:to="`/post/${post.siteId}/${post.slug}`"
|
||||
class="flex items-center px-4 py-3 hover:bg-zinc-50 transition-colors"
|
||||
>
|
||||
<img
|
||||
:src="post.cover"
|
||||
:alt="post.title"
|
||||
class="w-12 h-8 rounded object-cover mr-3 shrink-0"
|
||||
/>
|
||||
<span class="flex-1 min-w-0 truncate font-medium">{{ post.title }}</span>
|
||||
<span class="text-zinc-400 text-sm ml-3 shrink-0">{{ post.views }} views</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardMain>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
// 简易 Markdown 编辑器(占位实现,仅用于文章管理页跳转)
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import DashboardMain from '@/components/dashboard/DashboardMain.vue'
|
||||
import { allPosts } from '@/mock/data'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const post = computed(() => {
|
||||
const id = route.query.id as string | undefined
|
||||
return allPosts.find((p) => p.id === id) ?? allPosts[0]
|
||||
})
|
||||
|
||||
const title = ref('')
|
||||
const content = ref('')
|
||||
watch(
|
||||
post,
|
||||
(p) => {
|
||||
if (p) {
|
||||
title.value = p.title
|
||||
content.value = p.content
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardMain title="Editor">
|
||||
<div class="space-y-4 max-w-screen-lg">
|
||||
<input v-model="title" class="input is-block text-lg font-bold" placeholder="Title" />
|
||||
<textarea
|
||||
v-model="content"
|
||||
class="input is-block min-h-[400px] py-3 font-mono text-sm resize-y"
|
||||
placeholder="Write in Markdown..."
|
||||
/>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button class="button is-secondary">Save Draft</button>
|
||||
<button class="button is-primary">Publish</button>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardMain>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
// 主页:活动标签 + 文章 Grid + 右侧边栏
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import HomeActivitiesTabs from '@/components/home/HomeActivitiesTabs.vue'
|
||||
import HomeFeed from '@/components/home/HomeFeed.vue'
|
||||
import HomeSidebar from '@/components/home/HomeSidebar.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const feedType = computed(() => {
|
||||
const path = route.path.replace(/^\//, '')
|
||||
if (['shorts', 'latest', 'hottest', 'following'].includes(path)) {
|
||||
return path as 'shorts' | 'latest' | 'hottest' | 'following'
|
||||
}
|
||||
return 'featured'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 主栏 -->
|
||||
<div class="flex-1 min-w-[300px]">
|
||||
<HomeActivitiesTabs />
|
||||
<HomeFeed :type="feedType" />
|
||||
</div>
|
||||
|
||||
<!-- 右侧边栏(桌面端显示) -->
|
||||
<HomeSidebar />
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
// 404 页
|
||||
import Logo from '@/components/common/Logo.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-white flex flex-col items-center justify-center py-20 space-y-6">
|
||||
<div class="inline-block size-16">
|
||||
<Logo :size="64" />
|
||||
</div>
|
||||
<h1 class="text-6xl font-extrabold">404</h1>
|
||||
<p class="text-zinc-500">This page could not be found.</p>
|
||||
<RouterLink to="/" class="button is-primary">Back to Home</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
// 页面管理页
|
||||
import PagesManager from '@/components/dashboard/PagesManager.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagesManager title="Pages" item-label="Page" />
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
// 文章详情页:标题 + 元信息 + AI 摘要 + Markdown 正文 + 互动区 + 评论
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import MarkdownContent from '@/components/common/MarkdownContent.vue'
|
||||
import PostFooter from '@/components/site/PostFooter.vue'
|
||||
import PostMeta from '@/components/site/PostMeta.vue'
|
||||
import PostTitle from '@/components/site/PostTitle.vue'
|
||||
import { getPost } from '@/mock/data'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const post = computed(() =>
|
||||
getPost(route.params.site as string, route.params.slug as string),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-screen-md mx-auto">
|
||||
<article v-if="post">
|
||||
<!-- 文章标题 -->
|
||||
<PostTitle :post="post" />
|
||||
|
||||
<!-- 文章元信息 -->
|
||||
<PostMeta :post="post" />
|
||||
|
||||
<!-- AI 摘要 -->
|
||||
<div class="xlog-post-summary border rounded-xl mt-5 p-4 space-y-2">
|
||||
<div class="font-bold text-zinc-700 flex items-center">
|
||||
<i class="i-mingcute-sparkles-line mr-2 text-lg" />
|
||||
AI-generated summary
|
||||
</div>
|
||||
<div class="text-zinc-500 leading-loose text-sm">{{ post.excerpt }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Markdown 正文 -->
|
||||
<MarkdownContent :content="post.content" class="mt-10" />
|
||||
|
||||
<!-- 互动区 + 评论区 -->
|
||||
<PostFooter :post="post" />
|
||||
</article>
|
||||
<EmptyState v-else icon="i-mingcute-file-line" text="Post not found" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
// 文章管理页
|
||||
import PagesManager from '@/components/dashboard/PagesManager.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagesManager title="Posts" item-label="Post" />
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
// 搜索页:基于 URL query 过滤 mock 文章
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import PostCard from '@/components/common/PostCard.vue'
|
||||
import { allPosts } from '@/mock/data'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const keyword = computed(() => String(route.query.q ?? '').trim().toLowerCase())
|
||||
|
||||
const results = computed(() => {
|
||||
if (!keyword.value) return []
|
||||
return allPosts.filter((post) =>
|
||||
[post.title, post.excerpt, ...post.tags, post.author.name]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(keyword.value),
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 min-w-[300px]">
|
||||
<div class="flex mb-8 border-b">
|
||||
<h2 class="inline-flex items-center h-10 whitespace-nowrap cursor-pointer text-accent font-medium border-b-2 border-accent">
|
||||
Results for "{{ keyword }}"
|
||||
</h2>
|
||||
</div>
|
||||
<div v-if="results.length" class="grid gap-3 sm:gap-6 grid-cols-1 sm:grid-cols-3 my-8">
|
||||
<PostCard v-for="post in results" :key="post.id" :post="post" />
|
||||
</div>
|
||||
<EmptyState v-else :text="keyword ? 'No results found' : 'Type something to search'" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
// 设置页:General / Social / Navigation / Domains / Custom CSS
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Tabs from '@/components/ui/Tabs.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const tab = computed(() => (route.params.tab as string) || 'general')
|
||||
|
||||
const tabs = [
|
||||
{ key: 'general', label: 'General', to: '/dashboard/settings/general' },
|
||||
{ key: 'social', label: 'Social Platforms', to: '/dashboard/settings/social' },
|
||||
{ key: 'navigation', label: 'Navigation', to: '/dashboard/settings/navigation' },
|
||||
{ key: 'domains', label: 'Domains', to: '/dashboard/settings/domains' },
|
||||
{ key: 'css', label: 'Custom CSS', to: '/dashboard/settings/css' },
|
||||
]
|
||||
|
||||
const form = ref({ name: 'MyBlog', description: 'A blog about code and life.' })
|
||||
const saved = ref(false)
|
||||
|
||||
watch(form, () => (saved.value = false), { deep: true })
|
||||
|
||||
function save() {
|
||||
saved.value = true
|
||||
window.setTimeout(() => (saved.value = false), 2000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-w-[270px] relative p-5 md:px-10 md:py-8 min-h-full flex flex-col">
|
||||
<header class="mb-8">
|
||||
<h2 class="text-2xl font-bold">Settings</h2>
|
||||
</header>
|
||||
<div>
|
||||
<!-- 设置子标签 -->
|
||||
<Tabs :items="tabs" :active-key="tab" />
|
||||
|
||||
<!-- 设置内容 -->
|
||||
<div class="max-w-screen-md">
|
||||
<!-- General -->
|
||||
<div v-if="tab === 'general'" class="space-y-6">
|
||||
<div>
|
||||
<label class="block mb-2 font-bold text-gray-700">Site Name</label>
|
||||
<input v-model="form.name" class="input is-block" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-2 font-bold text-gray-700">Description</label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
class="input is-block min-h-[74px] py-3 resize-y"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-2 font-bold text-gray-700">Avatar</label>
|
||||
<button class="button is-secondary">
|
||||
<i class="i-mingcute-camera-line mr-2" />
|
||||
Upload Avatar
|
||||
</button>
|
||||
</div>
|
||||
<button class="button is-primary" @click="save">
|
||||
{{ saved ? 'Saved ✓' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 其他设置(占位) -->
|
||||
<EmptyState
|
||||
v-else
|
||||
:icon="'i-mingcute-settings-3-line'"
|
||||
:text="`${tab} settings are coming soon`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
// 站点归档页:按月份分组的文章列表
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Time from '@/components/common/Time.vue'
|
||||
import { allPosts } from '@/mock/data'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const grouped = computed(() => {
|
||||
const posts = allPosts
|
||||
.filter((p) => p.siteId === route.params.site)
|
||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||
|
||||
const map = new Map<string, typeof posts>()
|
||||
for (const post of posts) {
|
||||
const key = post.date.slice(0, 7) // YYYY-MM
|
||||
map.set(key, [...(map.get(key) ?? []), post])
|
||||
}
|
||||
return [...map.entries()]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-screen-md mx-auto">
|
||||
<div v-if="grouped.length">
|
||||
<div v-for="[month, posts] in grouped" :key="month" class="mb-8">
|
||||
<h3 class="text-xl font-bold text-zinc-900 mb-3">{{ month }}</h3>
|
||||
<ul class="border-t divide-y">
|
||||
<li v-for="post in posts" :key="post.id">
|
||||
<RouterLink
|
||||
:to="`/post/${post.siteId}/${post.slug}`"
|
||||
class="flex items-center justify-between py-3 hover:text-accent transition-colors"
|
||||
>
|
||||
<span class="truncate">{{ post.title }}</span>
|
||||
<span class="text-zinc-400 text-sm ml-3 shrink-0">
|
||||
<Time :date="post.date" />
|
||||
</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState v-else text="No posts yet" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
// 站点主页:该创作者的公开文章列表
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import PostCard from '@/components/common/PostCard.vue'
|
||||
import { allPosts, sites } from '@/mock/data'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const site = computed(
|
||||
() => sites.find((s) => s.handle === (route.params.site as string)) ?? null,
|
||||
)
|
||||
|
||||
const posts = computed(() => allPosts.filter((p) => p.siteId === route.params.site))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="site">
|
||||
<div
|
||||
v-if="posts.length"
|
||||
class="grid gap-3 sm:gap-6 grid-cols-1 sm:grid-cols-2 my-8"
|
||||
>
|
||||
<PostCard v-for="post in posts" :key="post.id" :post="post" />
|
||||
</div>
|
||||
<EmptyState v-else text="No posts yet" />
|
||||
</div>
|
||||
<EmptyState v-else icon="i-mingcute-file-line" text="Site not found" />
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
import { getIconCollections, iconsPlugin } from '@egoist/tailwindcss-icons'
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: ['class', 'html.dark'],
|
||||
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'var(--border-color)',
|
||||
accent: 'var(--theme-color)',
|
||||
hover: 'var(--hover-color)',
|
||||
},
|
||||
spacing: {
|
||||
sidebar: '240px',
|
||||
},
|
||||
boxShadow: {
|
||||
modal: 'rgb(0 0 0 / 20%) 0px 0px 1px, rgb(0 0 0 / 20%) 0px 20px 40px',
|
||||
'card-hover':
|
||||
'rgb(0 0 0 / 5%) 0px 0px 1px, rgb(0 0 0 / 12%) 0px 15px 30px',
|
||||
card: 'rgb(0 0 0 / 5%) 0px 0px 1px, rgb(0 0 0 / 4%) 0px 15px 30px',
|
||||
},
|
||||
fontFamily: {
|
||||
mono: 'Roboto Mono, Monaco, monospace',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
iconsPlugin({
|
||||
collections: getIconCollections(['mingcute']),
|
||||
}),
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user