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:
@@ -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": [
|
||||||
|
|||||||
@@ -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 |
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare const __APP_VERSION__: string
|
||||||
@@ -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.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
})
|
||||||
@@ -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。可在设置页面配置可选的用户名/密码登录。',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
@@ -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;
|
||||||
@@ -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; }
|
||||||
@@ -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
@@ -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" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user