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.
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/app/.claude/skills/ai-annotator/SKILL.md +31 -0
- package/app/app.config.ts +20 -0
- package/app/app.vue +7 -0
- package/app/assets/css/main.css +609 -0
- package/app/components/ContentView.vue +838 -0
- package/app/components/LocalStorageChecklist.vue +372 -0
- package/app/components/StackedColumn.vue +81 -0
- package/app/components/StackedColumns.vue +216 -0
- package/app/composables/useStack.ts +331 -0
- package/app/layouts/default.vue +22 -0
- package/app/pages/[...slug].vue +3 -0
- package/app/types/app-config.d.ts +19 -0
- package/content/index.md +25 -0
- package/content/license.md +62 -0
- package/nuxt.config.ts +76 -0
- package/package.json +55 -0
- package/tailwind.config.js +64 -0
- package/tsconfig.json +18 -0
|
@@ -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,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
|
+
}
|
package/content/index.md
ADDED
|
@@ -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
|
+
}
|