@netrojs/create-vono 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +768 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +213 -0
- package/files/_gitignore +6 -0
- package/files/_package.json +27 -0
- package/files/app/layouts/DashboardLayout.vue +55 -0
- package/files/app/layouts/RootLayout.vue +62 -0
- package/files/app/pages/404.vue +14 -0
- package/files/app/pages/blog/[slug].vue +59 -0
- package/files/app/pages/blog/index.vue +70 -0
- package/files/app/pages/dashboard/index.vue +84 -0
- package/files/app/pages/dashboard/posts.vue +64 -0
- package/files/app/pages/dashboard/settings.vue +73 -0
- package/files/app/pages/home.vue +94 -0
- package/files/app/pages/login.vue +71 -0
- package/files/app/routes.ts +272 -0
- package/files/app/style.css +279 -0
- package/files/app.ts +36 -0
- package/files/client.ts +17 -0
- package/files/global.d.ts +13 -0
- package/files/server.ts +18 -0
- package/files/tsconfig.json +20 -0
- package/files/vite.config.ts +47 -0
- package/package.json +54 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// index.ts
|
|
4
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs";
|
|
5
|
+
import { dirname, join, relative, resolve } from "path";
|
|
6
|
+
import { execSync } from "child_process";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
import prompts from "prompts";
|
|
9
|
+
import { bold, cyan, dim, green, red } from "kolorist";
|
|
10
|
+
var VONO_VERSION = "0.0.1";
|
|
11
|
+
var FILES_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "files");
|
|
12
|
+
function banner() {
|
|
13
|
+
console.log();
|
|
14
|
+
console.log(bold(cyan(" \u25C8 create-vono")));
|
|
15
|
+
console.log(dim(" Full-stack Hono + Vue 3 \u2014 SSR \xB7 SPA \xB7 code splitting \xB7 TypeScript"));
|
|
16
|
+
console.log();
|
|
17
|
+
}
|
|
18
|
+
function isDirEmpty(dir) {
|
|
19
|
+
if (!existsSync(dir)) return true;
|
|
20
|
+
const items = readdirSync(dir);
|
|
21
|
+
return items.length === 0 || items.length === 1 && items[0] === ".git";
|
|
22
|
+
}
|
|
23
|
+
function* walk(dir) {
|
|
24
|
+
for (const entry of readdirSync(dir)) {
|
|
25
|
+
const full = join(dir, entry);
|
|
26
|
+
if (statSync(full).isDirectory()) yield* walk(full);
|
|
27
|
+
else yield full;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function applyVars(content, vars) {
|
|
31
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
32
|
+
content = content.replaceAll(`{{${k}}}`, v);
|
|
33
|
+
}
|
|
34
|
+
return content;
|
|
35
|
+
}
|
|
36
|
+
var serverContent = {
|
|
37
|
+
node: `// server.ts \u2014 Node.js production entry
|
|
38
|
+
// vonoVitePlugin sets target: 'node18' in the SSR build, enabling top-level await.
|
|
39
|
+
import { serve } from '@netrojs/vono/server'
|
|
40
|
+
import { vono } from './app'
|
|
41
|
+
|
|
42
|
+
await serve({
|
|
43
|
+
app: vono,
|
|
44
|
+
port: Number(process.env['PORT'] ?? 3000),
|
|
45
|
+
runtime: 'node',
|
|
46
|
+
staticDir: './dist',
|
|
47
|
+
})
|
|
48
|
+
`,
|
|
49
|
+
bun: `// server.ts \u2014 Bun production entry
|
|
50
|
+
// vonoVitePlugin sets target: 'node18' in the SSR build, enabling top-level await.
|
|
51
|
+
import { serve } from '@netrojs/vono/server'
|
|
52
|
+
import { vono } from './app'
|
|
53
|
+
|
|
54
|
+
await serve({
|
|
55
|
+
app: vono,
|
|
56
|
+
port: Number(process.env['PORT'] ?? 3000),
|
|
57
|
+
runtime: 'bun',
|
|
58
|
+
staticDir: './dist',
|
|
59
|
+
})
|
|
60
|
+
`,
|
|
61
|
+
deno: `// server.ts \u2014 Deno production entry
|
|
62
|
+
// vonoVitePlugin sets target: 'node18' in the SSR build, enabling top-level await.
|
|
63
|
+
import { serve } from '@netrojs/vono/server'
|
|
64
|
+
import { vono } from './app'
|
|
65
|
+
|
|
66
|
+
await serve({
|
|
67
|
+
app: vono,
|
|
68
|
+
port: Number(Deno.env.get('PORT') ?? 3000),
|
|
69
|
+
runtime: 'deno',
|
|
70
|
+
staticDir: './dist',
|
|
71
|
+
})
|
|
72
|
+
`
|
|
73
|
+
};
|
|
74
|
+
function scaffold(dir, a) {
|
|
75
|
+
const devCmds = {
|
|
76
|
+
node: "vite --host",
|
|
77
|
+
bun: "bun --bun vite --host",
|
|
78
|
+
deno: "deno run -A npm:vite --host"
|
|
79
|
+
};
|
|
80
|
+
const buildCmds = {
|
|
81
|
+
node: "vite build",
|
|
82
|
+
bun: "bun --bun vite build",
|
|
83
|
+
deno: "deno run -A npm:vite build"
|
|
84
|
+
};
|
|
85
|
+
const vars = {
|
|
86
|
+
PROJECT_NAME: a.projectName,
|
|
87
|
+
VONO_VERSION,
|
|
88
|
+
DEV_CMD: devCmds[a.runtime],
|
|
89
|
+
BUILD_CMD: buildCmds[a.runtime]
|
|
90
|
+
};
|
|
91
|
+
mkdirSync(dir, { recursive: true });
|
|
92
|
+
for (const srcPath of walk(FILES_DIR)) {
|
|
93
|
+
const rel = relative(FILES_DIR, srcPath);
|
|
94
|
+
if (/\scopy\b/.test(rel)) continue;
|
|
95
|
+
const renamed = rel.replace(/^_package\.json$/, "package.json").replace(/^_gitignore$/, ".gitignore");
|
|
96
|
+
const destPath = join(dir, renamed);
|
|
97
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
98
|
+
let content = readFileSync(srcPath, "utf-8");
|
|
99
|
+
if (renamed === "server.ts") {
|
|
100
|
+
content = serverContent[a.runtime];
|
|
101
|
+
}
|
|
102
|
+
if (renamed === "package.json") {
|
|
103
|
+
const parsed = JSON.parse(applyVars(content, vars));
|
|
104
|
+
if (a.runtime === "bun") {
|
|
105
|
+
delete parsed.devDependencies["@hono/node-server"];
|
|
106
|
+
parsed.devDependencies["@types/bun"] = "latest";
|
|
107
|
+
}
|
|
108
|
+
if (a.runtime === "deno") {
|
|
109
|
+
delete parsed.devDependencies["@hono/node-server"];
|
|
110
|
+
}
|
|
111
|
+
writeFileSync(destPath, JSON.stringify(parsed, null, 2) + "\n", "utf-8");
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
writeFileSync(destPath, applyVars(content, vars), "utf-8");
|
|
115
|
+
}
|
|
116
|
+
writeFileSync(
|
|
117
|
+
join(dir, ".env.example"),
|
|
118
|
+
`PORT=3000
|
|
119
|
+
NODE_ENV=development
|
|
120
|
+
`,
|
|
121
|
+
"utf-8"
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
var INSTALL = {
|
|
125
|
+
npm: "npm install",
|
|
126
|
+
pnpm: "pnpm install",
|
|
127
|
+
bun: "bun install",
|
|
128
|
+
yarn: "yarn"
|
|
129
|
+
};
|
|
130
|
+
async function main() {
|
|
131
|
+
banner();
|
|
132
|
+
const nameArg = process.argv[2]?.trim();
|
|
133
|
+
const a = await prompts([
|
|
134
|
+
{
|
|
135
|
+
name: "projectName",
|
|
136
|
+
type: nameArg ? null : "text",
|
|
137
|
+
message: "Project name:",
|
|
138
|
+
initial: "my-vono-app",
|
|
139
|
+
validate: (v) => v.trim() ? true : "Name is required"
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
name: "runtime",
|
|
143
|
+
type: "select",
|
|
144
|
+
message: "Runtime:",
|
|
145
|
+
choices: [
|
|
146
|
+
{ title: "Node.js", value: "node" },
|
|
147
|
+
{ title: "Bun", value: "bun" },
|
|
148
|
+
{ title: "Deno", value: "deno" }
|
|
149
|
+
],
|
|
150
|
+
initial: 0
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: "pkgManager",
|
|
154
|
+
type: "select",
|
|
155
|
+
message: "Package manager:",
|
|
156
|
+
choices: [
|
|
157
|
+
{ title: "npm", value: "npm" },
|
|
158
|
+
{ title: "pnpm", value: "pnpm" },
|
|
159
|
+
{ title: "bun", value: "bun" },
|
|
160
|
+
{ title: "yarn", value: "yarn" }
|
|
161
|
+
],
|
|
162
|
+
initial: 0
|
|
163
|
+
},
|
|
164
|
+
{ name: "gitInit", type: "confirm", message: "Init git repo?", initial: true },
|
|
165
|
+
{ name: "installDeps", type: "confirm", message: "Install dependencies?", initial: true }
|
|
166
|
+
], {
|
|
167
|
+
onCancel: () => {
|
|
168
|
+
console.log(red("\nCancelled.\n"));
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
if (nameArg) a.projectName = nameArg;
|
|
173
|
+
const dir = resolve(process.cwd(), a.projectName);
|
|
174
|
+
if (!isDirEmpty(dir)) {
|
|
175
|
+
console.log(red(`
|
|
176
|
+
Directory "${a.projectName}" is not empty.
|
|
177
|
+
`));
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
console.log();
|
|
181
|
+
scaffold(dir, a);
|
|
182
|
+
console.log(green(` \u2713 Scaffolded ${bold(a.projectName)}/`));
|
|
183
|
+
if (a.gitInit) {
|
|
184
|
+
try {
|
|
185
|
+
execSync("git init", { cwd: dir, stdio: "ignore" });
|
|
186
|
+
execSync("git add -A", { cwd: dir, stdio: "ignore" });
|
|
187
|
+
execSync('git commit -m "chore: initial vono scaffold"', { cwd: dir, stdio: "ignore" });
|
|
188
|
+
console.log(green(" \u2713 Git repo initialised"));
|
|
189
|
+
} catch {
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (a.installDeps) {
|
|
193
|
+
console.log(dim(`
|
|
194
|
+
Running ${INSTALL[a.pkgManager]}\u2026
|
|
195
|
+
`));
|
|
196
|
+
execSync(INSTALL[a.pkgManager], { cwd: dir, stdio: "inherit" });
|
|
197
|
+
}
|
|
198
|
+
const rel = relative(process.cwd(), dir);
|
|
199
|
+
console.log();
|
|
200
|
+
console.log(bold(" Next steps:"));
|
|
201
|
+
if (rel !== ".") console.log(` ${cyan(`cd ${rel}`)}`);
|
|
202
|
+
if (!a.installDeps) console.log(` ${cyan(INSTALL[a.pkgManager])}`);
|
|
203
|
+
console.log(` ${cyan(a.runtime === "bun" ? "bun run dev" : "npm run dev")}`);
|
|
204
|
+
console.log();
|
|
205
|
+
console.log(dim(" Open http://localhost:5173 to see the demo app."));
|
|
206
|
+
console.log(dim(" Dashboard demo: /dashboard (sign in with any credentials)"));
|
|
207
|
+
console.log(dim(" Docs: https://github.com/netrosolutions/vono"));
|
|
208
|
+
console.log();
|
|
209
|
+
}
|
|
210
|
+
main().catch((err) => {
|
|
211
|
+
console.error(err);
|
|
212
|
+
process.exit(1);
|
|
213
|
+
});
|
package/files/_gitignore
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{PROJECT_NAME}}",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"private": true,
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "{{DEV_CMD}}",
|
|
8
|
+
"build": "{{BUILD_CMD}}",
|
|
9
|
+
"start": "node dist/server/server.js",
|
|
10
|
+
"typecheck": "vue-tsc --noEmit"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@netrojs/vono": "^{{VONO_VERSION}}",
|
|
14
|
+
"vue": "^3.5.13",
|
|
15
|
+
"vue-router": "^4.5.1",
|
|
16
|
+
"@vue/server-renderer": "^3.5.13",
|
|
17
|
+
"hono": "^4.12.8"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@hono/node-server": "^1.19.11",
|
|
21
|
+
"@hono/vite-dev-server": "^0.25.0",
|
|
22
|
+
"@vitejs/plugin-vue": "^5.2.3",
|
|
23
|
+
"typescript": "^5.9.3",
|
|
24
|
+
"vite": "^6.3.5",
|
|
25
|
+
"vue-tsc": "^2.2.10"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { RouterLink, useRoute } from 'vue-router'
|
|
3
|
+
import { computed } from 'vue'
|
|
4
|
+
|
|
5
|
+
const route = useRoute()
|
|
6
|
+
|
|
7
|
+
const sideNav = [
|
|
8
|
+
{ to: '/dashboard', icon: 'π', label: 'Overview' },
|
|
9
|
+
{ to: '/dashboard/posts', icon: 'π', label: 'Posts' },
|
|
10
|
+
{ to: '/dashboard/settings', icon: 'βοΈ', label: 'Settings' },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
const pageTitle = computed(() => {
|
|
14
|
+
const match = sideNav.find(n => n.to === route.path)
|
|
15
|
+
return match?.label ?? 'Dashboard'
|
|
16
|
+
})
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<div class="dash-shell">
|
|
21
|
+
<!-- ββ Sidebar βββββββββββββββββββββββββββββββββββββββββββββββββββββ -->
|
|
22
|
+
<aside class="sidebar">
|
|
23
|
+
<RouterLink to="/" class="sidebar-logo">β Vono</RouterLink>
|
|
24
|
+
|
|
25
|
+
<nav class="sidebar-nav">
|
|
26
|
+
<RouterLink
|
|
27
|
+
v-for="item in sideNav"
|
|
28
|
+
:key="item.to"
|
|
29
|
+
:to="item.to"
|
|
30
|
+
class="sidebar-link"
|
|
31
|
+
:class="{ active: route.path === item.to }"
|
|
32
|
+
>
|
|
33
|
+
<span class="sidebar-icon">{{ item.icon }}</span>
|
|
34
|
+
{{ item.label }}
|
|
35
|
+
</RouterLink>
|
|
36
|
+
</nav>
|
|
37
|
+
|
|
38
|
+
<div class="sidebar-footer">
|
|
39
|
+
<a href="/" class="sidebar-link">β Back to site</a>
|
|
40
|
+
</div>
|
|
41
|
+
</aside>
|
|
42
|
+
|
|
43
|
+
<!-- ββ Main area βββββββββββββββββββββββββββββββββββββββββββββββββββ -->
|
|
44
|
+
<div class="dash-body">
|
|
45
|
+
<header class="dash-header">
|
|
46
|
+
<h1 class="dash-title">{{ pageTitle }}</h1>
|
|
47
|
+
<span class="dash-badge">Demo mode β auth stub</span>
|
|
48
|
+
</header>
|
|
49
|
+
|
|
50
|
+
<main class="dash-content">
|
|
51
|
+
<slot />
|
|
52
|
+
</main>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</template>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { RouterLink, useRoute } from 'vue-router'
|
|
3
|
+
import { ref, onMounted } from 'vue'
|
|
4
|
+
|
|
5
|
+
const route = useRoute()
|
|
6
|
+
const menuOpen = ref(false)
|
|
7
|
+
const scrolled = ref(false)
|
|
8
|
+
|
|
9
|
+
// Demonstrates onMounted + ref working correctly after SSR hydration
|
|
10
|
+
onMounted(() => {
|
|
11
|
+
const handler = () => { scrolled.value = window.scrollY > 40 }
|
|
12
|
+
window.addEventListener('scroll', handler, { passive: true })
|
|
13
|
+
handler()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const nav = [
|
|
17
|
+
{ to: '/', label: 'Home' },
|
|
18
|
+
{ to: '/blog', label: 'Blog' },
|
|
19
|
+
{ to: '/dashboard', label: 'Dashboard' },
|
|
20
|
+
]
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<div class="app">
|
|
25
|
+
<!-- ββ Sticky nav βββββββββββββββββββββββββββββββββββββββββββββββββββ -->
|
|
26
|
+
<header class="nav" :class="{ scrolled }">
|
|
27
|
+
<RouterLink to="/" class="logo" @click="menuOpen = false">β Vono</RouterLink>
|
|
28
|
+
|
|
29
|
+
<nav class="nav-links" :class="{ open: menuOpen }">
|
|
30
|
+
<RouterLink
|
|
31
|
+
v-for="item in nav"
|
|
32
|
+
:key="item.to"
|
|
33
|
+
:to="item.to"
|
|
34
|
+
class="nav-link"
|
|
35
|
+
:class="{ active: route.path === item.to || (item.to !== '/' && route.path.startsWith(item.to)) }"
|
|
36
|
+
@click="menuOpen = false"
|
|
37
|
+
>
|
|
38
|
+
{{ item.label }}
|
|
39
|
+
</RouterLink>
|
|
40
|
+
</nav>
|
|
41
|
+
|
|
42
|
+
<button class="menu-btn" :aria-expanded="menuOpen" @click="menuOpen = !menuOpen">
|
|
43
|
+
<span class="sr-only">Toggle menu</span>
|
|
44
|
+
<span class="hamburger" :class="{ open: menuOpen }" />
|
|
45
|
+
</button>
|
|
46
|
+
</header>
|
|
47
|
+
|
|
48
|
+
<!-- ββ Page content (slot) βββββββββββββββββββββββββββββββββββββββββ -->
|
|
49
|
+
<main class="main">
|
|
50
|
+
<slot />
|
|
51
|
+
</main>
|
|
52
|
+
|
|
53
|
+
<!-- ββ Footer βββββββββββββββββββββββββββββββββββββββββββββββββββββ -->
|
|
54
|
+
<footer class="footer">
|
|
55
|
+
<span>Built with <a href="https://github.com/netrosolutions/vono" rel="external noopener">β Vono</a></span>
|
|
56
|
+
<span class="footer-sep">Β·</span>
|
|
57
|
+
<a href="https://hono.dev" rel="external noopener">Hono</a>
|
|
58
|
+
<span class="footer-sep">Β·</span>
|
|
59
|
+
<a href="https://vuejs.org" rel="external noopener">Vue 3</a>
|
|
60
|
+
</footer>
|
|
61
|
+
</div>
|
|
62
|
+
</template>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// This component is used as createVono({ notFound: NotFoundPage })
|
|
3
|
+
// It is rendered server-side for unmatched URLs (returns 404 status).
|
|
4
|
+
// No usePageData() here β it's not a route page, it's a fallback component.
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<template>
|
|
8
|
+
<div class="not-found">
|
|
9
|
+
<div class="not-found-code">404</div>
|
|
10
|
+
<h1>Page not found</h1>
|
|
11
|
+
<p>The page you're looking for doesn't exist or has been moved.</p>
|
|
12
|
+
<a href="/" class="btn btn-primary">Go home</a>
|
|
13
|
+
</div>
|
|
14
|
+
</template>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { RouterLink, useRoute } from 'vue-router'
|
|
4
|
+
import { usePageData } from '@netrojs/vono/client'
|
|
5
|
+
import type { BlogPostData } from '../routes'
|
|
6
|
+
|
|
7
|
+
// useRoute() works identically after SSR hydration β no special handling needed
|
|
8
|
+
const route = useRoute()
|
|
9
|
+
const data = usePageData<BlogPostData>()
|
|
10
|
+
|
|
11
|
+
// Computed from loader data β reactive on SPA navigation
|
|
12
|
+
const post = computed(() => data.post)
|
|
13
|
+
const notFound = computed(() => !post.value)
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<template>
|
|
17
|
+
<!-- 404 state β post not in loader data -->
|
|
18
|
+
<div v-if="notFound" class="page">
|
|
19
|
+
<h1>Post not found</h1>
|
|
20
|
+
<p class="lead">No post matched <code>{{ route.params.slug }}</code>.</p>
|
|
21
|
+
<RouterLink to="/blog" class="btn btn-ghost">β Back to Blog</RouterLink>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<!-- Post content -->
|
|
25
|
+
<article v-else class="page">
|
|
26
|
+
<!-- Tags -->
|
|
27
|
+
<div class="post-meta" style="margin-bottom:1rem">
|
|
28
|
+
<RouterLink to="/blog" class="muted">Blog</RouterLink>
|
|
29
|
+
<span class="muted"> / </span>
|
|
30
|
+
<span class="tag" v-for="tag in post!.tags" :key="tag">#{{ tag }}</span>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<h1>{{ post!.title }}</h1>
|
|
34
|
+
|
|
35
|
+
<div class="post-byline">
|
|
36
|
+
<span>By {{ post!.author }}</span>
|
|
37
|
+
<span class="muted">Β·</span>
|
|
38
|
+
<time :datetime="post!.date">{{ post!.date }}</time>
|
|
39
|
+
<span class="muted">Β·</span>
|
|
40
|
+
<span>{{ post!.views.toLocaleString() }} views</span>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<p class="lead">{{ post!.excerpt }}</p>
|
|
44
|
+
|
|
45
|
+
<div class="prose">
|
|
46
|
+
<!-- In a real app: use a markdown renderer e.g. @shikijs/vitepress-twoslash -->
|
|
47
|
+
<p>{{ post!.body }}</p>
|
|
48
|
+
|
|
49
|
+
<blockquote>
|
|
50
|
+
This is a demo post. In production, render markdown here using
|
|
51
|
+
<code>marked</code>, <code>unified</code>, or your preferred pipeline.
|
|
52
|
+
</blockquote>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<div class="post-footer">
|
|
56
|
+
<RouterLink to="/blog" class="btn btn-ghost">β All posts</RouterLink>
|
|
57
|
+
</div>
|
|
58
|
+
</article>
|
|
59
|
+
</template>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed } from 'vue'
|
|
3
|
+
import { RouterLink } from 'vue-router'
|
|
4
|
+
import { usePageData } from '@netrojs/vono/client'
|
|
5
|
+
import type { BlogListData } from '../routes'
|
|
6
|
+
|
|
7
|
+
const data = usePageData<BlogListData>()
|
|
8
|
+
const search = ref('')
|
|
9
|
+
|
|
10
|
+
const filtered = computed(() =>
|
|
11
|
+
search.value.trim() === ''
|
|
12
|
+
? data.posts
|
|
13
|
+
: data.posts.filter(p =>
|
|
14
|
+
p.title.toLowerCase().includes(search.value.toLowerCase()) ||
|
|
15
|
+
p.tags.some(t => t.includes(search.value.toLowerCase()))
|
|
16
|
+
)
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
// All unique tags from all posts
|
|
20
|
+
const allTags = computed(() => [...new Set(data.posts.flatMap(p => p.tags))])
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<div class="page">
|
|
25
|
+
<h1>Blog</h1>
|
|
26
|
+
<p class="lead">Deep dives into Vue 3, Hono, SSR, and the Vono framework.</p>
|
|
27
|
+
|
|
28
|
+
<!-- Search -->
|
|
29
|
+
<div class="search-row">
|
|
30
|
+
<input
|
|
31
|
+
v-model="search"
|
|
32
|
+
class="search-input"
|
|
33
|
+
type="search"
|
|
34
|
+
placeholder="Search posts or tagsβ¦"
|
|
35
|
+
aria-label="Search posts"
|
|
36
|
+
>
|
|
37
|
+
<div class="tag-row">
|
|
38
|
+
<button
|
|
39
|
+
v-for="tag in allTags"
|
|
40
|
+
:key="tag"
|
|
41
|
+
class="tag"
|
|
42
|
+
:class="{ active: search === tag }"
|
|
43
|
+
@click="search = search === tag ? '' : tag"
|
|
44
|
+
>
|
|
45
|
+
#{{ tag }}
|
|
46
|
+
</button>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<!-- Post list -->
|
|
51
|
+
<div class="post-list" v-if="filtered.length">
|
|
52
|
+
<RouterLink
|
|
53
|
+
v-for="post in filtered"
|
|
54
|
+
:key="post.slug"
|
|
55
|
+
:to="`/blog/${post.slug}`"
|
|
56
|
+
class="post-card"
|
|
57
|
+
>
|
|
58
|
+
<div class="post-meta">
|
|
59
|
+
<span>{{ post.author }}</span>
|
|
60
|
+
<span>{{ post.date }}</span>
|
|
61
|
+
<span class="tag" v-for="tag in post.tags" :key="tag">#{{ tag }}</span>
|
|
62
|
+
</div>
|
|
63
|
+
<h2 class="post-card-title">{{ post.title }}</h2>
|
|
64
|
+
<p class="post-card-excerpt">{{ post.excerpt }}</p>
|
|
65
|
+
<span class="post-views">{{ post.views.toLocaleString() }} views</span>
|
|
66
|
+
</RouterLink>
|
|
67
|
+
</div>
|
|
68
|
+
<p v-else class="muted">No posts match "{{ search }}".</p>
|
|
69
|
+
</div>
|
|
70
|
+
</template>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, onMounted, computed } from 'vue'
|
|
3
|
+
import { usePageData } from '@netrojs/vono/client'
|
|
4
|
+
import type { DashboardStats } from '../routes'
|
|
5
|
+
|
|
6
|
+
const data = usePageData<DashboardStats>()
|
|
7
|
+
|
|
8
|
+
// Client-only interactive state β verified working with onMounted after hydration
|
|
9
|
+
const selectedMetric = ref<'users' | 'views'>('views')
|
|
10
|
+
const chartMounted = ref(false)
|
|
11
|
+
|
|
12
|
+
onMounted(() => { chartMounted.value = true })
|
|
13
|
+
|
|
14
|
+
const maxVal = computed(() =>
|
|
15
|
+
Math.max(...data.trend.map(d => d[selectedMetric.value]))
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
const barHeight = (val: number) =>
|
|
19
|
+
chartMounted.value ? `${Math.round((val / maxVal.value) * 100)}%` : '0%'
|
|
20
|
+
|
|
21
|
+
const kpis = computed(() => [
|
|
22
|
+
{ label: 'Total Users', value: data.totalUsers.toLocaleString(), icon: 'π₯' },
|
|
23
|
+
{ label: 'Total Posts', value: data.totalPosts.toLocaleString(), icon: 'π' },
|
|
24
|
+
{ label: 'Total Views', value: data.totalViews.toLocaleString(), icon: 'π' },
|
|
25
|
+
{ label: 'Recent Signups', value: `+${data.recentSignups}`, icon: 'π' },
|
|
26
|
+
])
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<template>
|
|
30
|
+
<!-- KPI cards -->
|
|
31
|
+
<div class="kpi-grid">
|
|
32
|
+
<div v-for="kpi in kpis" :key="kpi.label" class="kpi-card">
|
|
33
|
+
<span class="kpi-icon">{{ kpi.icon }}</span>
|
|
34
|
+
<div>
|
|
35
|
+
<div class="kpi-value">{{ kpi.value }}</div>
|
|
36
|
+
<div class="kpi-label">{{ kpi.label }}</div>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<!-- Interactive sparkline chart β client-only behaviour -->
|
|
42
|
+
<div class="chart-card">
|
|
43
|
+
<div class="chart-header">
|
|
44
|
+
<h2 class="chart-title">7-day trend</h2>
|
|
45
|
+
<div class="chart-tabs">
|
|
46
|
+
<button
|
|
47
|
+
class="chart-tab"
|
|
48
|
+
:class="{ active: selectedMetric === 'views' }"
|
|
49
|
+
@click="selectedMetric = 'views'"
|
|
50
|
+
>Views</button>
|
|
51
|
+
<button
|
|
52
|
+
class="chart-tab"
|
|
53
|
+
:class="{ active: selectedMetric === 'users' }"
|
|
54
|
+
@click="selectedMetric = 'users'"
|
|
55
|
+
>Users</button>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div class="chart-bars">
|
|
60
|
+
<div
|
|
61
|
+
v-for="d in data.trend"
|
|
62
|
+
:key="d.day"
|
|
63
|
+
class="bar-col"
|
|
64
|
+
>
|
|
65
|
+
<div class="bar-value">{{ d[selectedMetric] }}</div>
|
|
66
|
+
<div class="bar-wrap">
|
|
67
|
+
<div
|
|
68
|
+
class="bar"
|
|
69
|
+
:style="{ height: barHeight(d[selectedMetric]) }"
|
|
70
|
+
:title="`${d.day}: ${d[selectedMetric]}`"
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="bar-label">{{ d.day }}</div>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<!-- Hydration proof -->
|
|
79
|
+
<div class="info-banner">
|
|
80
|
+
β
This dashboard data was SSR-rendered on the server, hydrated on the client, and the
|
|
81
|
+
chart is fully interactive β no extra fetch needed on first load.
|
|
82
|
+
The <code>onMounted</code> hook animates the bars after hydration.
|
|
83
|
+
</div>
|
|
84
|
+
</template>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed } from 'vue'
|
|
3
|
+
import { usePageData } from '@netrojs/vono/client'
|
|
4
|
+
import type { Post } from '../routes'
|
|
5
|
+
|
|
6
|
+
interface PostsData { posts: Post[] }
|
|
7
|
+
|
|
8
|
+
const data = usePageData<PostsData>()
|
|
9
|
+
const search = ref('')
|
|
10
|
+
const sorted = ref<'date' | 'views'>('date')
|
|
11
|
+
|
|
12
|
+
const rows = computed(() => {
|
|
13
|
+
let list = [...data.posts]
|
|
14
|
+
if (search.value) {
|
|
15
|
+
list = list.filter(p => p.title.toLowerCase().includes(search.value.toLowerCase()))
|
|
16
|
+
}
|
|
17
|
+
return sorted.value === 'views'
|
|
18
|
+
? list.sort((a, b) => b.views - a.views)
|
|
19
|
+
: list.sort((a, b) => b.date.localeCompare(a.date))
|
|
20
|
+
})
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<div class="dash-section">
|
|
25
|
+
<div class="table-toolbar">
|
|
26
|
+
<input
|
|
27
|
+
v-model="search"
|
|
28
|
+
class="search-input"
|
|
29
|
+
type="search"
|
|
30
|
+
placeholder="Filter postsβ¦"
|
|
31
|
+
>
|
|
32
|
+
<div class="sort-tabs">
|
|
33
|
+
<button class="chart-tab" :class="{ active: sorted === 'date' }" @click="sorted = 'date'">Latest</button>
|
|
34
|
+
<button class="chart-tab" :class="{ active: sorted === 'views' }" @click="sorted = 'views'">Top views</button>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<table class="data-table">
|
|
39
|
+
<thead>
|
|
40
|
+
<tr>
|
|
41
|
+
<th>Title</th>
|
|
42
|
+
<th>Author</th>
|
|
43
|
+
<th>Date</th>
|
|
44
|
+
<th>Tags</th>
|
|
45
|
+
<th style="text-align:right">Views</th>
|
|
46
|
+
</tr>
|
|
47
|
+
</thead>
|
|
48
|
+
<tbody>
|
|
49
|
+
<tr v-for="post in rows" :key="post.id">
|
|
50
|
+
<td><strong>{{ post.title }}</strong></td>
|
|
51
|
+
<td>{{ post.author }}</td>
|
|
52
|
+
<td>{{ post.date }}</td>
|
|
53
|
+
<td>
|
|
54
|
+
<span class="tag" v-for="tag in post.tags" :key="tag">#{{ tag }}</span>
|
|
55
|
+
</td>
|
|
56
|
+
<td style="text-align:right">{{ post.views.toLocaleString() }}</td>
|
|
57
|
+
</tr>
|
|
58
|
+
<tr v-if="rows.length === 0">
|
|
59
|
+
<td colspan="5" class="muted" style="text-align:center;padding:1.5rem">No posts match.</td>
|
|
60
|
+
</tr>
|
|
61
|
+
</tbody>
|
|
62
|
+
</table>
|
|
63
|
+
</div>
|
|
64
|
+
</template>
|