andy-note-nuxt 0.2.0

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.
@@ -0,0 +1,331 @@
1
+ import { computed, ref, nextTick, type Ref } from 'vue'
2
+ import { useRoute, useRouter } from 'vue-router'
3
+
4
+ /**
5
+ * Singleton matchMedia ref — created once per client session.
6
+ * SSR-safe: guarded by import.meta.client so it never touches window on the server.
7
+ */
8
+ let _isMobileRef: Ref<boolean> | null = null
9
+
10
+ function useIsMobile(): Ref<boolean> {
11
+ if (_isMobileRef) return _isMobileRef
12
+ _isMobileRef = ref(false)
13
+ if (import.meta.client) {
14
+ const mq = window.matchMedia('(max-width: 767px)')
15
+ _isMobileRef.value = mq.matches
16
+ mq.addEventListener('change', (e) => {
17
+ if (_isMobileRef) _isMobileRef.value = e.matches
18
+ })
19
+ }
20
+ return _isMobileRef
21
+ }
22
+
23
+ /**
24
+ * Normalize a path string: strip query/hash, ensure leading slash, collapse
25
+ * double slashes, strip trailing slash on non-root paths.
26
+ *
27
+ * Trailing-slash strip matters because static hosts (Cloudflare Pages, Netlify
28
+ * with `pretty_urls`, GitHub Pages with directories) 308-redirect `/foo` to
29
+ * `/foo/`. The browser URL ends in `/`; `useRoute().path` reflects that;
30
+ * `queryCollection().where('path', '=', '/foo/')` returns null because
31
+ * Nuxt Content stores `index.md` paths without the trailing slash.
32
+ * Normalizing here keeps the path equal to the stored shape regardless of
33
+ * how the host rewrote the request URL.
34
+ */
35
+ function normalizePath(path: string): string {
36
+ // Strip query and hash using regex so split()[0] is always defined
37
+ const withoutQuery = path.replace(/[?#].*$/, '')
38
+ // Ensure leading slash
39
+ const withSlash = withoutQuery.startsWith('/') ? withoutQuery : `/${withoutQuery}`
40
+ // Collapse double slashes
41
+ const collapsed = withSlash.replace(/\/\/+/g, '/')
42
+ // Strip trailing slash unless this IS the root path
43
+ return collapsed.length > 1 ? collapsed.replace(/\/$/, '') : collapsed
44
+ }
45
+
46
+ /**
47
+ * Check if an href is external (http, https, mailto, tel, or protocol-relative //).
48
+ */
49
+ function isExternalHref(href: string): boolean {
50
+ return (
51
+ href.startsWith('http://') ||
52
+ href.startsWith('https://') ||
53
+ href.startsWith('mailto:') ||
54
+ href.startsWith('tel:') ||
55
+ href.startsWith('//')
56
+ )
57
+ }
58
+
59
+ /**
60
+ * Find nearest <a> ancestor (inclusive of target) from an event target.
61
+ */
62
+ function findAnchorAncestor(target: EventTarget | null): HTMLAnchorElement | null {
63
+ let el = target as Element | null
64
+ while (el && el.tagName !== 'A') {
65
+ el = el.parentElement
66
+ }
67
+ return el as HTMLAnchorElement | null
68
+ }
69
+
70
+ export function useStack() {
71
+ const route = useRoute()
72
+ const router = useRouter()
73
+ const isMobile = useIsMobile()
74
+
75
+ /** column 1+ paths from query.stack, normalized to string[] */
76
+ const stack = computed<string[]>(() => {
77
+ const raw = route.query.stack
78
+ const arr = raw == null ? [] : Array.isArray(raw) ? raw : [raw]
79
+ return arr.filter((p): p is string => typeof p === 'string' && p.length > 0)
80
+ })
81
+
82
+ /** [route.path, ...stack] — all column paths including column 0.
83
+ * route.path passes through normalizePath so a host-injected trailing
84
+ * slash (e.g. CF Pages 308 `/foo` → `/foo/`) doesn't break content lookup. */
85
+ const fullStack = computed<string[]>(() => [normalizePath(route.path), ...stack.value])
86
+
87
+ /** Currently focused column index — updated by callers (e.g. IntersectionObserver in US-005) */
88
+ const activeIndex = ref<number>(0)
89
+
90
+ /**
91
+ * Scroll the container so column at the given index is fully revealed at its
92
+ * peek-aligned position (sticky-stack natural state).
93
+ *
94
+ * Why a custom calculation instead of element.scrollIntoView():
95
+ * scrollIntoView() uses the element's *current* bounding rect, which for a
96
+ * sticky-active column is the peek-painted position (e.g. col 1 stuck at
97
+ * viewport-x=48, not its natural flex position at 640). Browser solves the
98
+ * "align rect.right with viewport-right" equation against that peek rect,
99
+ * arrives at a scrollLeft where the column is still inside its sticky
100
+ * threshold, sticky re-activates after the scroll, and the column stays
101
+ * stuck at peek — visible only as the original 48px strip. The user
102
+ * observed this as "scroll runs but only half/partial reveal at end".
103
+ *
104
+ * Geometry instead. With cols 0..K-1 sticky-stacked at left edges and col K
105
+ * sitting just past the peek stack:
106
+ * col K viewport-x = K * stack-peek
107
+ * col K natural-pos − scrollLeft = K * stack-peek
108
+ * K * col-width − scrollLeft = K * stack-peek
109
+ * ⇒ scrollLeft = K * (col-width − stack-peek)
110
+ *
111
+ * For the last column, this target exceeds max scroll (N*col-width − vw),
112
+ * so the browser naturally clamps it — leaving the last column right-aligned
113
+ * at viewport-right, which is exactly the active-card layout the rest of the
114
+ * stack already assumes.
115
+ *
116
+ * expectedFullLength is the post-mutation column count. Pass it when the
117
+ * DOM still contains leaving columns whose width has not yet been reclaimed
118
+ * (TransitionGroup leave-fade in progress) — without it the formula would
119
+ * scroll into the about-to-disappear slots' natural positions, leaving
120
+ * scrollLeft in a place the post-fade max can't support, which the browser
121
+ * then snaps back from at transitionend. With it, we clamp to the post-fade
122
+ * max up front so the eventual shrink is invisible.
123
+ */
124
+ function scrollToColumn(index: number, expectedFullLength?: number): void {
125
+ if (!import.meta.client) return
126
+ const container = document.querySelector<HTMLElement>('[data-stacked-columns]')
127
+ if (!container) return
128
+
129
+ activeIndex.value = index
130
+
131
+ const rootStyles = getComputedStyle(document.documentElement)
132
+ const colWidth = parseInt(rootStyles.getPropertyValue('--column-width'), 10) || 640
133
+ const stackPeek = parseInt(rootStyles.getPropertyValue('--stack-peek'), 10) || 48
134
+ let targetScrollLeft = index * (colWidth - stackPeek)
135
+
136
+ if (expectedFullLength !== undefined) {
137
+ const postFadeMax = expectedFullLength * colWidth - container.clientWidth
138
+ if (postFadeMax < targetScrollLeft) targetScrollLeft = Math.max(0, postFadeMax)
139
+ }
140
+
141
+ container.scrollTo({ left: targetScrollLeft, behavior: 'smooth' })
142
+ }
143
+
144
+ /**
145
+ * Leave-transition timing — must stay in sync with the .col-fade-leave-active
146
+ * rules in StackedColumns.vue. Stagger is per-column delay, applied rightmost
147
+ * first; total leave time = LEAVE_DURATION_MS + (leavingCount-1) * LEAVE_STAGGER_MS.
148
+ * Buffer is added on top so Phase B starts after Vue has fully removed the
149
+ * leaving DOM nodes (transitionend → next tick).
150
+ */
151
+ const LEAVE_DURATION_MS = 200
152
+ const LEAVE_STAGGER_MS = 60
153
+ const LEAVE_BUFFER_MS = 30
154
+
155
+ /**
156
+ * Push a new column to the right of fromIndex, or scroll to it if already in the stack.
157
+ *
158
+ * Single atomic router.replace combining trim + push. With key=index on the
159
+ * <TransitionGroup> in StackedColumns.vue, the slot at fromIndex+1 keeps its
160
+ * slot identity across the mutation — its `path` prop swaps and ContentView
161
+ * remounts via :key="path" on the inner element, but the slot itself runs no
162
+ * leave/enter transition. Only slots BEYOND finalFullLength actually unmount,
163
+ * and they get the staggered fade. Smooth scroll fires while leaving slots
164
+ * still occupy width, so scrollWidth stays constant during the fade and the
165
+ * post-fade shrink is invisible (scrollLeft has already settled at the new max).
166
+ *
167
+ * Why this differs from the previous trim → wait → push two-phase:
168
+ * the old code did router.replace(trimmedStack) first, which removed slot
169
+ * fromIndex+1 entirely (TransitionGroup leave-fade), then pushed it back as
170
+ * a fresh slot (enter-fade). For the common case [A,B,C] + click in B → [A,B,X]
171
+ * where finalFullLength == currentFullLength, that path swap should be silent
172
+ * but the two-phase forced an unnecessary leave/enter pair. Worse, between
173
+ * phases scrollWidth shrank by one column, the browser auto-clamped scrollLeft
174
+ * to the smaller max (visible as "B shifts left to right-edge"), then phase B
175
+ * grew scrollWidth back and smooth-scroll moved the layout right again
176
+ * ("shifts back"). The user reported this exact double-shift.
177
+ *
178
+ * All stack mutations use router.replace (no history bloat).
179
+ */
180
+ async function pushColumn(targetPath: string, fromIndex: number): Promise<void> {
181
+ const normalized = normalizePath(targetPath)
182
+ const fullPaths = fullStack.value
183
+
184
+ // No-op: clicked link to the current column
185
+ if (normalized === fullPaths[fromIndex]) return
186
+
187
+ // Already open: scroll to it instead of duplicating
188
+ const existingIndex = fullPaths.indexOf(normalized)
189
+ if (existingIndex >= 0) {
190
+ scrollToColumn(existingIndex)
191
+ return
192
+ }
193
+
194
+ const trimmedStack = stack.value.slice(0, Math.max(fromIndex, 0))
195
+ const finalStack = [...trimmedStack, normalized]
196
+ // Final fullStack length = column 0 (route.path) + trimmedStack + new column.
197
+ const finalFullLength = fromIndex + 2
198
+ // Slots that genuinely unmount. With key=index, slot at fromIndex+1 keeps
199
+ // identity (path swap, no transition); only slots strictly beyond it leave.
200
+ const leavingCount = Math.max(0, fullPaths.length - finalFullLength)
201
+ const { stack: _omit, ...rest } = route.query
202
+
203
+ // Pre-set --max-col-idx so the CSS stagger formula (max - idx) * 60ms
204
+ // resolves correctly when Vue applies leave-active on the leaving slots.
205
+ // Skip when nothing leaves — the var simply isn't read in that case.
206
+ if (leavingCount > 0 && import.meta.client) {
207
+ const container = document.querySelector<HTMLElement>('[data-stacked-columns]')
208
+ container?.style.setProperty('--max-col-idx', String(fullPaths.length - 1))
209
+ }
210
+
211
+ // Single atomic mutation. Slot at fromIndex+1 path-swaps (silent), slots
212
+ // beyond it run the staggered leave-fade. ScrollWidth stays constant
213
+ // throughout the fade because leaving slots still occupy flex width.
214
+ await router.replace({
215
+ path: route.path,
216
+ query: { ...rest, stack: finalStack },
217
+ })
218
+ await nextTick()
219
+
220
+ // Scroll while leaving slots still occupy width. Pass finalFullLength so
221
+ // the helper clamps to the post-fade max instead of overshooting into the
222
+ // about-to-disappear slots' territory — without that clamp, the browser
223
+ // would snap scrollLeft back at transitionend when scrollWidth shrinks.
224
+ scrollToColumn(fromIndex + 1, finalFullLength)
225
+
226
+ if (leavingCount > 0) {
227
+ const totalLeaveMs =
228
+ LEAVE_DURATION_MS + (leavingCount - 1) * LEAVE_STAGGER_MS + LEAVE_BUFFER_MS
229
+ await new Promise(resolve => setTimeout(resolve, totalLeaveMs))
230
+ if (import.meta.client) {
231
+ const container = document.querySelector<HTMLElement>('[data-stacked-columns]')
232
+ container?.style.removeProperty('--max-col-idx')
233
+ }
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Returns true if path is anywhere in the full stack (column 0 included).
239
+ */
240
+ function isInStack(path: string): boolean {
241
+ return fullStack.value.includes(normalizePath(path))
242
+ }
243
+
244
+ /**
245
+ * Click interceptor for column body content links.
246
+ * Prevents default and calls pushColumn for valid internal links.
247
+ */
248
+ function handleStackClick(event: MouseEvent, fromIndex: number): void {
249
+ if (!import.meta.client) return
250
+ if (isMobile.value) return // mobile: let NuxtLink handle navigation natively
251
+
252
+ const anchor = findAnchorAncestor(event.target)
253
+ if (!anchor) return
254
+
255
+ // Let browser handle modifier-key clicks (new tab, etc.)
256
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
257
+
258
+ // Let browser handle _blank target
259
+ if (anchor.target === '_blank') return
260
+
261
+ const href = anchor.getAttribute('href')
262
+ if (!href) return
263
+
264
+ // External links: fall through to browser
265
+ if (isExternalHref(href)) return
266
+
267
+ // Reset zone ancestor: defer to NuxtLink default (router.push clears query.stack)
268
+ if (anchor.closest('[data-stack-reset-zone]')) return
269
+
270
+ // Individual links marked as reset-intent: defer to NuxtLink default
271
+ if (anchor.hasAttribute('data-stack-reset')) return
272
+
273
+ // Hash-only hrefs ("#foo") are ALWAYS in-column anchors regardless of where
274
+ // the column is mounted in the stack. We cannot resolve them against
275
+ // window.location.href to get a column-path comparison: when this column
276
+ // is at fullStack[1+], window.location.href is "/?stack=<col1path>" so the
277
+ // resolved pathname becomes "/" and the comparison below would treat the
278
+ // hash as a navigation to root. Handle hash-only as a dedicated branch
279
+ // first so footnote refs (`<a href="#user-content-fn-1">`), back-refs, TOC
280
+ // anchors, and heading refs all scroll inside this column, not navigate.
281
+ if (href.startsWith('#')) {
282
+ // preventDefault alone is not enough: Nuxt Content's ProseA renders
283
+ // markdown `<a>` as `<NuxtLink>`, whose bubble-phase click handler
284
+ // ignores defaultPrevented and calls router.push() — which resolves
285
+ // a hash-only href against route.path ("/" when this column is in
286
+ // stack mode) and drops query.stack as a side effect, hard-navigating
287
+ // to the home page. stopPropagation() in this capture-phase handler
288
+ // prevents NuxtLink's bubble-phase listener from running at all.
289
+ event.preventDefault()
290
+ event.stopPropagation()
291
+ const targetId = decodeURIComponent(href.slice(1))
292
+ // Scope the lookup to this column so duplicate ids across columns
293
+ // (possible when two columns render the same article) don't scroll
294
+ // the wrong one.
295
+ const columnEl = anchor.closest('[data-column-index]') as HTMLElement | null
296
+ const target = columnEl?.querySelector<HTMLElement>(`[id="${CSS.escape(targetId)}"]`)
297
+ if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' })
298
+ return
299
+ }
300
+
301
+ // Resolve path-relative or absolute hrefs. Base is window.location.href —
302
+ // safe here because we've already handled hash-only above.
303
+ const resolved = new URL(href, window.location.href)
304
+ const pathname = resolved.pathname
305
+
306
+ // "/current-path#section"-style anchor that points back to this column's
307
+ // own path: scroll within this column instead of navigating.
308
+ if (pathname === fullStack.value[fromIndex] && resolved.hash) {
309
+ event.preventDefault()
310
+ const targetId = decodeURIComponent(resolved.hash.slice(1))
311
+ const columnEl = anchor.closest('[data-column-index]') as HTMLElement | null
312
+ const target = columnEl?.querySelector<HTMLElement>(`[id="${CSS.escape(targetId)}"]`)
313
+ if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' })
314
+ return
315
+ }
316
+
317
+ event.preventDefault()
318
+ pushColumn(pathname, fromIndex)
319
+ }
320
+
321
+ return {
322
+ stack,
323
+ fullStack,
324
+ activeIndex,
325
+ isMobile,
326
+ pushColumn,
327
+ isInStack,
328
+ scrollToColumn,
329
+ handleStackClick,
330
+ }
331
+ }
@@ -0,0 +1,22 @@
1
+ <template>
2
+ <div class="h-screen flex flex-col overflow-hidden bg-terminal-bg text-terminal-text">
3
+ <main class="flex-1 min-h-0 overflow-hidden">
4
+ <slot />
5
+ </main>
6
+ <!--
7
+ Toast host. Mounted client-only by vue-sonner's nuxt module so it never
8
+ ships during SSR (toast lifecycle is purely interactive). Positioned
9
+ bottom-right and capped at 3 visible toasts to keep the brutalist surface
10
+ uncluttered — extra toasts queue underneath and rotate in as older ones
11
+ time out. Theme overrides live in `app/assets/css/main.css` under
12
+ `[data-sonner-toaster]`.
13
+ -->
14
+ <ClientOnly>
15
+ <Toaster
16
+ position="bottom-right"
17
+ :visible-toasts="3"
18
+ :duration="2200"
19
+ />
20
+ </ClientOnly>
21
+ </div>
22
+ </template>
@@ -0,0 +1,3 @@
1
+ <template>
2
+ <StackedColumns />
3
+ </template>
@@ -0,0 +1,19 @@
1
+ export {}
2
+
3
+ // AppConfig augmentation. Child layers can re-declare this interface to add
4
+ // their own fields — TypeScript merges declarations across `nuxt/schema`.
5
+ declare module 'nuxt/schema' {
6
+ interface AppConfig {
7
+ site: {
8
+ title: string
9
+ description: string
10
+ tagline: string
11
+ author: string
12
+ themeColor: string
13
+ logo: string
14
+ // Free-form extras child projects can attach without re-declaring the interface.
15
+ [key: string]: unknown
16
+ }
17
+ menu: Array<{ name: string; url: string; weight: number }>
18
+ }
19
+ }
@@ -0,0 +1,25 @@
1
+ ---
2
+ title: "Andy Notes"
3
+ description: "Brutalist-terminal Nuxt Content theme — stacked-column navigation for personal notes and second-brain knowledge bases."
4
+ created: 2026-05-11
5
+ updated: 2026-05-11
6
+ ---
7
+
8
+ # Andy Notes
9
+
10
+ Đây là **theme mặc định** sau khi bạn vừa clone (hoặc extend) repo `andy-note-nuxt`. Layout đang chạy là **stacked-column navigation** — mỗi lần click vào một note, một cột mới push sang phải thay vì điều hướng trang khác. Cách này giữ context của tất cả cấp cha ở bên trái, đọc nhiều note liên quan trong cùng một flow.
11
+
12
+ ## Bắt đầu
13
+
14
+ 1. Viết note đầu tiên bằng cách tạo file markdown bất kỳ trong `content/`. Ví dụ `content/notes/my-first-note.md`.
15
+ 2. Frontmatter cơ bản chỉ cần `title` + `description`. Mọi field khác đều optional (xem `content.config.ts` để biết hết).
16
+ 3. Tạo subfolder để tự động có section group ở landing page. Ví dụ `content/projects/` sẽ xuất hiện như một section.
17
+
18
+ ## Tuỳ biến
19
+
20
+ - **Menu & branding**: sửa `app/app.config.ts` để đổi `site.title`, `site.description`, và mảng `menu`.
21
+ - **Title trang web**: sửa `app.head.title` trong `nuxt.config.ts`.
22
+ - **Màu sắc**: palette terminal nằm trong `tailwind.config.js`. Token chính: `terminal.accent` (#d4ff00 — lime), `terminal.bg` (#2a2a28 — warm dark).
23
+ - **Component**: mọi file trong `app/components/` đều override được bằng cách tạo file cùng tên ở child project.
24
+
25
+ Xem [License](/license) để biết điều khoản sử dụng.
@@ -0,0 +1,62 @@
1
+ ---
2
+ title: "License"
3
+ description: "MIT License — andy-note-nuxt theme và toàn bộ source code."
4
+ created: 2026-05-11
5
+ updated: 2026-05-11
6
+ ---
7
+
8
+ # License
9
+
10
+ Theme này phát hành theo **MIT License** — bạn được toàn quyền dùng cho dự án cá nhân, thương mại, modify, redistribute, sublicense. Chỉ cần giữ lại copyright notice trong các bản phân phối lại.
11
+
12
+ ## Phạm vi
13
+
14
+ License áp dụng cho **source code của theme** (toàn bộ thư mục `app/`, `nuxt.config.ts`, `content.config.ts`, `tailwind.config.js`, configs, scripts).
15
+
16
+ License **không** áp dụng cho **content do bạn viết** trong child project — content thuộc về bạn, bạn quyết định license của riêng nó. Hai layer này độc lập:
17
+
18
+ - **Theme** (`andy-note-nuxt`) — MIT, code do tác giả maintain.
19
+ - **Notes của bạn** — license tuỳ bạn (All Rights Reserved, CC-BY, CC0, v.v.). Khai báo trong README của repo con hoặc trong `content/license.md` của riêng bạn — file đó sẽ override file license mặc định này.
20
+
21
+ ## Override file này
22
+
23
+ Khi bạn extend theme thành child project và muốn dùng license khác, **tạo `content/license.md` ở child project**. Nuxt Layers sẽ ưu tiên file của child, mặc file mặc định ở theme.
24
+
25
+ Ví dụ child project muốn dùng CC-BY-4.0 cho content:
26
+
27
+ ```markdown
28
+ ---
29
+ title: "License"
30
+ ---
31
+
32
+ # License
33
+
34
+ Code: tham chiếu license của theme upstream (MIT).
35
+ Content trong repo này: **CC-BY-4.0**.
36
+ ```
37
+
38
+ ## MIT License — bản đầy đủ
39
+
40
+ ```
41
+ MIT License
42
+
43
+ Copyright (c) 2026 Nguyen Van Duoc
44
+
45
+ Permission is hereby granted, free of charge, to any person obtaining a copy
46
+ of this software and associated documentation files (the "Software"), to deal
47
+ in the Software without restriction, including without limitation the rights
48
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
49
+ copies of the Software, and to permit persons to whom the Software is
50
+ furnished to do so, subject to the following conditions:
51
+
52
+ The above copyright notice and this permission notice shall be included in all
53
+ copies or substantial portions of the Software.
54
+
55
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
56
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
57
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
58
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
59
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
60
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
61
+ SOFTWARE.
62
+ ```
package/nuxt.config.ts ADDED
@@ -0,0 +1,76 @@
1
+ // https://nuxt.com/docs/api/configuration/nuxt-config
2
+ //
3
+ // Base Nuxt config for `andy-note-nuxt` theme. Consumers extend this layer
4
+ // via `extends: ['github:nguyenvanduocit/andy-note-nuxt']` (or local path /
5
+ // npm package) and override `app.head`, `appConfig.site`, `appConfig.menu`
6
+ // in their child project. All values here are sensible defaults.
7
+
8
+ import { createResolver } from '@nuxt/kit'
9
+
10
+ // Resolve paths relative to THIS layer's directory, not the consumer's root.
11
+ // `~/` aliases resolve to the consumer's `srcDir` at runtime, which would
12
+ // (incorrectly) look for theme assets inside the child project. Absolute
13
+ // paths produced here always point back to the layer.
14
+ const { resolve } = createResolver(import.meta.url)
15
+
16
+ export default defineNuxtConfig({
17
+ compatibilityDate: '2025-07-15',
18
+ devtools: { enabled: true },
19
+
20
+ modules: [
21
+ 'vite-plugin-ai-annotator/nuxt',
22
+ '@nuxt/content',
23
+ '@nuxtjs/tailwindcss',
24
+ // Toast notifications. Auto-registers `<Toaster />` (client-only) and a
25
+ // plugin exposing `$toast` / the imported `toast()` helper from `vue-sonner`.
26
+ 'vue-sonner/nuxt',
27
+ ],
28
+
29
+ // Browser-feedback overlay — disable in child by setting `aiAnnotator: false`.
30
+ aiAnnotator: {
31
+ port: 7318,
32
+ verbose: false,
33
+ },
34
+
35
+ tailwindcss: {
36
+ cssPath: resolve('./app/assets/css/main.css'),
37
+ viewer: false,
38
+ },
39
+
40
+ css: [resolve('./app/assets/css/main.css')],
41
+
42
+ content: {
43
+ experimental: {
44
+ sqliteConnector: 'native',
45
+ },
46
+ build: {
47
+ markdown: {
48
+ highlight: false,
49
+ },
50
+ },
51
+ },
52
+
53
+ // Tolerate prerender failures so a single broken markdown link in user content
54
+ // does not abort the entire static build. Child projects can override this.
55
+ nitro: {
56
+ prerender: {
57
+ failOnError: false,
58
+ },
59
+ },
60
+
61
+ app: {
62
+ head: {
63
+ htmlAttrs: { lang: 'en' },
64
+ charset: 'utf-8',
65
+ viewport: 'width=device-width, initial-scale=1',
66
+ title: 'Andy Notes — Stacked-Column Knowledge Base',
67
+ meta: [
68
+ { name: 'description', content: 'A brutalist-terminal Nuxt Content theme for personal notes, guides, and second-brain knowledge bases.' },
69
+ { name: 'theme-color', content: '#d4ff00' },
70
+ ],
71
+ link: [
72
+ { rel: 'icon', type: 'image/png', href: '/images/favicon.png' },
73
+ ],
74
+ },
75
+ },
76
+ })
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "andy-note-nuxt",
3
+ "version": "0.2.0",
4
+ "description": "Brutalist-terminal Nuxt Content theme for personal notes, guides, and second-brain knowledge bases. Use as a Nuxt layer.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/nguyenvanduocit/andy-note-nuxt.git"
10
+ },
11
+ "keywords": [
12
+ "nuxt",
13
+ "nuxt-theme",
14
+ "nuxt-layer",
15
+ "nuxt-content",
16
+ "second-brain",
17
+ "knowledge-base",
18
+ "brutalist",
19
+ "tailwindcss"
20
+ ],
21
+ "files": [
22
+ "app",
23
+ "public",
24
+ "content/index.md",
25
+ "content/license.md",
26
+ "nuxt.config.ts",
27
+ "tailwind.config.js",
28
+ "tsconfig.json"
29
+ ],
30
+ "scripts": {
31
+ "build": "nuxt build",
32
+ "dev": "nuxt dev",
33
+ "generate": "nuxt generate",
34
+ "preview": "nuxt preview",
35
+ "lint": "oxlint app/",
36
+ "postinstall": "nuxt prepare"
37
+ },
38
+ "dependencies": {
39
+ "@floating-ui/vue": "^1.1.11",
40
+ "@fontsource/literata": "^5.2.8",
41
+ "@fontsource/space-grotesk": "^5.2.10",
42
+ "@nuxt/content": "^3.12.0",
43
+ "@nuxtjs/tailwindcss": "^6.14.0",
44
+ "nuxt": "^4.3.1",
45
+ "rehype-raw": "^7.0.0",
46
+ "vue": "^3.5.29",
47
+ "vue-router": "^4.6.4",
48
+ "vue-sonner": "^2.0.9"
49
+ },
50
+ "devDependencies": {
51
+ "oxlint": "^1.65.0",
52
+ "tailwindcss": "^3",
53
+ "vite-plugin-ai-annotator": "^1.14.13"
54
+ }
55
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Brutalist terminal palette — ported from onepercentplus.
3
+ * Warm dark base + coral accent + 4px-offset stamp shadows are the visual signature.
4
+ *
5
+ * Note: kept as `.js` (not `.ts`) because @nuxtjs/tailwindcss's generated
6
+ * postcss.mjs hardcodes `tailwind.config.js` as the import target. A `.ts`
7
+ * file is silently skipped by Vite's resolver and the custom theme is lost.
8
+ *
9
+ * @type {import('tailwindcss').Config}
10
+ */
11
+ export default {
12
+ content: [
13
+ './app/components/**/*.{vue,js,ts}',
14
+ './app/layouts/**/*.vue',
15
+ './app/pages/**/*.vue',
16
+ './app/app.vue',
17
+ ],
18
+ theme: {
19
+ extend: {
20
+ colors: {
21
+ primary: '#ff7b6b',
22
+ terminal: {
23
+ bg: '#2a2a28',
24
+ 'bg-light': '#323330',
25
+ text: '#d5cfc5',
26
+ 'text-secondary': '#c0b8a8',
27
+ 'text-muted': '#a8a298',
28
+ 'text-faint': '#8a857c',
29
+ accent: '#ff7b6b',
30
+ 'accent-hover': '#ff9b8a',
31
+ border: '#474541',
32
+ 'border-strong': '#5a5854',
33
+ surface: {
34
+ 0: '#2e2f2c',
35
+ 1: '#3b3c39',
36
+ 2: '#444541',
37
+ elevated: '#4d4e4b',
38
+ },
39
+ },
40
+ },
41
+ fontFamily: {
42
+ display: ['Space Grotesk', '-apple-system', 'BlinkMacSystemFont', 'sans-serif'],
43
+ prose: ['Literata', 'Georgia', 'serif'],
44
+ mono: ['SF Mono', 'Monaco', 'Consolas', 'ui-monospace', 'monospace'],
45
+ },
46
+ // Stamp shadows — flat 0-blur offsets are the brutalist signature.
47
+ // Use `shadow-stamp` on cards/buttons; `shadow-stamp-accent` for hover/active.
48
+ boxShadow: {
49
+ stamp: '4px 4px 0px 0px #474541',
50
+ 'stamp-sm': '2px 2px 0px 0px #474541',
51
+ 'stamp-lg': '6px 6px 0px 0px #474541',
52
+ 'stamp-accent': '4px 4px 0px 0px #ff7b6b',
53
+ 'stamp-accent-sm': '2px 2px 0px 0px #ff7b6b',
54
+ },
55
+ borderWidth: {
56
+ 3: '3px',
57
+ },
58
+ letterSpacing: {
59
+ 'widest-lg': '0.15em',
60
+ },
61
+ },
62
+ },
63
+ plugins: [],
64
+ }