feat: add landing page and docs website (#537)

* feat: add landing page and docs website package

Add packages/website — a Vue 3 + Naive UI static site with landing page
and documentation, sharing the Pure Ink monochrome design with the main
app. Features: particle network hero animation, screenshot carousel,
feature grid, install guide tabs, GitHub star history, scroll reveal
animations, and Chinese/English bilingual support.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: add favicon to website package

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: use dynamic theme param for star history chart

Switch from CSS media query to JS-based dark mode detection so the
star-history SVG matches the current theme toggle state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: resolve TypeScript strict mode errors in website components

- Remove unused isDark import in HeroSection
- Add null check for canvas parent element
- Rename unused img loop variable in ScreenshotsSection
- Remove unused NIcon import in SiteHeader

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: resolve TS narrowing errors in canvas resize closure

Use canvasRef.value directly inside resize() with local null check
instead of relying on outer closure narrowing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-05-08 13:36:11 +08:00
committed by GitHub
parent b0e03ae838
commit 9edb76ac64
34 changed files with 2672 additions and 1 deletions
+3
View File
@@ -55,6 +55,9 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"dev:website": "vite --config vite.config.website.ts",
"build:website": "vite build --config vite.config.website.ts",
"preview:website": "vite preview --config vite.config.website.ts",
"openapi:generate": "node scripts/generate-openapi.mjs" "openapi:generate": "node scripts/generate-openapi.mjs"
}, },
"files": [ "files": [
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hermes Web UI - Self-Hosted AI Chat Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { darkTheme } from 'naive-ui'
import { NConfigProvider, NMessageProvider } from 'naive-ui'
import { useTheme } from '@/composables/useTheme'
import { getThemeOverrides } from '@client/styles/theme'
import SiteHeader from '@/components/layout/SiteHeader.vue'
import SiteFooter from '@/components/layout/SiteFooter.vue'
const { isDark } = useTheme()
</script>
<template>
<NConfigProvider :theme="isDark ? darkTheme : undefined" :theme-overrides="getThemeOverrides(isDark)">
<NMessageProvider>
<div class="website-app">
<SiteHeader />
<main class="website-main">
<router-view />
</main>
<SiteFooter />
</div>
</NMessageProvider>
</NConfigProvider>
</template>
<style scoped lang="scss">
.website-app {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg-primary);
color: var(--text-primary);
}
.website-main {
flex: 1;
}
</style>
@@ -0,0 +1,136 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
const { t, tm, rt } = useI18n()
const route = useRoute()
const pageKey = computed(() => (route.meta.page as string) || 'gettingStarted')
const pageTitle = computed(() => t(`docs.${pageKey.value}.title`))
const pageIntro = computed(() => t(`docs.${pageKey.value}.intro`))
interface DocSection {
title: string
content: string
rows?: string[][]
}
const sections = computed<DocSection[]>(() => {
const key = pageKey.value
const meta = tm(`docs.${key}`) as Record<string, any>
if (!meta) return []
const result: DocSection[] = []
const sectionKeys = Object.keys(meta).filter(
(k) => k !== 'title' && k !== 'intro' && typeof meta[k] === 'object' && meta[k] !== null,
)
for (const sk of sectionKeys) {
const section = meta[sk] as Record<string, any>
result.push({
title: rt(section.title || ''),
content: rt(section.content || ''),
rows: Array.isArray(section.rows) ? section.rows : undefined,
})
}
return result
})
</script>
<template>
<div class="doc-content">
<h1 class="doc-title">{{ pageTitle }}</h1>
<p v-if="pageIntro" class="doc-intro">{{ pageIntro }}</p>
<div v-for="(section, i) in sections" :key="i" class="doc-section">
<h2 class="doc-section-title">{{ section.title }}</h2>
<p v-if="section.content" class="doc-section-text">{{ section.content }}</p>
<table v-if="section.rows?.length" class="doc-table">
<tbody>
<tr v-for="(row, ri) in section.rows" :key="ri">
<td class="doc-table-key"><code>{{ row[0] }}</code></td>
<td>{{ row[1] }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style scoped lang="scss">
.doc-content {
max-width: 720px;
width: 100%;
padding: 40px 32px 80px;
@media (max-width: $breakpoint-mobile) {
padding: 24px 16px 60px;
}
}
.doc-title {
font-size: 32px;
font-weight: 700;
margin-bottom: 16px;
color: var(--text-primary);
}
.doc-intro {
font-size: 16px;
line-height: 1.7;
color: var(--text-secondary);
margin-bottom: 40px;
}
.doc-section {
margin-bottom: 36px;
}
.doc-section-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 12px;
color: var(--text-primary);
padding-bottom: 8px;
border-bottom: 1px solid var(--border-light);
}
.doc-section-text {
font-size: 15px;
line-height: 1.7;
color: var(--text-secondary);
}
.doc-table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
tr {
border-bottom: 1px solid var(--border-light);
}
td {
padding: 10px 12px;
font-size: 14px;
color: var(--text-secondary);
vertical-align: top;
}
}
.doc-table-key {
white-space: nowrap;
width: 1%;
font-weight: 500;
code {
background: var(--bg-secondary);
padding: 2px 8px;
border-radius: 4px;
font-size: 13px;
}
}
</style>
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const pages = [
{ key: 'gettingStarted', name: 'docs.getting-started' },
{ key: 'configuration', name: 'docs.configuration' },
{ key: 'features', name: 'docs.features' },
{ key: 'platforms', name: 'docs.platforms' },
{ key: 'api', name: 'docs.api' },
]
function isActive(name: string) {
return route.name === name
}
function navigate(name: string) {
router.push({ name })
}
</script>
<template>
<aside class="doc-sidebar">
<nav class="sidebar-nav">
<a
v-for="p in pages"
:key="p.key"
class="sidebar-link"
:class="{ active: isActive(p.name) }"
@click.prevent="navigate(p.name)"
>
{{ t(`docs.sidebar.${p.key}`) }}
</a>
</nav>
</aside>
</template>
<style scoped lang="scss">
.doc-sidebar {
width: 240px;
flex-shrink: 0;
border-right: 1px solid var(--border-color);
padding: 24px 0;
position: sticky;
top: 60px;
height: calc(100vh - 60px);
overflow-y: auto;
@media (max-width: $breakpoint-mobile) {
display: none;
}
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 2px;
padding: 0 12px;
}
.sidebar-link {
display: block;
padding: 8px 12px;
border-radius: $radius-sm;
color: var(--text-secondary);
font-size: 14px;
cursor: pointer;
transition: all $transition-fast;
&:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
&.active {
color: var(--text-primary);
background: var(--bg-secondary);
font-weight: 500;
}
}
</style>
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useScrollReveal } from '@/composables/useScrollReveal'
const { t } = useI18n()
useScrollReveal()
const features = [
{ key: 'streaming', icon: 'M13 2L3 14h9l-1 8 10-12h-9l1-8z' },
{ key: 'platforms', icon: 'M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z' },
{ key: 'multiModel', icon: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm0 14l-4-4h8l-4 4z' },
{ key: 'groupChat', icon: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zm14 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75' },
{ key: 'kanban', icon: 'M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z' },
{ key: 'analytics', icon: 'M18 20V10M12 20V4M6 20v-6' },
{ key: 'profiles', icon: 'M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2M9 2h6a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z' },
{ key: 'files', icon: 'M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z' },
{ key: 'terminal', icon: 'M4 17l6-6-6-6M12 19h8' },
{ key: 'quickInstall', icon: 'M13 2L3 14h9l-1 8 10-12h-9l1-8z' },
{ key: 'i18n', icon: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm-1 17.93a8 8 0 0 1 0-15.86M12 2a15 15 0 0 1 4 10 15 15 0 0 1-4 10M2 12h20' },
{ key: 'theme', icon: 'M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z' },
]
</script>
<template>
<section class="section">
<h2 class="section-title reveal">{{ t('features.title') }}</h2>
<p class="section-desc reveal">{{ t('features.desc') }}</p>
<div class="features-grid">
<div
v-for="(f, i) in features"
:key="f.key"
class="feature-card reveal"
:class="[`reveal-delay-${(i % 4) + 1}`]"
>
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path :d="f.icon" />
</svg>
</div>
<h3 class="feature-title">{{ t(`features.${f.key}.title`) }}</h3>
<p class="feature-desc">{{ t(`features.${f.key}.desc`) }}</p>
</div>
</div>
</section>
</template>
<style scoped lang="scss">
.features-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
@media (max-width: 900px) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: $breakpoint-mobile) {
grid-template-columns: 1fr;
}
}
.feature-card {
padding: 28px 24px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: $radius-md;
transition: all $transition-normal;
&:hover {
border-color: var(--text-muted);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
transform: translateY(-2px);
}
}
.feature-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border-radius: $radius-sm;
margin-bottom: 16px;
color: var(--accent-primary);
transition: background $transition-normal;
.feature-card:hover & {
background: var(--border-color);
}
svg {
width: 20px;
height: 20px;
}
}
.feature-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 8px;
color: var(--text-primary);
}
.feature-desc {
font-size: 14px;
line-height: 1.6;
color: var(--text-secondary);
}
</style>
@@ -0,0 +1,293 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { ref, onMounted, onUnmounted } from 'vue'
const { t } = useI18n()
const router = useRouter()
const copied = ref(false)
const canvasRef = ref<HTMLCanvasElement>()
const installCmd = 'npm install -g hermes-web-ui'
async function copyCmd() {
try {
await navigator.clipboard.writeText(installCmd)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} catch {}
}
// ─── Particle network animation ──────────────────────────
interface Particle {
x: number
y: number
vx: number
vy: number
r: number
}
let animId = 0
let particles: Particle[] = []
function initCanvas() {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')!
const dpr = window.devicePixelRatio || 1
function resize() {
const el = canvasRef.value
if (!el || !el.parentElement) return
const rect = el.parentElement.getBoundingClientRect()
el.width = rect.width * dpr
el.height = rect.height * dpr
el.style.width = rect.width + 'px'
el.style.height = rect.height + 'px'
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
}
resize()
const count = Math.min(60, Math.floor((canvas.width / dpr) / 18))
const w = canvas.width / dpr
const h = canvas.height / dpr
particles = Array.from({ length: count }, () => ({
x: Math.random() * w,
y: Math.random() * h,
vx: (Math.random() - 0.5) * 0.4,
vy: (Math.random() - 0.5) * 0.4,
r: Math.random() * 1.5 + 0.5,
}))
const maxDist = 120
function draw() {
const dark = document.documentElement.classList.contains('dark')
const dotColor = dark ? 'rgba(224,224,224,' : 'rgba(51,51,51,'
const lineColor = dark ? 'rgba(224,224,224,' : 'rgba(51,51,51,'
ctx.clearRect(0, 0, w, h)
// Update & draw particles
for (const p of particles) {
p.x += p.vx
p.y += p.vy
if (p.x < 0 || p.x > w) p.vx *= -1
if (p.y < 0 || p.y > h) p.vy *= -1
ctx.beginPath()
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2)
ctx.fillStyle = dotColor + '0.6)'
ctx.fill()
}
// Draw connections
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
const dx = particles[i].x - particles[j].x
const dy = particles[i].y - particles[j].y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < maxDist) {
const alpha = (1 - dist / maxDist) * 0.15
ctx.beginPath()
ctx.moveTo(particles[i].x, particles[i].y)
ctx.lineTo(particles[j].x, particles[j].y)
ctx.strokeStyle = lineColor + alpha + ')'
ctx.lineWidth = 0.5
ctx.stroke()
}
}
}
animId = requestAnimationFrame(draw)
}
draw()
const onResize = () => {
cancelAnimationFrame(animId)
initCanvas()
}
window.addEventListener('resize', onResize)
onUnmounted(() => {
cancelAnimationFrame(animId)
window.removeEventListener('resize', onResize)
})
}
onMounted(() => {
initCanvas()
})
</script>
<template>
<section class="hero">
<canvas ref="canvasRef" class="hero-canvas" />
<div class="hero-inner">
<h1 class="hero-title animate-fade-in-up">{{ t('hero.title') }}</h1>
<p class="hero-subtitle animate-fade-in-up animate-delay-1">{{ t('hero.subtitle') }}</p>
<div class="hero-actions animate-fade-in-up animate-delay-2">
<button class="btn-primary" @click="router.push({ name: 'docs.getting-started' })">
{{ t('hero.cta') }}
</button>
<a
class="btn-outline"
href="https://github.com/EKKOLearnAI/hermes-web-ui"
target="_blank"
rel="noopener"
>
<svg viewBox="0 0 24 24" fill="currentColor" class="btn-icon">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
{{ t('hero.viewGithub') }}
</a>
</div>
<div class="install-box animate-fade-in animate-delay-3">
<code>{{ installCmd }}</code>
<button class="copy-btn" @click="copyCmd">
{{ copied ? 'Copied!' : 'Copy' }}
</button>
</div>
</div>
</section>
</template>
<style scoped lang="scss">
.hero {
position: relative;
overflow: hidden;
padding: 120px 24px 80px;
text-align: center;
background: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
@media (max-width: $breakpoint-mobile) {
padding: 80px 16px 48px;
}
}
.hero-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.hero-inner {
position: relative;
z-index: 1;
max-width: 720px;
margin: 0 auto;
}
.hero-title {
font-size: 48px;
font-weight: 700;
line-height: 1.2;
margin-bottom: 20px;
color: var(--text-primary);
@media (max-width: $breakpoint-mobile) {
font-size: 32px;
}
}
.hero-subtitle {
font-size: 18px;
line-height: 1.6;
color: var(--text-secondary);
margin-bottom: 36px;
@media (max-width: $breakpoint-mobile) {
font-size: 15px;
}
}
.hero-actions {
display: flex;
justify-content: center;
gap: 12px;
margin-bottom: 36px;
flex-wrap: wrap;
}
.btn-primary {
padding: 12px 28px;
background: var(--accent-primary);
color: var(--text-on-accent);
border: none;
border-radius: $radius-md;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background $transition-fast, transform $transition-fast;
&:hover {
background: var(--accent-hover);
transform: translateY(-1px);
}
}
.btn-outline {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 28px;
background: transparent;
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: $radius-md;
font-size: 15px;
font-weight: 500;
text-decoration: none;
transition: all $transition-fast;
&:hover {
border-color: var(--text-muted);
transform: translateY(-1px);
}
}
.btn-icon {
width: 18px;
height: 18px;
}
.install-box {
display: inline-flex;
align-items: center;
gap: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-md;
padding: 12px 20px;
code {
font-size: 14px;
background: transparent;
padding: 0;
}
}
.copy-btn {
padding: 4px 12px;
border: 1px solid var(--border-color);
border-radius: $radius-sm;
background: transparent;
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all $transition-fast;
&:hover {
color: var(--text-primary);
border-color: var(--text-muted);
}
}
</style>
@@ -0,0 +1,138 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { ref } from 'vue'
import { useScrollReveal } from '@/composables/useScrollReveal'
const { t } = useI18n()
useScrollReveal()
const activeTab = ref<'npm' | 'docker' | 'source'>('npm')
function copyText(text: string) {
navigator.clipboard.writeText(text).catch(() => {})
}
</script>
<template>
<div class="install-panel">
<h2 class="panel-title reveal">{{ t('install.title') }}</h2>
<p class="panel-desc reveal">{{ t('install.desc') }}</p>
<div class="install-tabs reveal">
<button
v-for="tab in (['npm', 'docker', 'source'] as const)"
:key="tab"
class="tab-btn"
:class="{ active: activeTab === tab }"
@click="activeTab = tab"
>
{{ t(`install.${tab}.title`) }}
</button>
</div>
<div class="install-content reveal reveal-delay-1">
<template v-if="activeTab === 'npm'">
<div class="code-block" @click="copyText(t('install.npm.cmd1'))">
<code>{{ t('install.npm.cmd1') }}</code>
</div>
<div class="code-block" @click="copyText(t('install.npm.cmd2'))">
<code>{{ t('install.npm.cmd2') }}</code>
</div>
</template>
<template v-else-if="activeTab === 'docker'">
<div class="code-block" @click="copyText(t('install.docker.cmd'))">
<code>{{ t('install.docker.cmd') }}</code>
</div>
</template>
<template v-else>
<div class="code-block" @click="copyText(t('install.source.cmd1'))">
<code>{{ t('install.source.cmd1') }}</code>
</div>
<div class="code-block" @click="copyText(t('install.source.cmd2'))">
<code>{{ t('install.source.cmd2') }}</code>
</div>
</template>
<p class="prereq">{{ t('install.prereq') }}</p>
</div>
</div>
</template>
<style scoped lang="scss">
.install-panel {
padding: 40px 32px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
}
.panel-title {
font-size: 24px;
font-weight: 700;
margin-bottom: 8px;
color: var(--text-primary);
}
.panel-desc {
color: var(--text-secondary);
font-size: 15px;
margin-bottom: 24px;
}
.install-tabs {
display: flex;
gap: 4px;
margin-bottom: 20px;
background: var(--bg-secondary);
border-radius: $radius-md;
padding: 4px;
}
.tab-btn {
flex: 1;
padding: 8px 16px;
border: none;
border-radius: $radius-sm;
background: transparent;
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all $transition-fast;
white-space: nowrap;
&.active {
background: var(--bg-card);
color: var(--text-primary);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
}
.install-content {
// full width within panel
}
.code-block {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-sm;
padding: 14px 18px;
margin-bottom: 8px;
cursor: pointer;
transition: border-color $transition-fast;
&:hover {
border-color: var(--text-muted);
}
code {
font-size: 14px;
background: transparent;
padding: 0;
}
}
.prereq {
color: var(--text-muted);
font-size: 13px;
margin-top: 16px;
}
</style>
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const platforms = [
{ key: 'telegram', icon: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.64 6.8c-.15 1.58-.8 5.42-1.13 7.19-.14.75-.42 1-.68 1.03-.58.05-1.02-.38-1.58-.75-.88-.58-1.38-.94-2.23-1.5-.99-.65-.35-1.01.22-1.59.15-.15 2.71-2.48 2.76-2.69.01-.03.01-.14-.07-.2-.08-.06-.19-.04-.27-.02-.12.02-1.96 1.25-5.54 3.67-.52.36-1 .53-1.42.52-.47-.01-1.37-.26-2.03-.48-.82-.27-1.47-.42-1.42-.88.03-.24.37-.49 1.02-.75 3.99-1.74 6.65-2.89 7.99-3.44 3.81-1.58 4.6-1.86 5.12-1.87.11 0 .37.03.54.17.14.12.18.28.2.45-.01.06.01.24 0 .38z' },
{ key: 'discord', icon: 'M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z' },
{ key: 'slack', icon: 'M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z' },
{ key: 'whatsapp', icon: 'M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347z' },
{ key: 'matrix', icon: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm0 3a7 7 0 0 1 7 7h-2a5 5 0 0 0-5-5V5z' },
{ key: 'feishu', icon: 'M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5' },
{ key: 'wechat', icon: 'M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18z' },
{ key: 'wecom', icon: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z' },
]
</script>
<template>
<section class="platforms-section">
<div class="section-inner">
<h2 class="section-title">{{ t('platforms.title') }}</h2>
<p class="section-desc">{{ t('platforms.desc') }}</p>
<div class="platforms-grid">
<div v-for="p in platforms" :key="p.key" class="platform-item">
<div class="platform-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path :d="p.icon" />
</svg>
</div>
<span class="platform-name">{{ t(`platforms.${p.key}`) }}</span>
</div>
</div>
</div>
</section>
</template>
<style scoped lang="scss">
.platforms-section {
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
}
.section-inner {
max-width: 1120px;
margin: 0 auto;
padding: 80px 24px;
@media (max-width: $breakpoint-mobile) {
padding: 48px 16px;
}
}
.platforms-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 24px;
}
.platform-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
width: 120px;
}
.platform-icon {
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: $radius-md;
color: var(--text-primary);
transition: all $transition-fast;
&:hover {
border-color: var(--text-muted);
}
svg {
width: 24px;
height: 24px;
}
}
.platform-name {
font-size: 13px;
color: var(--text-secondary);
text-align: center;
}
</style>
@@ -0,0 +1,252 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useScrollReveal } from '@/composables/useScrollReveal'
useScrollReveal()
const images = [
{ src: '/image1.png', alt: 'AI Chat with Image Generation' },
{ src: '/image2.png', alt: 'Chat and File Browser' },
{ src: '/image3.png', alt: 'Multi-panel Workspace' },
{ src: '/image4.png', alt: 'Kanban Board' },
]
const activeIndex = ref(0)
let timer: ReturnType<typeof setInterval>
function next() {
activeIndex.value = (activeIndex.value + 1) % images.length
}
function prev() {
activeIndex.value = (activeIndex.value - 1 + images.length) % images.length
}
function setActive(i: number) {
activeIndex.value = i
resetTimer()
}
function resetTimer() {
clearInterval(timer)
timer = setInterval(next, 5000)
}
onMounted(() => {
timer = setInterval(next, 5000)
})
onUnmounted(() => {
clearInterval(timer)
})
</script>
<template>
<section class="screenshots-section">
<div class="screenshots-inner reveal">
<!-- Browser frame mockup -->
<div class="browser-frame">
<div class="browser-bar">
<div class="browser-dots">
<span class="dot red" />
<span class="dot yellow" />
<span class="dot green" />
</div>
<div class="browser-url">
<span>http://localhost:8648</span>
</div>
<div class="browser-spacer" />
</div>
<div class="browser-viewport">
<transition name="slide" mode="out-in">
<img
:key="activeIndex"
:src="images[activeIndex].src"
:alt="images[activeIndex].alt"
class="screenshot-img"
/>
</transition>
</div>
</div>
<!-- Navigation -->
<div class="screenshot-nav">
<button class="nav-arrow" @click="prev(); resetTimer()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6" /></svg>
</button>
<div class="screenshot-dots">
<button
v-for="(_img, i) in images"
:key="i"
class="dot-btn"
:class="{ active: activeIndex === i }"
@click="setActive(i)"
/>
</div>
<button class="nav-arrow" @click="next(); resetTimer()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6" /></svg>
</button>
</div>
</div>
</section>
</template>
<style scoped lang="scss">
.screenshots-section {
padding: 0 24px;
margin-top: 48px;
margin-bottom: 24px;
}
.screenshots-inner {
max-width: 920px;
margin: 0 auto;
}
// ─── Browser Frame ──────────────────────────
.browser-frame {
border-radius: $radius-lg;
border: 1px solid var(--border-color);
overflow: hidden;
background: var(--bg-secondary);
box-shadow:
0 4px 16px rgba(0, 0, 0, 0.06),
0 20px 60px rgba(0, 0, 0, 0.08);
transition: transform 0.4s ease, box-shadow 0.4s ease;
&:hover {
transform: translateY(-4px);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.08),
0 32px 80px rgba(0, 0, 0, 0.12);
}
}
.browser-bar {
display: flex;
align-items: center;
padding: 10px 14px;
gap: 12px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-card);
}
.browser-dots {
display: flex;
gap: 6px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
&.red { background: #ff5f57; }
&.yellow { background: #febc2e; }
&.green { background: #28c840; }
}
.browser-url {
flex: 1;
background: var(--bg-secondary);
border-radius: 4px;
padding: 4px 12px;
font-size: 12px;
color: var(--text-muted);
font-family: $font-code;
text-align: center;
}
.browser-spacer {
width: 52px;
}
.browser-viewport {
position: relative;
width: 100%;
background: var(--bg-secondary);
}
.screenshot-img {
width: 100%;
display: block;
}
// ─── Slide Transition ───────────────────────
.slide-enter-active,
.slide-leave-active {
transition: all 0.4s ease;
}
.slide-enter-from {
opacity: 0;
transform: translateX(24px);
}
.slide-leave-to {
opacity: 0;
transform: translateX(-24px);
}
// ─── Navigation ─────────────────────────────
.screenshot-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 16px;
}
.nav-arrow {
width: 36px;
height: 36px;
border-radius: 50%;
border: 1px solid var(--border-color);
background: var(--bg-card);
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all $transition-fast;
&:hover {
color: var(--text-primary);
border-color: var(--text-muted);
}
svg {
width: 16px;
height: 16px;
}
}
.screenshot-dots {
display: flex;
gap: 8px;
}
.dot-btn {
width: 8px;
height: 8px;
border-radius: 50%;
border: none;
background: var(--border-color);
cursor: pointer;
transition: all $transition-fast;
&.active {
background: var(--accent-primary);
transform: scale(1.3);
}
&:hover:not(.active) {
background: var(--text-muted);
}
}
</style>
@@ -0,0 +1,166 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useScrollReveal } from '@/composables/useScrollReveal'
import { useTheme } from '@/composables/useTheme'
const { t } = useI18n()
const { isDark } = useTheme()
useScrollReveal()
const stars = ref<number | null>(null)
const chartSrc = computed(() => {
const base = 'https://api.star-history.com/svg?repos=EKKOLearnAI%2Fhermes-web-ui&type=Date'
return isDark.value ? `${base}&theme=dark` : base
})
onMounted(async () => {
try {
const res = await fetch('https://api.github.com/repos/EKKOLearnAI/hermes-web-ui')
const data = await res.json()
stars.value = data.stargazers_count
} catch {}
})
</script>
<template>
<div class="star-panel">
<h2 class="panel-title reveal">{{ t('starHistory.title') }}</h2>
<p class="panel-desc reveal">{{ t('starHistory.desc') }}</p>
<div class="star-badges reveal reveal-delay-1">
<a
class="star-btn"
href="https://github.com/EKKOLearnAI/hermes-web-ui"
target="_blank"
rel="noopener"
>
<svg viewBox="0 0 24 24" fill="currentColor" class="star-icon">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
<span>Star</span>
<span v-if="stars !== null" class="star-count">{{ stars.toLocaleString() }}</span>
</a>
<img
class="github-badge"
src="https://img.shields.io/github/license/EKKOLearnAI/hermes-web-ui?style=flat-square"
alt="License"
/>
<img
class="github-badge"
src="https://img.shields.io/github/v/release/EKKOLearnAI/hermes-web-ui?style=flat-square"
alt="Version"
/>
</div>
<div class="star-chart reveal reveal-delay-2">
<a
href="https://www.star-history.com/?type=date&repos=EKKOLearnAI%2Fhermes-web-ui"
target="_blank"
rel="noopener noreferrer"
class="chart-link"
>
<img
:src="chartSrc"
alt="Star History"
class="chart-img"
/>
</a>
</div>
</div>
</template>
<style scoped lang="scss">
.star-panel {
padding: 40px 32px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
display: flex;
flex-direction: column;
}
.panel-title {
font-size: 24px;
font-weight: 700;
margin-bottom: 8px;
color: var(--text-primary);
}
.panel-desc {
color: var(--text-secondary);
font-size: 15px;
margin-bottom: 24px;
}
.star-badges {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.star-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-md;
text-decoration: none;
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
transition: all $transition-fast;
&:hover {
border-color: var(--text-muted);
}
}
.star-icon {
width: 16px;
height: 16px;
fill: var(--text-muted);
}
.star-count {
padding: 1px 8px;
background: var(--bg-secondary);
border-left: 1px solid var(--border-color);
border-radius: 0 $radius-sm $radius-sm 0;
margin-left: 2px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.github-badge {
height: 22px;
border-radius: 2px;
}
.star-chart {
flex: 1;
display: flex;
align-items: center;
}
.chart-link {
display: block;
width: 100%;
}
.chart-img {
width: 100%;
border-radius: $radius-sm;
transition: opacity $transition-fast;
&:hover {
opacity: 0.85;
}
}
</style>
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<template>
<footer class="site-footer">
<div class="footer-inner">
<div class="footer-left">
<div class="footer-brand">
<img src="/logo.png" alt="Hermes" class="footer-logo" />
<span>Hermes Web UI</span>
</div>
<p class="footer-desc">{{ t('footer.description') }}</p>
</div>
<div class="footer-right">
<p class="footer-meta">{{ t('footer.madeWith') }}</p>
<p class="footer-meta">{{ t('footer.license') }}</p>
<a
class="footer-github"
href="https://github.com/EKKOLearnAI/hermes-web-ui"
target="_blank"
rel="noopener"
>
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
</a>
</div>
</div>
</footer>
</template>
<style scoped lang="scss">
.site-footer {
border-top: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.footer-inner {
max-width: 1120px;
margin: 0 auto;
padding: 40px 24px;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 40px;
@media (max-width: $breakpoint-mobile) {
flex-direction: column;
gap: 24px;
}
}
.footer-left {
display: flex;
flex-direction: column;
gap: 8px;
}
.footer-brand {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: var(--text-primary);
}
.footer-logo {
width: 24px;
height: 24px;
border-radius: $radius-sm;
}
.footer-desc {
color: var(--text-muted);
font-size: 13px;
}
.footer-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
@media (max-width: $breakpoint-mobile) {
align-items: flex-start;
}
}
.footer-meta {
color: var(--text-muted);
font-size: 13px;
}
.footer-github {
color: var(--text-muted);
transition: color $transition-fast;
&:hover {
color: var(--text-primary);
}
svg {
width: 20px;
height: 20px;
}
}
</style>
@@ -0,0 +1,257 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useTheme } from '@/composables/useTheme'
const { t, locale } = useI18n()
const router = useRouter()
const { isDark, toggleTheme } = useTheme()
const mobileMenuOpen = ref(false)
function switchLocale() {
const next = locale.value === 'en' ? 'zh' : 'en'
locale.value = next
localStorage.setItem('hermes_website_locale', next)
}
function navigateTo(name: string) {
router.push({ name })
mobileMenuOpen.value = false
}
function goHome() {
router.push({ name: 'landing' })
mobileMenuOpen.value = false
}
</script>
<template>
<header class="site-header">
<div class="header-inner">
<div class="header-left" @click="goHome">
<img src="/logo.png" alt="Hermes" class="logo-icon" />
<span class="logo-text">Hermes Web UI</span>
</div>
<nav class="header-nav">
<a class="nav-link" @click.prevent="navigateTo('landing')">{{ t('nav.home') }}</a>
<a class="nav-link" @click.prevent="navigateTo('docs.getting-started')">{{ t('nav.docs') }}</a>
<a
class="nav-link"
href="https://github.com/EKKOLearnAI/hermes-web-ui"
target="_blank"
rel="noopener"
>
{{ t('nav.github') }}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="external-icon">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
</a>
<button class="icon-btn" @click="switchLocale" :title="locale === 'en' ? '中文' : 'English'">
{{ locale === 'en' ? '中' : 'EN' }}
</button>
<button class="icon-btn" @click="toggleTheme" :title="isDark ? 'Light' : 'Dark'">
<svg v-if="isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</button>
</nav>
<button class="mobile-toggle" @click="mobileMenuOpen = !mobileMenuOpen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
</div>
<div v-if="mobileMenuOpen" class="mobile-menu" @click="mobileMenuOpen = false">
<div class="mobile-menu-inner" @click.stop>
<a class="mobile-link" @click.prevent="navigateTo('landing')">{{ t('nav.home') }}</a>
<a class="mobile-link" @click.prevent="navigateTo('docs.getting-started')">{{ t('nav.docs') }}</a>
<a class="mobile-link" href="https://github.com/EKKOLearnAI/hermes-web-ui" target="_blank" rel="noopener">{{ t('nav.github') }}</a>
<div class="mobile-actions">
<button class="icon-btn" @click="switchLocale">{{ locale === 'en' ? '中文' : 'English' }}</button>
<button class="icon-btn" @click="toggleTheme">{{ isDark ? 'Light Mode' : 'Dark Mode' }}</button>
</div>
</div>
</div>
</header>
</template>
<style scoped lang="scss">
.site-header {
position: sticky;
top: 0;
z-index: 100;
background: var(--bg-card);
border-bottom: 1px solid var(--border-color);
backdrop-filter: blur(8px);
}
.header-inner {
max-width: 1120px;
margin: 0 auto;
padding: 0 24px;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
font-weight: 600;
font-size: 16px;
color: var(--text-primary);
&:hover {
opacity: 0.8;
}
}
.logo-icon {
width: 28px;
height: 28px;
border-radius: $radius-sm;
}
.logo-text {
white-space: nowrap;
}
.header-nav {
display: flex;
align-items: center;
gap: 8px;
}
.nav-link {
padding: 6px 14px;
border-radius: $radius-sm;
color: var(--text-secondary);
font-size: 14px;
cursor: pointer;
transition: all $transition-fast;
display: inline-flex;
align-items: center;
gap: 4px;
&:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
}
.external-icon {
width: 12px;
height: 12px;
}
.icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 1px solid var(--border-color);
border-radius: $radius-sm;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all $transition-fast;
&:hover {
color: var(--text-primary);
border-color: var(--text-muted);
background: var(--bg-secondary);
}
svg {
width: 16px;
height: 16px;
}
}
.mobile-toggle {
display: none;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
background: transparent;
color: var(--text-primary);
cursor: pointer;
svg {
width: 20px;
height: 20px;
}
}
.mobile-menu {
position: fixed;
inset: 0;
top: 60px;
background: rgba(0, 0, 0, 0.3);
z-index: 99;
}
.mobile-menu-inner {
background: var(--bg-card);
padding: 16px 24px;
display: flex;
flex-direction: column;
gap: 4px;
border-bottom: 1px solid var(--border-color);
}
.mobile-link {
padding: 12px 0;
color: var(--text-secondary);
font-size: 16px;
cursor: pointer;
border-bottom: 1px solid var(--border-light);
&:hover {
color: var(--text-primary);
}
}
.mobile-actions {
display: flex;
gap: 8px;
padding-top: 12px;
}
@media (max-width: $breakpoint-mobile) {
.header-nav {
display: none;
}
.mobile-toggle {
display: flex;
}
}
</style>
@@ -0,0 +1,27 @@
import { onMounted, onUnmounted } from 'vue'
export function useScrollReveal() {
let observer: IntersectionObserver | null = null
onMounted(() => {
observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
entry.target.classList.add('revealed')
observer!.unobserve(entry.target)
}
}
},
{ threshold: 0.1, rootMargin: '0px 0px -40px 0px' },
)
document.querySelectorAll('.reveal').forEach((el) => {
observer!.observe(el)
})
})
onUnmounted(() => {
observer?.disconnect()
})
}
@@ -0,0 +1,54 @@
import { ref, watch } from 'vue'
export type ThemeMode = 'light' | 'dark' | 'system'
const STORAGE_KEY = 'hermes_website_theme'
const mode = ref<ThemeMode>(
(localStorage.getItem(STORAGE_KEY) as ThemeMode) || 'system',
)
const isDark = ref(false)
function applyTheme(dark: boolean) {
isDark.value = dark
document.documentElement.classList.toggle('dark', dark)
}
function resolveDark(m: ThemeMode): boolean {
if (m === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
return m === 'dark'
}
applyTheme(resolveDark(mode.value))
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', () => {
if (mode.value === 'system') {
applyTheme(resolveDark('system'))
}
})
watch(mode, (newMode) => {
localStorage.setItem(STORAGE_KEY, newMode)
applyTheme(resolveDark(newMode))
})
export function useTheme() {
function setMode(m: ThemeMode) {
mode.value = m
}
function toggleTheme() {
mode.value = isDark.value ? 'light' : 'dark'
}
return {
mode,
isDark,
setMode,
toggleTheme,
}
}
+3
View File
@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string
+247
View File
@@ -0,0 +1,247 @@
export default {
nav: {
home: 'Home',
docs: 'Documentation',
github: 'GitHub',
},
hero: {
title: 'Self-Hosted AI Chat Dashboard',
subtitle: 'Open-source AI agent dashboard — streaming chat, multi-model routing, Kanban boards, usage analytics, web terminal, all in one self-hosted interface.',
cta: 'Get Started',
viewGithub: 'View on GitHub',
install: 'npm install -g hermes-web-ui',
},
features: {
title: 'Everything You Need',
desc: 'A complete AI agent management dashboard with rich features out of the box.',
streaming: {
title: 'Streaming Chat',
desc: 'Real-time SSE-powered AI conversations with multi-session management, Markdown rendering, and code syntax highlighting.',
},
platforms: {
title: '8 Platforms',
desc: 'Unified management for Telegram, Discord, Slack, WhatsApp, Matrix, Feishu, WeChat, and WeCom channels.',
},
multiModel: {
title: 'Multi-Model',
desc: 'Support for Claude, GPT, Gemini, DeepSeek, and any OpenAI-compatible provider with auto-discovery.',
},
groupChat: {
title: 'Group Chat',
desc: 'Multi-agent chat rooms with mention routing, context compression, and real-time collaboration.',
},
kanban: {
title: 'Kanban Board',
desc: 'Visual task management with 7 status columns, assignee tracking, and filtering for AI-driven workflows.',
},
analytics: {
title: 'Usage Analytics',
desc: 'Token usage breakdown, cost tracking, cache hit rates, model distribution, and 30-day trends.',
},
profiles: {
title: 'Multi-Profile',
desc: 'Isolated profiles with independent configs. Clone, import/export profiles, run multiple gateways.',
},
files: {
title: 'File Browser',
desc: 'Manage files across local, Docker, SSH, and Singularity backends with upload, preview, and edit.',
},
terminal: {
title: 'Web Terminal',
desc: 'Full PTY terminal in the browser with multi-session support via WebSocket and xterm.js.',
},
quickInstall: {
title: 'One Command',
desc: 'Install and start with a single command. Auto-detects config, resolves ports, opens the browser.',
},
i18n: {
title: '8 Languages',
desc: 'Built-in support for English, Chinese, German, Spanish, French, Japanese, Korean, and Portuguese.',
},
theme: {
title: 'Dark / Light',
desc: 'Pure Ink monochrome design with smooth theme switching. Responsive layout for mobile and desktop.',
},
},
platforms: {
title: 'Unified Platform Management',
desc: 'Configure credentials and behavior for 8 messaging platforms from a single settings page.',
telegram: 'Telegram',
discord: 'Discord',
slack: 'Slack',
whatsapp: 'WhatsApp',
matrix: 'Matrix',
feishu: 'Feishu',
wechat: 'WeChat',
wecom: 'WeCom',
},
install: {
title: 'Quick Start',
desc: 'Get Hermes Web UI running in under a minute.',
npm: {
title: 'npm',
cmd1: 'npm install -g hermes-web-ui',
cmd2: 'hermes-web-ui start',
},
docker: {
title: 'Docker',
cmd: 'docker compose up -d',
},
source: {
title: 'From Source',
cmd1: 'git clone https://github.com/EKKOLearnAI/hermes-web-ui.git',
cmd2: 'cd hermes-web-ui && npm install && npm run dev',
},
prereq: 'Requires Node.js >= 23',
},
starHistory: {
title: 'Growing Community',
desc: 'Star us on GitHub and join the community.',
},
footer: {
description: 'Self-hosted AI chat dashboard for Hermes Agent.',
license: 'MIT License',
madeWith: 'Built with Vue 3, Naive UI, and TypeScript.',
},
docs: {
sidebar: {
gettingStarted: 'Getting Started',
configuration: 'Configuration',
features: 'Features',
platforms: 'Platform Guides',
api: 'API Reference',
},
gettingStarted: {
title: 'Getting Started',
intro: 'Hermes Web UI is a self-hosted web dashboard for managing AI conversations, platform channels, scheduled jobs, and more. It wraps the Hermes Agent CLI and provides a beautiful web interface.',
install: {
title: 'Installation',
content: 'Install globally via npm. Node.js 23 or higher is required.',
},
firstRun: {
title: 'First Run',
content: 'On first start, Hermes Web UI will automatically generate an auth token, validate configuration files, start the Hermes gateway, and open the dashboard in your browser.',
},
login: {
title: 'Login',
content: 'The auto-generated token is stored in ~/.hermes-web-ui/.token. You can also set up username/password login from the Settings page after your first login.',
},
},
configuration: {
title: 'Configuration',
intro: 'Hermes Web UI can be configured via environment variables.',
envVars: {
title: 'Environment Variables',
rows: [
['AUTH_DISABLED', 'Set to "1" to disable authentication'],
['AUTH_TOKEN', 'Custom auth token (overrides auto-generated)'],
['PORT', 'Server listen port (default: 8648)'],
['UPSTREAM', 'Hermes gateway URL (default: http://127.0.0.1:8642)'],
['UPLOAD_DIR', 'Custom upload directory path'],
['CORS_ORIGINS', 'CORS origin config (default: *)'],
['HERMES_BIN', 'Custom path to hermes CLI binary'],
],
},
gateway: {
title: 'Gateway Management',
content: 'The gateway is the Hermes Agent process that handles AI conversations. Hermes Web UI manages the gateway lifecycle — start, stop, and monitor from the Gateways page. Multiple gateways can run with different profiles.',
},
profiles: {
title: 'Profiles',
content: 'Profiles provide isolated configurations for different use cases. Each profile has its own Hermes config, cache, and gateway. Create, clone, import, or export profiles from the Profiles page.',
},
},
features: {
title: 'Features',
intro: 'Explore the core features of Hermes Web UI.',
chat: {
title: 'AI Chat',
content: 'Real-time streaming chat powered by Server-Sent Events. Supports multi-session management, Markdown rendering with syntax highlighting, tool call inspection, file upload/download, and global search across all conversations (Ctrl+K).',
},
kanban: {
title: 'Kanban Board',
content: 'A visual task management board with 7 status columns: triage, todo, ready, running, blocked, done, and archived. Supports assignee management, filtering, and detailed task editing via a side drawer.',
},
groupChat: {
title: 'Group Chat',
content: 'Multi-agent chat rooms where multiple AI agents collaborate. Features mention routing to trigger specific agents, automatic context compression when history exceeds limits, typing indicators, and SQLite-based message persistence.',
},
jobs: {
title: 'Scheduled Jobs',
content: 'Create and manage cron-based scheduled jobs that run AI tasks automatically. Configure schedule, prompt, and model for each job.',
},
skills: {
title: 'Skills',
content: 'Browse and manage installed AI skills. Skills extend the agent\'s capabilities with specialized knowledge and tool integrations.',
},
memory: {
title: 'Memory',
content: 'Manage agent memory and user notes. The agent uses memory to maintain context across conversations and personalize responses.',
},
terminal: {
title: 'Terminal',
content: 'Full pseudo-terminal in the browser powered by node-pty and xterm.js. Supports multiple terminal sessions, real-time keyboard input, and window resizing via WebSocket.',
},
files: {
title: 'File Browser',
content: 'Browse and manage files on remote backends including local, Docker, SSH, and Singularity. Upload, download, rename, move, delete files, and preview content with syntax highlighting.',
},
analytics: {
title: 'Usage Analytics',
content: 'Track token usage (input/output), estimated costs, cache hit rates, session counts, and model distribution. View 30-day daily trends with interactive charts.',
},
},
platforms: {
title: 'Platform Guides',
intro: 'Configure messaging platform integrations from the Channels settings page.',
telegram: {
title: 'Telegram',
content: 'Create a Telegram Bot via BotFather, then enter the bot token. Configure mention requirements, free-response chats, and reaction handling.',
},
discord: {
title: 'Discord',
content: 'Create a Discord Bot in the Developer Portal. Supports auto-thread creation, allowed/ignored channels, reaction handling, and free-response channels.',
},
slack: {
title: 'Slack',
content: 'Create a Slack App with bot token scope. Configure mention requirements, bot allowlisting, and free-response channels.',
},
whatsapp: {
title: 'WhatsApp',
content: 'Enable WhatsApp integration and configure mention patterns and free-response chats.',
},
matrix: {
title: 'Matrix',
content: 'Provide access token and homeserver URL. Supports auto-thread, DM mention threads, and free-response rooms.',
},
feishu: {
title: 'Feishu (Lark)',
content: 'Register a Feishu app and configure App ID and Secret.',
},
wechat: {
title: 'WeChat',
content: 'Scan the QR code from the settings page to log in. Credentials are auto-saved for subsequent sessions.',
},
wecom: {
title: 'WeCom',
content: 'Configure Bot ID and Secret from the WeCom admin console.',
},
},
api: {
title: 'API Reference',
intro: 'Hermes Web UI provides both a local BFF API and proxies requests to the upstream Hermes gateway.',
local: {
title: 'Local BFF Endpoints',
content: 'The Koa server handles session management, profile CRUD, config read/write, log access, skill listing, and memory operations. These endpoints call the Hermes CLI directly.',
},
proxy: {
title: 'Gateway Proxy',
content: 'Requests to /api/hermes/v1/* are forwarded to the Hermes gateway. This includes AI model interactions, run management, and streaming events.',
},
auth: {
title: 'Authentication',
content: 'All API endpoints require a Bearer token via the Authorization header. The token is auto-generated on first run and stored in ~/.hermes-web-ui/.token. Optional username/password login can be configured from the Settings page.',
},
},
},
}
+13
View File
@@ -0,0 +1,13 @@
import { createI18n } from 'vue-i18n'
import en from './en'
import zh from './zh'
const detected = navigator.language.startsWith('zh') ? 'zh' : 'en'
const saved = localStorage.getItem('hermes_website_locale')
export const i18n = createI18n({
legacy: false,
locale: saved || detected,
fallbackLocale: 'en',
messages: { en, zh },
})
+247
View File
@@ -0,0 +1,247 @@
export default {
nav: {
home: '首页',
docs: '文档',
github: 'GitHub',
},
hero: {
title: '自托管 AI 聊天仪表板',
subtitle: '开源 AI Agent 仪表板 — 流式对话、多模型调度、看板管理、用量分析、Web 终端,一个界面掌控一切。',
cta: '快速开始',
viewGithub: '查看 GitHub',
install: 'npm install -g hermes-web-ui',
},
features: {
title: '功能齐全',
desc: '开箱即用的完整 AI Agent 管理仪表板。',
streaming: {
title: '流式聊天',
desc: '基于 SSE 的实时 AI 对话,支持多会话管理、Markdown 渲染和代码语法高亮。',
},
platforms: {
title: '8 大平台',
desc: '统一管理 Telegram、Discord、Slack、WhatsApp、Matrix、飞书、微信、企业微信。',
},
multiModel: {
title: '多模型支持',
desc: '支持 Claude、GPT、Gemini、DeepSeek 及任何 OpenAI 兼容模型,自动发现。',
},
groupChat: {
title: '群聊协作',
desc: '多 Agent 聊天室,支持提及路由、上下文压缩和实时协作。',
},
kanban: {
title: '看板管理',
desc: '可视化任务看板,7 个状态列,支持任务分配和筛选。',
},
analytics: {
title: '用量分析',
desc: 'Token 用量、费用追踪、缓存命中率、模型分布和 30 天趋势。',
},
profiles: {
title: '多配置',
desc: '隔离的多配置文件,独立配置。支持克隆、导入/导出、多网关运行。',
},
files: {
title: '文件管理',
desc: '跨本地、Docker、SSH 和 Singularity 管理文件,支持上传、预览和编辑。',
},
terminal: {
title: 'Web 终端',
desc: '浏览器内完整 PTY 终端,基于 WebSocket 和 xterm.js 的多会话支持。',
},
quickInstall: {
title: '一键安装',
desc: '一条命令安装启动。自动检测配置、解析端口、打开浏览器。',
},
i18n: {
title: '8 种语言',
desc: '内置英语、中文、德语、西班牙语、法语、日语、韩语和葡萄牙语。',
},
theme: {
title: '暗色 / 亮色',
desc: '水墨单色设计,平滑主题切换,响应式布局适配移动端和桌面端。',
},
},
platforms: {
title: '统一平台管理',
desc: '在一个页面配置 8 大消息平台的凭证和行为。',
telegram: 'Telegram',
discord: 'Discord',
slack: 'Slack',
whatsapp: 'WhatsApp',
matrix: 'Matrix',
feishu: '飞书',
wechat: '微信',
wecom: '企业微信',
},
install: {
title: '快速开始',
desc: '一分钟内启动 Hermes Web UI。',
npm: {
title: 'npm',
cmd1: 'npm install -g hermes-web-ui',
cmd2: 'hermes-web-ui start',
},
docker: {
title: 'Docker',
cmd: 'docker compose up -d',
},
source: {
title: '源码安装',
cmd1: 'git clone https://github.com/EKKOLearnAI/hermes-web-ui.git',
cmd2: 'cd hermes-web-ui && npm install && npm run dev',
},
prereq: '需要 Node.js >= 23',
},
starHistory: {
title: '社区成长',
desc: '在 GitHub 上给我们加星,加入社区。',
},
footer: {
description: 'Hermes Agent 的自托管 AI 聊天仪表板。',
license: 'MIT 开源协议',
madeWith: '使用 Vue 3、Naive UI 和 TypeScript 构建。',
},
docs: {
sidebar: {
gettingStarted: '快速开始',
configuration: '配置说明',
features: '功能详解',
platforms: '平台接入',
api: 'API 参考',
},
gettingStarted: {
title: '快速开始',
intro: 'Hermes Web UI 是一个自托管的 Web 仪表板,用于管理 AI 对话、平台通道、定时任务等。它封装了 Hermes Agent CLI 并提供美观的 Web 界面。',
install: {
title: '安装',
content: '通过 npm 全局安装。需要 Node.js 23 或更高版本。',
},
firstRun: {
title: '首次运行',
content: '首次启动时,Hermes Web UI 会自动生成认证令牌、验证配置文件、启动 Hermes 网关并在浏览器中打开仪表板。',
},
login: {
title: '登录',
content: '自动生成的令牌存储在 ~/.hermes-web-ui/.token。首次登录后可在设置页面配置用户名/密码登录。',
},
},
configuration: {
title: '配置说明',
intro: 'Hermes Web UI 可通过环境变量进行配置。',
envVars: {
title: '环境变量',
rows: [
['AUTH_DISABLED', '设为 "1" 禁用认证'],
['AUTH_TOKEN', '自定义认证令牌(覆盖自动生成的令牌)'],
['PORT', '服务器监听端口(默认:8648'],
['UPSTREAM', 'Hermes 网关 URL(默认:http://127.0.0.1:8642'],
['UPLOAD_DIR', '自定义上传目录路径'],
['CORS_ORIGINS', 'CORS 来源配置(默认:*'],
['HERMES_BIN', '自定义 hermes CLI 二进制路径'],
],
},
gateway: {
title: '网关管理',
content: '网关是处理 AI 对话的 Hermes Agent 进程。Hermes Web UI 管理网关生命周期——在网关页面启动、停止和监控。不同配置可运行多个网关。',
},
profiles: {
title: '配置文件',
content: '配置文件为不同场景提供隔离的配置。每个配置文件拥有独立的 Hermes 配置、缓存和网关。可在配置页面创建、克隆、导入或导出配置文件。',
},
},
features: {
title: '功能详解',
intro: '探索 Hermes Web UI 的核心功能。',
chat: {
title: 'AI 聊天',
content: '基于 Server-Sent Events 的实时流式聊天。支持多会话管理、Markdown 渲染与语法高亮、工具调用检查、文件上传/下载以及全局搜索 (Ctrl+K)。',
},
kanban: {
title: '看板管理',
content: '可视化任务看板,包含 7 个状态列:分流、待办、就绪、运行中、阻塞、完成和已归档。支持任务分配、筛选和通过侧边抽屉进行详细编辑。',
},
groupChat: {
title: '群聊协作',
content: '多 Agent 聊天室,多个 AI Agent 协同工作。支持提及路由触发特定 Agent、历史记录超限时自动压缩上下文、输入状态指示和基于 SQLite 的消息持久化。',
},
jobs: {
title: '定时任务',
content: '创建和管理基于 cron 的定时任务,自动运行 AI 任务。可配置计划、提示词和模型。',
},
skills: {
title: '技能',
content: '浏览和管理已安装的 AI 技能。技能通过专业知识和工具集成扩展 Agent 能力。',
},
memory: {
title: '记忆',
content: '管理 Agent 记忆和用户笔记。Agent 使用记忆在对话间保持上下文并提供个性化回复。',
},
terminal: {
title: '终端',
content: '基于 node-pty 和 xterm.js 的浏览器内完整伪终端。支持多个终端会话、实时键盘输入和通过 WebSocket 的窗口大小调整。',
},
files: {
title: '文件管理',
content: '浏览和管理本地、Docker、SSH 和 Singularity 等远程后端上的文件。支持上传、下载、重命名、移动、删除文件以及带语法高亮的内容预览。',
},
analytics: {
title: '用量分析',
content: '追踪 Token 用量(输入/输出)、预估费用、缓存命中率、会话数和模型分布。查看 30 天日趋势交互图表。',
},
},
platforms: {
title: '平台接入',
intro: '从通道设置页面配置消息平台集成。',
telegram: {
title: 'Telegram',
content: '通过 BotFather 创建 Telegram Bot,输入 Bot Token。可配置提及要求、自由回复聊天和反应处理。',
},
discord: {
title: 'Discord',
content: '在开发者门户创建 Discord Bot。支持自动创建线程、允许/忽略频道、反应处理和自由回复频道。',
},
slack: {
title: 'Slack',
content: '创建带有 bot token 权限的 Slack App。配置提及要求、Bot 白名单和自由回复频道。',
},
whatsapp: {
title: 'WhatsApp',
content: '启用 WhatsApp 集成,配置提及模式和自由回复聊天。',
},
matrix: {
title: 'Matrix',
content: '提供访问令牌和服务器 URL。支持自动线程、私聊提及线程和自由回复房间。',
},
feishu: {
title: '飞书',
content: '注册飞书应用并配置 App ID 和 Secret。',
},
wechat: {
title: '微信',
content: '从设置页面扫描二维码登录。凭据会自动保存供后续使用。',
},
wecom: {
title: '企业微信',
content: '从企业微信管理后台配置 Bot ID 和 Secret。',
},
},
api: {
title: 'API 参考',
intro: 'Hermes Web UI 提供本地 BFF API 并代理请求到上游 Hermes 网关。',
local: {
title: '本地 BFF 端点',
content: 'Koa 服务器处理会话管理、配置文件 CRUD、配置读写、日志访问、技能列表和记忆操作。这些端点直接调用 Hermes CLI。',
},
proxy: {
title: '网关代理',
content: '对 /api/hermes/v1/* 的请求会转发到 Hermes 网关。包括 AI 模型交互、运行管理和流式事件。',
},
auth: {
title: '认证',
content: '所有 API 端点需要通过 Authorization 头提供 Bearer 令牌。令牌在首次运行时自动生成并存储在 ~/.hermes-web-ui/.token。可在设置页面配置可选的用户名/密码登录。',
},
},
},
}
+18
View File
@@ -0,0 +1,18 @@
import { createApp } from 'vue'
import router from './router'
import { i18n } from './i18n'
import App from './App.vue'
// Import CSS custom properties (theme variables) from client
import '@client/styles/variables.scss'
import './styles/global.scss'
const savedTheme = localStorage.getItem('hermes_website_theme') || 'system'
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
if (savedTheme === 'dark' || (savedTheme === 'system' && prefersDark)) {
document.documentElement.classList.add('dark')
}
const app = createApp(App)
app.use(i18n)
app.use(router)
app.mount('#app')
+61
View File
@@ -0,0 +1,61 @@
import { createRouter, createWebHashHistory } from 'vue-router'
const EmptyView = { render: () => null }
const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: '/',
name: 'landing',
component: () => import('@/views/LandingView.vue'),
},
{
path: '/docs',
name: 'docs',
component: () => import('@/views/DocsView.vue'),
redirect: { name: 'docs.getting-started' },
children: [
{
path: 'getting-started',
name: 'docs.getting-started',
component: EmptyView,
meta: { page: 'gettingStarted' },
},
{
path: 'configuration',
name: 'docs.configuration',
component: EmptyView,
meta: { page: 'configuration' },
},
{
path: 'features',
name: 'docs.features',
component: EmptyView,
meta: { page: 'features' },
},
{
path: 'platforms',
name: 'docs.platforms',
component: EmptyView,
meta: { page: 'platforms' },
},
{
path: 'api',
name: 'docs.api',
component: EmptyView,
meta: { page: 'api' },
},
],
},
{
path: '/:pathMatch(.*)*',
redirect: '/',
},
],
scrollBehavior() {
return { top: 0 }
},
})
export default router
@@ -0,0 +1,14 @@
// Website SCSS variables — pure constants (no CSS custom properties)
// CSS custom properties are defined in global.scss (imported from client)
$font-ui: 'Inter', system-ui, -apple-system, sans-serif;
$font-code: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
$breakpoint-mobile: 768px;
$radius-sm: 6px;
$radius-md: 10px;
$radius-lg: 14px;
$transition-fast: 0.15s ease;
$transition-normal: 0.25s ease;
+132
View File
@@ -0,0 +1,132 @@
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 14px;
line-height: 1.6;
scroll-behavior: smooth;
}
body {
font-family: $font-ui;
background: var(--bg-primary);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
color: var(--accent-primary);
text-decoration: none;
&:hover {
color: var(--accent-hover);
}
}
code {
font-family: $font-code;
background: var(--code-bg);
padding: 2px 6px;
border-radius: $radius-sm;
font-size: 0.9em;
}
pre {
code {
display: block;
padding: 16px;
border-radius: $radius-md;
overflow-x: auto;
}
}
.section {
max-width: 1120px;
margin: 0 auto;
padding: 80px 24px;
@media (max-width: $breakpoint-mobile) {
padding: 48px 16px;
}
}
.section-title {
font-size: 32px;
font-weight: 700;
text-align: center;
margin-bottom: 16px;
color: var(--text-primary);
@media (max-width: $breakpoint-mobile) {
font-size: 24px;
}
}
.section-desc {
text-align: center;
color: var(--text-secondary);
font-size: 16px;
max-width: 640px;
margin: 0 auto 48px;
}
// ─── Scroll reveal animations ────────────────────────────
.reveal {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.6s ease, transform 0.6s ease;
&.revealed {
opacity: 1;
transform: translateY(0);
}
}
.reveal-delay-1 { transition-delay: 0.08s; }
.reveal-delay-2 { transition-delay: 0.16s; }
.reveal-delay-3 { transition-delay: 0.24s; }
.reveal-delay-4 { transition-delay: 0.32s; }
// ─── Keyframes ────────────────────────────────────────────
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(32px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes pulse-border {
0%, 100% { border-color: var(--border-color); }
50% { border-color: var(--text-muted); }
}
.animate-fade-in-up {
animation: fade-in-up 0.7s ease both;
}
.animate-fade-in {
animation: fade-in 0.5s ease both;
}
.animate-delay-1 { animation-delay: 0.1s; }
.animate-delay-2 { animation-delay: 0.2s; }
.animate-delay-3 { animation-delay: 0.3s; }
.animate-delay-4 { animation-delay: 0.4s; }
.animate-delay-5 { animation-delay: 0.5s; }
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
import DocSidebar from '@/components/docs/DocSidebar.vue'
import DocContent from '@/components/docs/DocContent.vue'
const route = useRoute()
</script>
<template>
<div class="docs-layout">
<DocSidebar v-if="route.meta.page" />
<div class="docs-main">
<router-view />
<DocContent v-if="route.meta.page" />
<div v-else class="docs-placeholder">
<p>Select a section from the sidebar to get started.</p>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.docs-layout {
display: flex;
max-width: 1120px;
margin: 0 auto;
min-height: calc(100vh - 60px - 200px);
}
.docs-main {
flex: 1;
min-width: 0;
}
.docs-placeholder {
padding: 60px 32px;
color: var(--text-muted);
font-size: 16px;
text-align: center;
}
</style>
@@ -0,0 +1,40 @@
<script setup lang="ts">
import HeroSection from '@/components/landing/HeroSection.vue'
import ScreenshotsSection from '@/components/landing/ScreenshotsSection.vue'
import FeaturesGrid from '@/components/landing/FeaturesGrid.vue'
import InstallSection from '@/components/landing/InstallSection.vue'
import StarHistorySection from '@/components/landing/StarHistorySection.vue'
</script>
<template>
<div class="landing">
<HeroSection />
<ScreenshotsSection />
<FeaturesGrid />
<div class="cta-row">
<InstallSection class="cta-col" />
<StarHistorySection class="cta-col" />
</div>
</div>
</template>
<style scoped lang="scss">
.cta-row {
max-width: 1120px;
margin: 0 auto;
padding: 80px 24px;
display: flex;
gap: 40px;
@media (max-width: $breakpoint-mobile) {
flex-direction: column;
padding: 48px 16px;
gap: 0;
}
}
.cta-col {
flex: 1;
min-width: 0;
}
</style>
+2 -1
View File
@@ -2,6 +2,7 @@
"files": [], "files": [],
"references": [ "references": [
{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" } { "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.website.json" }
] ]
} }
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"lib": ["ES2025", "DOM", "DOM.Iterable"],
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.website.tsbuildinfo",
"types": ["vite/client"],
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"paths": {
"@/*": ["packages/website/src/*"],
"@client/*": ["packages/client/src/*"]
},
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["packages/website/src/**/*.ts", "packages/website/src/**/*.tsx", "packages/website/src/**/*.vue"]
}
+54
View File
@@ -0,0 +1,54 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import pkg from './package.json'
export default defineConfig({
root: 'packages/website',
plugins: [vue()],
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
resolve: {
alias: {
'@': resolve(__dirname, 'packages/website/src'),
'@client': resolve(__dirname, 'packages/client/src'),
},
},
css: {
preprocessorOptions: {
scss: {
additionalData: `@use "@/styles/variables" as *;\n`,
},
},
},
build: {
outDir: '../../dist/website',
emptyOutDir: true,
minify: 'esbuild',
sourcemap: false,
target: 'es2020',
cssCodeSplit: true,
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('vue') || id.includes('vue-router') || id.includes('vue-i18n') || id.includes('pinia')) {
return 'vue-vendor'
}
if (id.includes('naive-ui')) {
return 'ui-vendor'
}
return 'vendor'
}
},
chunkFileNames: 'assets/js/[name]-[hash].js',
entryFileNames: 'assets/js/[name]-[hash].js',
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
},
},
},
server: {
port: 3000,
},
})