Mercury/docs/superpowers/plans/2026-07-15-nychthemeron-shadcn-migration.md
Matthew L McPeak dd700d02ad
All checks were successful
ci / build-ui (push) Successful in 14s
ci / test (push) Successful in 3m50s
ci / publish (push) Successful in 3m9s
Inital Commit
2026-07-16 12:36:14 -04:00

1986 lines
73 KiB
Markdown

# Nychthemeron shadcn-vue Migration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Port Mercury UI from the old PrimeVue-based `@nychthemeron/library@0.0.1` to the new shadcn-vue-based `@nychthemeron/library@0.0.1` (same version, contents replaced on the registry — already installed and lockfile-consistent as of this plan).
**Architecture:** Two phases. Phase 1 fixes build/plugin wiring so the app compiles and runs against the new library (no view changes yet — the app will render unstyled/broken between Phase 1 and the first view task, which is expected). Phase 2 migrates each view file natively to the new component API, one file at a time, each independently testable via type-check + manual browser verification.
**Tech Stack:** Vue 3.5, Vite 5, TypeScript, `@nychthemeron/library` (shadcn-vue + Tailwind v4 + CVA under the hood, consumed only through its `Nych*` global components — this app never imports shadcn-vue/Tailwind/CVA directly).
> **Amendment (during Task 1 execution):** the library moved again, from
> `0.0.1` to `0.0.3`, while this plan was being executed (maintainer-owned
> registry, confirmed intentional). Two things changed that affect Task 1
> only — Phase 2's component-level API (Button/Dialog/Select/Alert props)
> is unaffected:
> - The `"./theme"` export subpath was dropped. `main.ts` must import
> `@nychthemeron/library/style` instead of `.../theme` — same
> `tailwind.css` content, different subpath.
> - The library's `"."` export declares a `"development"` condition
> pointing at `./src/index.ts`, which isn't in the published files
> (`dist/`, `src/assets/`, `src/components/` only). Vite's dev server
> picks that condition first and fails to resolve the bare
> `@nychthemeron/library` specifier. `vite.config.ts` needs a
> `resolve.alias` entry anchored to the exact bare specifier (regex
> `/^@nychthemeron\/library$/`, not a plain string — plain-string
> aliases prefix-match and break the `/style`/`/components` subpath
> imports) pointing straight at `./node_modules/@nychthemeron/library/dist/index.js`.
>
> Both fixes already landed in commit `86a715e` on `main`. Tasks 2+ can
> assume `bun run dev` and `bun run build` both work cleanly at the
> config/plugin level — nothing in this amendment affects Phase 2 view
> migrations.
## Global Constraints
- Every `Nych*` tag is a **globally registered component** via `app.use(createNychthemeron())` in `src/main.ts` — view files never `import` them individually. Don't add per-file imports.
- Button `variant` values: `primary` (default), `secondary`, `info`, `success`, `warning`, `danger`. `size` values: `default`, `sm`, `lg`, `icon`.
- Alert `variant` values: `info` (default), `success`, `warning`, `danger`, `secondary`.
- There is no `fluid` prop anywhere in the new library — full-width is always `class="w-full"`.
- There is no `label` prop on `NychButton` — button text is the default slot.
- `NychDialog` is `v-model:open` (boolean), not `v-model:visible`. Header text goes in `<NychDialogHeader><NychDialogTitle>...</NychDialogTitle></NychDialogHeader>`, not a `header` prop. Width goes on `NychDialogContent`'s `class`, not a `style` attribute on `NychDialog`.
- `NychSelect` takes a plain string `v-model` and `NychSelectItem` children — there is no `:options`/`optionLabel`/`optionValue` array API, and **values must be strings** (no boolean/number option values).
- No test framework exists in this repo (`package.json` has no `test` script). Verification is `bun run build` (runs `vue-tsc --noEmit && vite build`) for type-correctness, plus manual browser verification for visual/behavioral correctness — do not add a test framework as part of this plan (out of scope, not requested).
- Run all commands from `/home/mcpeakml/code/rust/Mercury/ui`.
---
### Task 1: Foundation — build and plugin wiring
**Files:**
- Modify: `src/main.ts`
- Modify: `vite.config.ts`
- Modify: `package.json`
- Modify: `src/assets/main.css`
**Interfaces:**
- Produces: a working `bun run dev` server with the new `@nychthemeron/library` theme CSS loaded and no PrimeVue references left anywhere in the app. Every later task depends on this.
- [ ] **Step 1: Remove PrimeVue from `src/main.ts`**
Current content:
```ts
import { createApp } from "vue";
import '@nychthemeron/library/theme'
import './assets/main.css'
import './stores/theme'
import { createPinia } from "pinia";
import PrimeVue from "@primevue/core/config";
import { createNychthemeron } from "@nychthemeron/library";
import App from "./App.vue";
import router from "./router";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.use(PrimeVue, { unstyled: true });
app.use(createNychthemeron());
app.mount("#app");
```
Replace with:
```ts
import { createApp } from "vue";
import '@nychthemeron/library/theme'
import './assets/main.css'
import './stores/theme'
import { createPinia } from "pinia";
import { createNychthemeron } from "@nychthemeron/library";
import App from "./App.vue";
import router from "./router";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.use(createNychthemeron());
app.mount("#app");
```
- [ ] **Step 2: Add the Tailwind v4 Vite plugin and drop the PrimeVue chunk in `vite.config.ts`**
Current content:
```ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
"/api": "http://localhost:3000",
"/auth": "http://localhost:3000",
"/admin": "http://localhost:3000",
},
},
build: {
outDir: "dist",
rollupOptions: {
output: {
manualChunks: {
vue: ["vue", "vue-router", "pinia"],
primevue: ["@primevue/core"],
nychthemeron: ["@nychthemeron/library"],
},
entryFileNames: "js/[name]-[hash].js",
chunkFileNames: "js/[name]-[hash].js",
assetFileNames: "assets/[name]-[hash][extname]",
},
},
},
});
```
Replace with:
```ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [vue(), tailwindcss()],
server: {
proxy: {
"/api": "http://localhost:3000",
"/auth": "http://localhost:3000",
"/admin": "http://localhost:3000",
},
},
build: {
outDir: "dist",
rollupOptions: {
output: {
manualChunks: {
vue: ["vue", "vue-router", "pinia"],
nychthemeron: ["@nychthemeron/library"],
},
entryFileNames: "js/[name]-[hash].js",
chunkFileNames: "js/[name]-[hash].js",
assetFileNames: "assets/[name]-[hash][extname]",
},
},
},
});
```
- [ ] **Step 3: Add direct devDependencies and bump the `vue` peer range in `package.json`**
Current content:
```json
{
"name": "mercury-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@nychthemeron/library": "^0.0.1",
"pinia": "^2.1.0",
"vue": "^3.4.0",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vue-tsc": "^2.0.0"
}
}
```
Replace with:
```json
{
"name": "mercury-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@nychthemeron/library": "^0.0.1",
"pinia": "^2.1.0",
"vue": "^3.5.0",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.2",
"@vitejs/plugin-vue": "^5.0.0",
"tailwindcss": "^4.3.2",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vue-tsc": "^2.0.0"
}
}
```
- [ ] **Step 4: Run `bun install` to sync the lockfile with the `package.json` edits**
Run: `bun install`
Expected: exits 0, no version conflicts reported. (`@tailwindcss/vite`, `tailwindcss`, and `vue@3.5.x` are already present in `bun.lock` as transitive dependencies of `@nychthemeron/library`, so this should only re-link them as direct deps, not download anything new.)
- [ ] **Step 5: Remove the dead unstyled-PrimeVue overrides from `src/assets/main.css`**
Find:
```css
.nych-dialog {
min-width: 500px;
}
[class^="nych-button"] {
box-shadow: unset !important;
justify-content: center !important;
}
[class^="nych-button"]:hover {
box-shadow: 0 0 5px var(--text-high);
}
.nych-loading-icon .wreath {
transform-box: fill-box;
transform-origin: center;
}
```
Replace with:
```css
.nych-loading-icon .wreath {
transform-box: fill-box;
transform-origin: center;
}
```
(`.nych-dialog` and `[class^="nych-button"]` matched classnames the old unstyled-PrimeVue passthrough emitted. The new `NychDialog`/`NychButton` don't emit any classname starting with `nych-dialog`/`nych-button` — they use Tailwind utility classes and `data-slot` attributes instead — so these two rules can no longer match anything. `.nych-loading-icon .wreath` stays: `NychLoadingIcon`'s markup is unchanged in the new library.)
- [ ] **Step 6: Verify the app boots**
Run: `bun run dev` (leave running in the background, or run and check output then stop it)
Expected: Vite starts with no errors. Open `http://localhost:5173` (or whatever port Vite prints) in a browser — the login page should load without console errors. It will look broken/unstyled in places until Phase 2 migrates `Login.vue` and the other views (raw `Nych*` global components render, but with old PrimeVue-style props like `label`/`fluid`/`header` that the new components silently ignore, so buttons show no text and dialogs show no visible chrome) — that's expected at this point, not a regression to fix here.
- [ ] **Step 7: Commit**
```bash
git add src/main.ts vite.config.ts package.json bun.lock src/assets/main.css
git commit -m "Wire Mercury UI build to the shadcn-vue nychthemeron library"
```
---
### Task 2: Migrate `src/views/Login.vue`
**Files:**
- Modify: `src/views/Login.vue`
**Interfaces:**
- Consumes: `NychInput` (rename of `NychInputText`), `NychButton` — both globally registered per Task 1.
- [ ] **Step 1: Update the template**
Find:
```html
<div class="login-field">
<label>Username</label>
<NychInputText
v-model="username"
placeholder="username"
fluid
autocomplete="username"
/>
</div>
<div class="login-field">
<label>Password</label>
<NychInputText
v-model="password"
type="password"
placeholder="••••••••"
fluid
autocomplete="current-password"
/>
</div>
<p v-if="error" class="login-error">{{ error }}</p>
<NychButton type="submit" :loading="loading" label="Sign In" fluid />
```
Replace with:
```html
<div class="login-field">
<label>Username</label>
<NychInput
v-model="username"
placeholder="username"
class="w-full"
autocomplete="username"
/>
</div>
<div class="login-field">
<label>Password</label>
<NychInput
v-model="password"
type="password"
placeholder="••••••••"
class="w-full"
autocomplete="current-password"
/>
</div>
<p v-if="error" class="login-error">{{ error }}</p>
<NychButton type="submit" :loading="loading" class="w-full">Sign In</NychButton>
```
- [ ] **Step 2: Update the leftover `<style>` selector**
Find:
```css
.nych-button-primary {
margin-top: 2px;
}
```
Replace with:
```css
[data-slot="button"] {
margin-top: 2px;
}
```
(The new `NychButton` root element carries `data-slot="button"` instead of a `nych-button-primary` classname — this preserves the same 2px top margin on the sign-in button.)
- [ ] **Step 3: Type-check**
Run: `bun run build`
Expected: no TypeScript errors referencing `Login.vue`. (The build may still fail on other unmigrated views — only check that `Login.vue` isn't in the error output.)
- [ ] **Step 4: Manual browser check**
Run `bun run dev`, open the login page. Verify: username/password fields render full-width with visible borders, typing works, submitting with empty fields still hits the API (existing validation is server-side), the "Sign In" button shows text and a loading spinner while `loading` is true, and a failed login shows the red error message.
- [ ] **Step 5: Commit**
```bash
git add src/views/Login.vue
git commit -m "Migrate Login.vue to the shadcn-vue nychthemeron components"
```
---
### Task 3: Restyle `src/views/admin/MethodSelect.vue`
This is a bespoke local multi-select component (not part of the library) that styled itself entirely off classnames the *old* `nychthemeron.css` defined (`.nych-select`, `.nych-select-label`, `.nych-select-dropdown`, `.nych-select-dropdownIcon`, `.nych-select-overlay`, `.nych-select-list`, `.nych-select-option`). The new theme CSS doesn't define those classnames at all, so without this fix `MethodSelect` renders as an unstyled native button/list as soon as Task 1 lands. It stays a bespoke component — the library's `NychSelect` is single-value only and has no multi-select equivalent, so there's nothing to swap it for.
**Files:**
- Modify: `src/views/admin/MethodSelect.vue`
- [ ] **Step 1: Add the missing styles into the component's existing global `<style>` block**
Find:
```css
<style>
/* Must be global — teleported content renders outside this component's scoped tree */
.ms-trigger {
display: flex;
width: 100%;
min-width: unset;
text-align: left;
font-family: var(--font-sans);
font-size: 14px;
cursor: pointer;
}
.ms-backdrop {
position: fixed;
inset: 0;
z-index: 9998;
}
.ms-panel {
z-index: 9999;
}
.ms-chevron {
transition: transform 0.15s ease;
}
.ms-chevron-open {
transform: rotate(180deg);
}
</style>
```
Replace with:
```css
<style>
/* Must be global — teleported content renders outside this component's scoped tree.
These .nych-select-* rules used to come from the library's own CSS; the new
shadcn-vue build doesn't emit them (its Select uses Tailwind utility classes
instead), so this component now carries its own copy to keep the same look. */
.nych-select {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 13rem;
font-family: var(--font-sans);
padding: 8px 11px;
color: var(--text-body);
background-color: var(--surface-1);
border: 1px solid var(--border);
border-radius: 5px;
cursor: pointer;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.nych-select:hover {
border-color: var(--border-hi);
}
.nych-select-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nych-select-label[data-p='placeholder'] {
color: var(--text-dim);
}
.nych-select-dropdown {
display: flex;
align-items: center;
color: var(--text-muted);
}
.nych-select-dropdownIcon {
width: 14px;
height: 14px;
}
.nych-select-overlay {
font-family: var(--font-sans);
margin-top: 4px;
background-color: var(--surface-2);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.nych-select-list {
list-style: none;
margin: 0;
padding: 4px;
max-height: 14rem;
overflow-y: auto;
}
.nych-select-option {
padding: 8px 11px;
border-radius: 4px;
color: var(--text-body);
cursor: pointer;
transition: background-color 0.15s ease;
}
.nych-select-option:hover {
background-color: var(--surface-3);
}
.nych-select-option[data-p-selected='true'] {
background-color: var(--primary);
color: var(--primary-fg);
}
/* Must be global — teleported content renders outside this component's scoped tree */
.ms-trigger {
display: flex;
width: 100%;
min-width: unset;
text-align: left;
font-family: var(--font-sans);
font-size: 14px;
cursor: pointer;
}
.ms-backdrop {
position: fixed;
inset: 0;
z-index: 9998;
}
.ms-panel {
z-index: 9999;
}
.ms-chevron {
transition: transform 0.15s ease;
}
.ms-chevron-open {
transform: rotate(180deg);
}
</style>
```
(Note: the old CSS also had `.nych-select[data-p~='focus']` / `.nych-select[data-p~='disabled']` / `.nych-select-option[data-p-focused='true']` rules — omitted here because this component's template never sets those `data-p` attributes, so they were always dead code for this usage.)
- [ ] **Step 2: Type-check**
Run: `bun run build`
Expected: no TypeScript errors referencing `MethodSelect.vue` (this task is CSS-only, no template/script changes, so this should already pass).
- [ ] **Step 3: Manual browser check**
This component is only used from `Blacklist.vue`, which hasn't been migrated yet at this point in the plan — skip the browser check here and verify it as part of Task 7 (`Blacklist.vue`) instead, where it's actually reachable in the running app.
- [ ] **Step 4: Commit**
```bash
git add src/views/admin/MethodSelect.vue
git commit -m "Restyle MethodSelect.vue for the new theme CSS"
```
---
### Task 4: Migrate `src/views/admin/Cache.vue`
**Files:**
- Modify: `src/views/admin/Cache.vue`
- [ ] **Step 1: Update the template**
Find:
```html
<NychButton @click="load" label="Refresh" />
<NychButton severity="danger" @click="flushCache" label="Flush Cache" />
```
Replace with:
```html
<NychButton @click="load">Refresh</NychButton>
<NychButton variant="danger" @click="flushCache">Flush Cache</NychButton>
```
- [ ] **Step 2: Type-check**
Run: `bun run build`
Expected: no errors referencing `Cache.vue`.
- [ ] **Step 3: Manual browser check**
Navigate to the Query Cache admin page. Verify: "Refresh" and "Flush Cache" buttons show their text, Refresh reloads stats, Flush Cache prompts a confirm dialog and clears stats on confirm.
- [ ] **Step 4: Commit**
```bash
git add src/views/admin/Cache.vue
git commit -m "Migrate Cache.vue to the shadcn-vue nychthemeron components"
```
---
### Task 5: Migrate `src/views/admin/Cors.vue`
**Files:**
- Modify: `src/views/admin/Cors.vue`
- [ ] **Step 1: Update the template**
Find:
```html
<NychButton @click="showCreate = true" label="+ Add Origin" />
```
Replace with:
```html
<NychButton @click="showCreate = true">+ Add Origin</NychButton>
```
Find:
```html
<NychButton size="small" severity="danger" @click="deleteOrigin(o.id)" label="Delete" />
```
Replace with:
```html
<NychButton size="sm" variant="danger" @click="deleteOrigin(o.id)">Delete</NychButton>
```
Find:
```html
<NychDialog v-model:visible="showCreate" header="Add CORS Origin" :modal="true" :draggable="false" style="width: min(480px, 95vw)">
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>Origin</label>
<NychInputText v-model="form.origin" placeholder="https://app.example.com" fluid />
<p class="hint">Use <code>*</code> to allow all origins (permissive mode).</p>
</div>
<NychButton type="submit" fluid label="Add Origin" :disabled="!form.origin || loading" />
</form>
</NychDialog>
```
Replace with:
```html
<NychDialog v-model:open="showCreate">
<NychDialogContent class="w-[min(480px,95vw)]">
<NychDialogHeader><NychDialogTitle>Add CORS Origin</NychDialogTitle></NychDialogHeader>
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>Origin</label>
<NychInput v-model="form.origin" placeholder="https://app.example.com" class="w-full" />
<p class="hint">Use <code>*</code> to allow all origins (permissive mode).</p>
</div>
<NychButton type="submit" class="w-full" :disabled="!form.origin || loading">Add Origin</NychButton>
</form>
</NychDialogContent>
</NychDialog>
```
- [ ] **Step 2: Type-check**
Run: `bun run build`
Expected: no errors referencing `Cors.vue`.
- [ ] **Step 3: Manual browser check**
Navigate to CORS Origins. Verify: "+ Add Origin" opens a dialog titled "Add CORS Origin" with a visible border/backdrop, the input accepts text, "Add Origin" is disabled until the field is non-empty, submitting adds a row and closes the dialog, and the per-row "Delete" button removes a row after confirm.
- [ ] **Step 4: Commit**
```bash
git add src/views/admin/Cors.vue
git commit -m "Migrate Cors.vue to the shadcn-vue nychthemeron components"
```
---
### Task 6: Migrate `src/views/admin/Queries.vue`
**Files:**
- Modify: `src/views/admin/Queries.vue`
- [ ] **Step 1: Update the header button and table action buttons**
Find:
```html
<NychButton @click="showCreate = true" label="+ New Query" />
```
Replace with:
```html
<NychButton @click="showCreate = true">+ New Query</NychButton>
```
Find:
```html
<NychButton size="small" @click="openEdit(q)" label="Edit" />
<NychButton size="small" severity="danger" @click="deleteQuery(q)" label="Delete" />
```
Replace with:
```html
<NychButton size="sm" @click="openEdit(q)">Edit</NychButton>
<NychButton size="sm" variant="danger" @click="deleteQuery(q)">Delete</NychButton>
```
- [ ] **Step 2: Update the create dialog**
Find:
```html
<NychDialog v-model:visible="showCreate" header="New Query" :modal="true" :draggable="false" style="width: min(800px, 95vw)">
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>Identifier</label>
<NychInputText v-model="form.identifier" placeholder="e.g. get-user-orders" fluid />
</div>
<div class="field">
<label>Description <span class="optional">(optional)</span></label>
<NychInputText v-model="form.description" placeholder="Brief description of what this query does" fluid />
</div>
<div class="field">
<label>SQL Template</label>
<NychTextarea v-model="form.sql_template" placeholder="SELECT * FROM orders WHERE user_id = :user_id" :rows="12" fluid />
<p class="hint">Use <code>:param_name</code> for named bind parameters.</p>
</div>
<NychButton type="submit" fluid label="Save Query" />
</form>
</NychDialog>
```
Replace with:
```html
<NychDialog v-model:open="showCreate">
<NychDialogContent class="w-[min(800px,95vw)]">
<NychDialogHeader><NychDialogTitle>New Query</NychDialogTitle></NychDialogHeader>
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>Identifier</label>
<NychInput v-model="form.identifier" placeholder="e.g. get-user-orders" class="w-full" />
</div>
<div class="field">
<label>Description <span class="optional">(optional)</span></label>
<NychInput v-model="form.description" placeholder="Brief description of what this query does" class="w-full" />
</div>
<div class="field">
<label>SQL Template</label>
<NychTextarea v-model="form.sql_template" placeholder="SELECT * FROM orders WHERE user_id = :user_id" :rows="12" class="w-full" />
<p class="hint">Use <code>:param_name</code> for named bind parameters.</p>
</div>
<NychButton type="submit" class="w-full">Save Query</NychButton>
</form>
</NychDialogContent>
</NychDialog>
```
- [ ] **Step 3: Update the edit dialog**
Find:
```html
<NychDialog v-model:visible="showEdit" :header="`Edit — ${editIdentifier}`" :modal="true" :draggable="false" style="width: min(800px, 95vw)">
<form @submit.prevent="submitEdit" class="dialog-form">
<div class="field">
<label>SQL Template</label>
<NychTextarea v-model="editForm.sql_template" :rows="12" fluid />
<p class="hint">Use <code>:param_name</code> for named bind parameters.</p>
</div>
<div class="field">
<label>Description <span class="optional">(optional)</span></label>
<NychInputText v-model="editForm.description" placeholder="Brief description" fluid />
</div>
<NychButton type="submit" fluid label="Update Query" />
</form>
</NychDialog>
```
Replace with:
```html
<NychDialog v-model:open="showEdit">
<NychDialogContent class="w-[min(800px,95vw)]">
<NychDialogHeader><NychDialogTitle>Edit — {{ editIdentifier }}</NychDialogTitle></NychDialogHeader>
<form @submit.prevent="submitEdit" class="dialog-form">
<div class="field">
<label>SQL Template</label>
<NychTextarea v-model="editForm.sql_template" :rows="12" class="w-full" />
<p class="hint">Use <code>:param_name</code> for named bind parameters.</p>
</div>
<div class="field">
<label>Description <span class="optional">(optional)</span></label>
<NychInput v-model="editForm.description" placeholder="Brief description" class="w-full" />
</div>
<NychButton type="submit" class="w-full">Update Query</NychButton>
</form>
</NychDialogContent>
</NychDialog>
```
- [ ] **Step 4: Type-check**
Run: `bun run build`
Expected: no errors referencing `Queries.vue`.
- [ ] **Step 5: Manual browser check**
Navigate to Query Registry. Verify: "+ New Query" opens a dialog, the SQL textarea is 12 rows tall and full width, saving adds a row; per-row "Edit" opens a dialog titled "Edit — &lt;identifier&gt;" pre-filled with the SQL and description, saving updates the row; "Delete" removes a row after confirm.
- [ ] **Step 6: Commit**
```bash
git add src/views/admin/Queries.vue
git commit -m "Migrate Queries.vue to the shadcn-vue nychthemeron components"
```
---
### Task 7: Migrate `src/views/admin/Blacklist.vue`
This file has the one boolean-valued `NychSelect` in the app (`Status: Active/Disabled`) — the new `NychSelect` only supports string `v-model`/`value`, so `editForm.active` needs to become a string internally, converted back to boolean at the API-call boundary.
**Files:**
- Modify: `src/views/admin/Blacklist.vue`
- [ ] **Step 1: Update the header button and table action buttons**
Find:
```html
<NychButton @click="showCreate = true" label="+ Add Pattern" />
```
Replace with:
```html
<NychButton @click="showCreate = true">+ Add Pattern</NychButton>
```
Find:
```html
<NychButton size="small" @click="openEdit(e)" label="Edit" />
<NychButton size="small" severity="danger" @click="deleteEntry(e.id)" label="Delete" />
```
Replace with:
```html
<NychButton size="sm" @click="openEdit(e)">Edit</NychButton>
<NychButton size="sm" variant="danger" @click="deleteEntry(e.id)">Delete</NychButton>
```
- [ ] **Step 2: Update the edit dialog**
Find:
```html
<NychDialog v-model:visible="showEdit" header="Edit Blacklist Entry" :modal="true" :draggable="false" style="width: min(640px, 95vw)">
<form @submit.prevent="submitEdit" class="dialog-form">
<div class="field">
<label>Pattern</label>
<NychInputText v-model="editForm.pattern" fluid />
</div>
<div class="field">
<label>HTTP Methods <span class="optional">(none = all methods)</span></label>
<MethodSelect v-model="editForm.methods" :options="HTTP_METHODS" placeholder="All methods" />
</div>
<div class="field">
<label>Reason <span class="optional">(optional)</span></label>
<NychInputText v-model="editForm.reason" fluid />
</div>
<div class="field">
<label>Bypass permission mask <span class="optional">(optional)</span></label>
<NychInputText v-model="editForm.bypass_mask" placeholder="32" fluid />
</div>
<div class="field">
<label>Status</label>
<NychSelect v-model="editForm.active" :options="[{ label: 'Active', value: true }, { label: 'Disabled', value: false }]" optionLabel="label" optionValue="value" fluid />
</div>
<NychButton type="submit" fluid label="Save Changes" />
</form>
</NychDialog>
```
Replace with:
```html
<NychDialog v-model:open="showEdit">
<NychDialogContent class="w-[min(640px,95vw)]">
<NychDialogHeader><NychDialogTitle>Edit Blacklist Entry</NychDialogTitle></NychDialogHeader>
<form @submit.prevent="submitEdit" class="dialog-form">
<div class="field">
<label>Pattern</label>
<NychInput v-model="editForm.pattern" class="w-full" />
</div>
<div class="field">
<label>HTTP Methods <span class="optional">(none = all methods)</span></label>
<MethodSelect v-model="editForm.methods" :options="HTTP_METHODS" placeholder="All methods" />
</div>
<div class="field">
<label>Reason <span class="optional">(optional)</span></label>
<NychInput v-model="editForm.reason" class="w-full" />
</div>
<div class="field">
<label>Bypass permission mask <span class="optional">(optional)</span></label>
<NychInput v-model="editForm.bypass_mask" placeholder="32" class="w-full" />
</div>
<div class="field">
<label>Status</label>
<NychSelect v-model="editForm.active" class="w-full">
<NychSelectTrigger class="w-full"><NychSelectValue /></NychSelectTrigger>
<NychSelectContent>
<NychSelectItem value="true">Active</NychSelectItem>
<NychSelectItem value="false">Disabled</NychSelectItem>
</NychSelectContent>
</NychSelect>
</div>
<NychButton type="submit" class="w-full">Save Changes</NychButton>
</form>
</NychDialogContent>
</NychDialog>
```
- [ ] **Step 3: Update the create dialog**
Find:
```html
<NychDialog v-model:visible="showCreate" header="New Blacklist Entry" :modal="true" :draggable="false" style="width: min(640px, 95vw)">
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>Pattern</label>
<NychInputText v-model="form.pattern" placeholder="/api/sensitive/**" fluid />
<p class="hint">Use <code>*</code> for one segment, <code>**</code> for any depth.</p>
</div>
<div class="field">
<label>HTTP Methods <span class="optional">(none = all methods)</span></label>
<MethodSelect v-model="form.methods" :options="HTTP_METHODS" placeholder="All methods" />
</div>
<div class="field">
<label>Reason <span class="optional">(optional)</span></label>
<NychInputText v-model="form.reason" placeholder="Why is this route blocked?" fluid />
</div>
<div class="field">
<label>Bypass permission mask <span class="optional">(optional)</span></label>
<NychInputText v-model="form.bypass_mask" placeholder="32" fluid />
<p class="hint">Permission bit that allows callers to bypass this rule. Leave blank to block everyone.</p>
</div>
<NychButton type="submit" fluid label="Add to Blacklist" />
</form>
</NychDialog>
```
Replace with:
```html
<NychDialog v-model:open="showCreate">
<NychDialogContent class="w-[min(640px,95vw)]">
<NychDialogHeader><NychDialogTitle>New Blacklist Entry</NychDialogTitle></NychDialogHeader>
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>Pattern</label>
<NychInput v-model="form.pattern" placeholder="/api/sensitive/**" class="w-full" />
<p class="hint">Use <code>*</code> for one segment, <code>**</code> for any depth.</p>
</div>
<div class="field">
<label>HTTP Methods <span class="optional">(none = all methods)</span></label>
<MethodSelect v-model="form.methods" :options="HTTP_METHODS" placeholder="All methods" />
</div>
<div class="field">
<label>Reason <span class="optional">(optional)</span></label>
<NychInput v-model="form.reason" placeholder="Why is this route blocked?" class="w-full" />
</div>
<div class="field">
<label>Bypass permission mask <span class="optional">(optional)</span></label>
<NychInput v-model="form.bypass_mask" placeholder="32" class="w-full" />
<p class="hint">Permission bit that allows callers to bypass this rule. Leave blank to block everyone.</p>
</div>
<NychButton type="submit" class="w-full">Add to Blacklist</NychButton>
</form>
</NychDialogContent>
</NychDialog>
```
- [ ] **Step 4: Convert `editForm.active` from boolean to string in the script**
Find:
```ts
const editForm = ref({ pattern: '', methods: [] as string[], reason: '', bypass_mask: '', active: true })
```
Replace with:
```ts
const editForm = ref({ pattern: '', methods: [] as string[], reason: '', bypass_mask: '', active: 'true' })
```
Find:
```ts
function openEdit(e: any) {
editId.value = e.id
editForm.value = {
pattern: e.pattern,
methods: stringToMethods(e.method),
reason: e.reason ?? '',
bypass_mask: e.bypass_mask ?? '',
active: e.active,
}
showEdit.value = true
}
async function submitEdit() {
await fetch(`/api/blacklist/${editId.value}`, {
method: 'PUT',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({
pattern: editForm.value.pattern,
method: methodsToString(editForm.value.methods),
reason: editForm.value.reason || null,
bypass_mask: editForm.value.bypass_mask || null,
active: editForm.value.active,
}),
})
showEdit.value = false
load()
}
```
Replace with:
```ts
function openEdit(e: any) {
editId.value = e.id
editForm.value = {
pattern: e.pattern,
methods: stringToMethods(e.method),
reason: e.reason ?? '',
bypass_mask: e.bypass_mask ?? '',
active: String(e.active),
}
showEdit.value = true
}
async function submitEdit() {
await fetch(`/api/blacklist/${editId.value}`, {
method: 'PUT',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({
pattern: editForm.value.pattern,
method: methodsToString(editForm.value.methods),
reason: editForm.value.reason || null,
bypass_mask: editForm.value.bypass_mask || null,
active: editForm.value.active === 'true',
}),
})
showEdit.value = false
load()
}
```
- [ ] **Step 5: Type-check**
Run: `bun run build`
Expected: no errors referencing `Blacklist.vue`.
- [ ] **Step 6: Manual browser check**
Navigate to Route Blacklist. Verify: "+ Add Pattern" and per-row "Edit"/"Delete" all show text and work; the `MethodSelect` multi-select dropdown (checked in Task 3) now visually matches the rest of the form inside these dialogs; the edit dialog's "Status" select shows "Active"/"Disabled", changing it and saving persists the new value (check the row's status badge flips), and a fresh entry defaults to "Active".
- [ ] **Step 7: Commit**
```bash
git add src/views/admin/Blacklist.vue
git commit -m "Migrate Blacklist.vue to the shadcn-vue nychthemeron components"
```
---
### Task 8: Migrate `src/views/admin/Permissions.vue`
**Files:**
- Modify: `src/views/admin/Permissions.vue`
- [ ] **Step 1: Update the header button and table action buttons**
Find:
```html
<NychButton @click="showCreate = true" label="+ New Permission" />
```
Replace with:
```html
<NychButton @click="showCreate = true">+ New Permission</NychButton>
```
Find:
```html
<NychButton size="small" @click="openEdit(p)" label="Edit" />
<NychButton size="small" severity="danger" @click="deletePermission(p.id)" label="Delete" />
```
Replace with:
```html
<NychButton size="sm" @click="openEdit(p)">Edit</NychButton>
<NychButton size="sm" variant="danger" @click="deletePermission(p.id)">Delete</NychButton>
```
- [ ] **Step 2: Update the edit dialog**
Find:
```html
<NychDialog v-model:visible="showEdit" :header="`Edit — ${editForm.name}`" :modal="true" :draggable="false" style="width: min(600px, 95vw)">
<form @submit.prevent="submitEdit" class="dialog-form">
<div class="field">
<label>Name</label>
<NychInputText v-model="editForm.name" fluid />
</div>
<div class="field">
<label>Description</label>
<NychInputText v-model="editForm.description" fluid />
</div>
<NychButton type="submit" fluid label="Save Changes" />
</form>
</NychDialog>
```
Replace with:
```html
<NychDialog v-model:open="showEdit">
<NychDialogContent class="w-[min(600px,95vw)]">
<NychDialogHeader><NychDialogTitle>Edit — {{ editForm.name }}</NychDialogTitle></NychDialogHeader>
<form @submit.prevent="submitEdit" class="dialog-form">
<div class="field">
<label>Name</label>
<NychInput v-model="editForm.name" class="w-full" />
</div>
<div class="field">
<label>Description</label>
<NychInput v-model="editForm.description" class="w-full" />
</div>
<NychButton type="submit" class="w-full">Save Changes</NychButton>
</form>
</NychDialogContent>
</NychDialog>
```
- [ ] **Step 3: Update the create dialog**
Find:
```html
<NychDialog v-model:visible="showCreate" header="New Permission" :modal="true" :draggable="false" style="width: min(600px, 95vw)">
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>Name</label>
<NychInputText v-model="form.name" placeholder="PERMISSION_NAME" fluid />
</div>
<div class="field">
<label>Bit value</label>
<NychInputText v-model="form.bit_value" placeholder="64" fluid />
<p class="hint">Must be a power of 2 not already in use (1, 2, 4, 8, 16, 32, 64 …)</p>
</div>
<div class="field">
<label>Description</label>
<NychInputText v-model="form.description" placeholder="What this permission grants" fluid />
</div>
<NychButton type="submit" fluid label="Create Permission" />
</form>
</NychDialog>
```
Replace with:
```html
<NychDialog v-model:open="showCreate">
<NychDialogContent class="w-[min(600px,95vw)]">
<NychDialogHeader><NychDialogTitle>New Permission</NychDialogTitle></NychDialogHeader>
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>Name</label>
<NychInput v-model="form.name" placeholder="PERMISSION_NAME" class="w-full" />
</div>
<div class="field">
<label>Bit value</label>
<NychInput v-model="form.bit_value" placeholder="64" class="w-full" />
<p class="hint">Must be a power of 2 not already in use (1, 2, 4, 8, 16, 32, 64 …)</p>
</div>
<div class="field">
<label>Description</label>
<NychInput v-model="form.description" placeholder="What this permission grants" class="w-full" />
</div>
<NychButton type="submit" class="w-full">Create Permission</NychButton>
</form>
</NychDialogContent>
</NychDialog>
```
- [ ] **Step 4: Type-check**
Run: `bun run build`
Expected: no errors referencing `Permissions.vue`.
- [ ] **Step 5: Manual browser check**
Navigate to Permissions. Verify: create/edit dialogs open with visible titles, inputs are full-width, save/create both work and refresh the table, delete works after confirm.
- [ ] **Step 6: Commit**
```bash
git add src/views/admin/Permissions.vue
git commit -m "Migrate Permissions.vue to the shadcn-vue nychthemeron components"
```
---
### Task 9: Migrate `src/views/admin/Users.vue`
**Files:**
- Modify: `src/views/admin/Users.vue`
- [ ] **Step 1: Update the header button and table action buttons**
Find:
```html
<NychButton @click="showCreate = true" label="+ New User" />
```
Replace with:
```html
<NychButton @click="showCreate = true">+ New User</NychButton>
```
Find:
```html
<NychButton size="small" @click="openEdit(u)" label="Edit" />
<NychButton size="small" severity="danger" @click="deleteUser(u.id)" label="Delete" />
```
Replace with:
```html
<NychButton size="sm" @click="openEdit(u)">Edit</NychButton>
<NychButton size="sm" variant="danger" @click="deleteUser(u.id)">Delete</NychButton>
```
- [ ] **Step 2: Update the edit dialog**
Find:
```html
<NychDialog v-model:visible="showEdit" :header="`Edit — ${editForm.username}`" :modal="true" :draggable="false" style="width: min(680px, 95vw)">
<form @submit.prevent="submitEdit" class="dialog-form">
<div class="field">
<label>Username</label>
<NychInputText v-model="editForm.username" fluid autocomplete="off" />
</div>
<div class="field">
<label>New password <span class="optional">(leave blank to keep current)</span></label>
<NychInputText v-model="editForm.password" type="password" placeholder="••••••••" fluid autocomplete="new-password" />
</div>
<div class="field">
<label>Role</label>
<NychSelect v-model="editForm.permissions_mask" :options="ROLES" optionLabel="label" optionValue="value" fluid />
<p class="hint">{{ ROLES.find(r => r.value === editForm.permissions_mask)?.description ?? '' }}</p>
</div>
<NychButton type="submit" fluid label="Save Changes" />
</form>
</NychDialog>
```
Replace with:
```html
<NychDialog v-model:open="showEdit">
<NychDialogContent class="w-[min(680px,95vw)]">
<NychDialogHeader><NychDialogTitle>Edit — {{ editForm.username }}</NychDialogTitle></NychDialogHeader>
<form @submit.prevent="submitEdit" class="dialog-form">
<div class="field">
<label>Username</label>
<NychInput v-model="editForm.username" class="w-full" autocomplete="off" />
</div>
<div class="field">
<label>New password <span class="optional">(leave blank to keep current)</span></label>
<NychInput v-model="editForm.password" type="password" placeholder="••••••••" class="w-full" autocomplete="new-password" />
</div>
<div class="field">
<label>Role</label>
<NychSelect v-model="editForm.permissions_mask" class="w-full">
<NychSelectTrigger class="w-full"><NychSelectValue /></NychSelectTrigger>
<NychSelectContent>
<NychSelectItem v-for="r in ROLES" :key="r.value" :value="r.value">{{ r.label }}</NychSelectItem>
</NychSelectContent>
</NychSelect>
<p class="hint">{{ ROLES.find(r => r.value === editForm.permissions_mask)?.description ?? '' }}</p>
</div>
<NychButton type="submit" class="w-full">Save Changes</NychButton>
</form>
</NychDialogContent>
</NychDialog>
```
- [ ] **Step 3: Update the create dialog**
Find:
```html
<NychDialog v-model:visible="showCreate" header="New User" :modal="true" :draggable="false" style="width: min(680px, 95vw)">
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>Username</label>
<NychInputText v-model="form.username" placeholder="username" fluid autocomplete="off" />
</div>
<div class="field">
<label>Password</label>
<NychInputText v-model="form.password" type="password" placeholder="••••••••" fluid autocomplete="new-password" />
</div>
<div class="field">
<label>Role</label>
<NychSelect
v-model="selectedRole"
:options="ROLES"
optionLabel="label"
optionValue="value"
placeholder="Select a role"
fluid
/>
<p class="hint">{{ roleDescription }}</p>
</div>
<NychButton type="submit" fluid label="Create User" />
</form>
</NychDialog>
```
Replace with:
```html
<NychDialog v-model:open="showCreate">
<NychDialogContent class="w-[min(680px,95vw)]">
<NychDialogHeader><NychDialogTitle>New User</NychDialogTitle></NychDialogHeader>
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>Username</label>
<NychInput v-model="form.username" placeholder="username" class="w-full" autocomplete="off" />
</div>
<div class="field">
<label>Password</label>
<NychInput v-model="form.password" type="password" placeholder="••••••••" class="w-full" autocomplete="new-password" />
</div>
<div class="field">
<label>Role</label>
<NychSelect v-model="selectedRole" class="w-full">
<NychSelectTrigger class="w-full"><NychSelectValue placeholder="Select a role" /></NychSelectTrigger>
<NychSelectContent>
<NychSelectItem v-for="r in ROLES" :key="r.value" :value="r.value">{{ r.label }}</NychSelectItem>
</NychSelectContent>
</NychSelect>
<p class="hint">{{ roleDescription }}</p>
</div>
<NychButton type="submit" class="w-full">Create User</NychButton>
</form>
</NychDialogContent>
</NychDialog>
```
- [ ] **Step 4: Type-check**
Run: `bun run build`
Expected: no errors referencing `Users.vue`.
- [ ] **Step 5: Manual browser check**
Navigate to Users. Verify: create dialog's Role select shows a placeholder until a role is picked, and the hint text below updates as you pick different roles; edit dialog's Role select is pre-populated with the user's current role and its hint matches; save/create/delete all work.
- [ ] **Step 6: Commit**
```bash
git add src/views/admin/Users.vue
git commit -m "Migrate Users.vue to the shadcn-vue nychthemeron components"
```
---
### Task 10: Migrate `src/views/admin/Cdn.vue`
**Files:**
- Modify: `src/views/admin/Cdn.vue`
- [ ] **Step 1: Update the header button and table action buttons**
Find:
```html
<NychButton @click="showCreate = true" label="+ Add Object" />
```
Replace with:
```html
<NychButton @click="showCreate = true">+ Add Object</NychButton>
```
Find:
```html
<NychButton size="small" @click="openEdit(o)" label="Edit" />
<NychButton size="small" severity="danger" @click="deleteObject(o.key)" label="Delete" />
```
Replace with:
```html
<NychButton size="sm" @click="openEdit(o)">Edit</NychButton>
<NychButton size="sm" variant="danger" @click="deleteObject(o.key)">Delete</NychButton>
```
- [ ] **Step 2: Update the create dialog**
Find:
```html
<NychDialog v-model:visible="showCreate" header="Upload CDN Object" :modal="true" :draggable="false" style="width: min(560px, 95vw)">
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>File</label>
<input type="file" ref="fileInput" @change="onFileChange" class="file-input" />
<p class="hint">The filename becomes the key unless overridden below.</p>
</div>
<div class="field">
<label>Key <span class="optional">(optional — defaults to filename)</span></label>
<NychInputText v-model="form.key" placeholder="logo.png" fluid />
</div>
<div class="field">
<label>Content Type <span class="optional">(optional — auto-detected)</span></label>
<NychInputText v-model="form.content_type" placeholder="image/png" fluid />
</div>
<div class="field">
<label>Description <span class="optional">(optional)</span></label>
<NychInputText v-model="form.description" placeholder="App logo" fluid />
</div>
<NychButton type="submit" fluid label="Upload" :disabled="!selectedFile || loading" />
</form>
</NychDialog>
```
Replace with:
```html
<NychDialog v-model:open="showCreate">
<NychDialogContent class="w-[min(560px,95vw)]">
<NychDialogHeader><NychDialogTitle>Upload CDN Object</NychDialogTitle></NychDialogHeader>
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>File</label>
<input type="file" ref="fileInput" @change="onFileChange" class="file-input" />
<p class="hint">The filename becomes the key unless overridden below.</p>
</div>
<div class="field">
<label>Key <span class="optional">(optional — defaults to filename)</span></label>
<NychInput v-model="form.key" placeholder="logo.png" class="w-full" />
</div>
<div class="field">
<label>Content Type <span class="optional">(optional — auto-detected)</span></label>
<NychInput v-model="form.content_type" placeholder="image/png" class="w-full" />
</div>
<div class="field">
<label>Description <span class="optional">(optional)</span></label>
<NychInput v-model="form.description" placeholder="App logo" class="w-full" />
</div>
<NychButton type="submit" class="w-full" :disabled="!selectedFile || loading">Upload</NychButton>
</form>
</NychDialogContent>
</NychDialog>
```
- [ ] **Step 3: Update the edit dialog**
Find:
```html
<NychDialog v-model:visible="showEdit" :header="`Edit — ${editKey}`" :modal="true" :draggable="false" style="width: min(560px, 95vw)">
<form @submit.prevent="submitEdit" class="dialog-form">
<div class="field">
<label>Key</label>
<NychInputText v-model="editForm.key" fluid />
</div>
<div class="field">
<label>Content Type <span class="optional">(optional)</span></label>
<NychInputText v-model="editForm.content_type" fluid />
</div>
<div class="field">
<label>Description <span class="optional">(optional)</span></label>
<NychInputText v-model="editForm.description" fluid />
</div>
<NychButton type="submit" fluid label="Save Changes" :disabled="loading" />
</form>
</NychDialog>
```
Replace with:
```html
<NychDialog v-model:open="showEdit">
<NychDialogContent class="w-[min(560px,95vw)]">
<NychDialogHeader><NychDialogTitle>Edit — {{ editKey }}</NychDialogTitle></NychDialogHeader>
<form @submit.prevent="submitEdit" class="dialog-form">
<div class="field">
<label>Key</label>
<NychInput v-model="editForm.key" class="w-full" />
</div>
<div class="field">
<label>Content Type <span class="optional">(optional)</span></label>
<NychInput v-model="editForm.content_type" class="w-full" />
</div>
<div class="field">
<label>Description <span class="optional">(optional)</span></label>
<NychInput v-model="editForm.description" class="w-full" />
</div>
<NychButton type="submit" class="w-full" :disabled="loading">Save Changes</NychButton>
</form>
</NychDialogContent>
</NychDialog>
```
- [ ] **Step 4: Type-check**
Run: `bun run build`
Expected: no errors referencing `Cdn.vue`.
- [ ] **Step 5: Manual browser check**
Navigate to CDN Objects. Verify: "+ Add Object" opens the upload dialog, "Upload" stays disabled until a file is picked, picking a file and submitting adds a row; per-row "Edit" opens pre-filled, saving updates the row; "Delete" removes a row after confirm.
- [ ] **Step 6: Commit**
```bash
git add src/views/admin/Cdn.vue
git commit -m "Migrate Cdn.vue to the shadcn-vue nychthemeron components"
```
---
### Task 11: Migrate `src/views/admin/Tables.vue`
The largest file — three dialogs (create, read-only inspect, drop-with-confirmation) plus a per-row column-type `NychSelect` inside a `v-for`.
**Files:**
- Modify: `src/views/admin/Tables.vue`
- [ ] **Step 1: Update the header button and table action buttons**
Find:
```html
<NychButton @click="showCreate = true" label="+ New Table" />
```
Replace with:
```html
<NychButton @click="showCreate = true">+ New Table</NychButton>
```
Find:
```html
<NychButton size="small" severity="secondary" @click="openInspect(t.table_name)" label="Inspect" />
<NychButton size="small" severity="danger" @click="openDropPreview(t.table_name)" label="Drop" />
```
Replace with:
```html
<NychButton size="sm" variant="secondary" @click="openInspect(t.table_name)">Inspect</NychButton>
<NychButton size="sm" variant="danger" @click="openDropPreview(t.table_name)">Drop</NychButton>
```
- [ ] **Step 2: Update the create dialog (header, inputs, and the per-column type select)**
Find:
```html
<!-- Create Table Dialog -->
<NychDialog v-model:visible="showCreate" header="New Table" :modal="true" :draggable="false" style="width: min(700px, 95vw)">
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>Table Name</label>
<NychInputText v-model="createForm.name" placeholder="my_table" fluid autocomplete="off" />
<p class="hint">Lowercase letters, numbers, and underscores only.</p>
</div>
<div class="columns-section">
<div class="columns-header">
<label>Columns</label>
<NychButton type="button" size="small" @click="addColumn" label="+ Add Column" />
</div>
<div class="hint fixed-col-hint">An <code>id SERIAL PRIMARY KEY</code> column is always added automatically.</div>
<div class="column-row header-row">
<span>Name</span>
<span>Type</span>
<span>Nullable</span>
<span></span>
</div>
<div v-for="(col, i) in createForm.columns" :key="i" class="column-row">
<NychInputText v-model="col.name" placeholder="column_name" fluid />
<NychSelect
v-model="col.col_type"
:options="COLUMN_TYPES"
fluid
/>
<div class="nullable-toggle">
<input type="checkbox" v-model="col.nullable" :id="`nullable-${i}`" />
<label :for="`nullable-${i}`">Yes</label>
</div>
<button type="button" class="remove-btn" @click="removeColumn(i)"></button>
</div>
<div class="empty-columns" v-if="createForm.columns.length === 0">
<span>Add at least one column.</span>
</div>
</div>
<NychButton type="submit" fluid :disabled="!canSubmitCreate" label="Create Table" />
</form>
</NychDialog>
```
Replace with:
```html
<!-- Create Table Dialog -->
<NychDialog v-model:open="showCreate">
<NychDialogContent class="w-[min(700px,95vw)]">
<NychDialogHeader><NychDialogTitle>New Table</NychDialogTitle></NychDialogHeader>
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>Table Name</label>
<NychInput v-model="createForm.name" placeholder="my_table" class="w-full" autocomplete="off" />
<p class="hint">Lowercase letters, numbers, and underscores only.</p>
</div>
<div class="columns-section">
<div class="columns-header">
<label>Columns</label>
<NychButton type="button" size="sm" @click="addColumn">+ Add Column</NychButton>
</div>
<div class="hint fixed-col-hint">An <code>id SERIAL PRIMARY KEY</code> column is always added automatically.</div>
<div class="column-row header-row">
<span>Name</span>
<span>Type</span>
<span>Nullable</span>
<span></span>
</div>
<div v-for="(col, i) in createForm.columns" :key="i" class="column-row">
<NychInput v-model="col.name" placeholder="column_name" class="w-full" />
<NychSelect v-model="col.col_type" class="w-full">
<NychSelectTrigger class="w-full"><NychSelectValue /></NychSelectTrigger>
<NychSelectContent>
<NychSelectItem v-for="ct in COLUMN_TYPES" :key="ct" :value="ct">{{ ct }}</NychSelectItem>
</NychSelectContent>
</NychSelect>
<div class="nullable-toggle">
<input type="checkbox" v-model="col.nullable" :id="`nullable-${i}`" />
<label :for="`nullable-${i}`">Yes</label>
</div>
<button type="button" class="remove-btn" @click="removeColumn(i)"></button>
</div>
<div class="empty-columns" v-if="createForm.columns.length === 0">
<span>Add at least one column.</span>
</div>
</div>
<NychButton type="submit" class="w-full" :disabled="!canSubmitCreate">Create Table</NychButton>
</form>
</NychDialogContent>
</NychDialog>
```
- [ ] **Step 3: Update the inspect dialog**
Find:
```html
<!-- Inspect Dialog (read-only) -->
<NychDialog v-model:visible="showInspect" :header="`Inspect — ${inspectPreview?.table_name ?? ''}`" :modal="true" :draggable="false" style="width: min(760px, 95vw)">
<div v-if="inspectLoading" class="preview-loading">Loading table data…</div>
```
Replace with:
```html
<!-- Inspect Dialog (read-only) -->
<NychDialog v-model:open="showInspect">
<NychDialogContent class="w-[min(760px,95vw)]">
<NychDialogHeader><NychDialogTitle>Inspect — {{ inspectPreview?.table_name ?? '' }}</NychDialogTitle></NychDialogHeader>
<div v-if="inspectLoading" class="preview-loading">Loading table data…</div>
```
Find (the closing tag for this dialog — it's the one immediately before the `<!-- Drop Preview Dialog -->` comment):
```html
<div class="empty-state sample-empty" v-else>
<span class="empty-icon"></span>
<span class="empty-label">Empty table</span>
<span class="empty-hint">Use <code>POST /api/{{ inspectPreview.table_name }}</code> to insert rows.</span>
</div>
</div>
</NychDialog>
<!-- Drop Preview Dialog -->
```
Replace with:
```html
<div class="empty-state sample-empty" v-else>
<span class="empty-icon"></span>
<span class="empty-label">Empty table</span>
<span class="empty-hint">Use <code>POST /api/{{ inspectPreview.table_name }}</code> to insert rows.</span>
</div>
</div>
</NychDialogContent>
</NychDialog>
<!-- Drop Preview Dialog -->
```
- [ ] **Step 4: Update the drop-preview dialog**
Find:
```html
<!-- Drop Preview Dialog -->
<NychDialog v-model:visible="showDropPreview" header="Drop Table" :modal="true" :draggable="false" style="width: min(760px, 95vw)">
<div v-if="dropPreviewLoading" class="preview-loading">Loading table data…</div>
```
Replace with:
```html
<!-- Drop Preview Dialog -->
<NychDialog v-model:open="showDropPreview">
<NychDialogContent class="w-[min(760px,95vw)]">
<NychDialogHeader><NychDialogTitle>Drop Table</NychDialogTitle></NychDialogHeader>
<div v-if="dropPreviewLoading" class="preview-loading">Loading table data…</div>
```
Find:
```html
<div class="confirm-section">
<label class="confirm-label">Type <strong>{{ dropPreview.table_name }}</strong> to confirm:</label>
<NychInputText v-model="dropConfirmName" :placeholder="dropPreview.table_name" fluid />
</div>
<div class="drop-actions">
<NychButton severity="secondary" @click="showDropPreview = false" label="Cancel" />
<NychButton severity="danger" :disabled="dropConfirmName !== dropPreview.table_name" @click="confirmDrop" label="Drop Table" />
</div>
</div>
</NychDialog>
</div>
</template>
```
Replace with:
```html
<div class="confirm-section">
<label class="confirm-label">Type <strong>{{ dropPreview.table_name }}</strong> to confirm:</label>
<NychInput v-model="dropConfirmName" :placeholder="dropPreview.table_name" class="w-full" />
</div>
<div class="drop-actions">
<NychButton variant="secondary" @click="showDropPreview = false">Cancel</NychButton>
<NychButton variant="danger" :disabled="dropConfirmName !== dropPreview.table_name" @click="confirmDrop">Drop Table</NychButton>
</div>
</div>
</NychDialogContent>
</NychDialog>
</div>
</template>
```
- [ ] **Step 5: Type-check**
Run: `bun run build`
Expected: no errors referencing `Tables.vue`.
- [ ] **Step 6: Manual browser check**
Navigate to Tables. Verify: "+ New Table" opens the create dialog; adding a column shows a type `NychSelect` per row (each opens independently, doesn't affect other rows), "Create Table" is disabled until name + at least one column are set, submitting creates the table and immediately opens its Inspect dialog; per-row "Inspect" shows schema + sample data read-only; "Drop" shows the warning banner when rows exist, and the "Drop Table" button stays disabled until the typed confirmation exactly matches the table name.
- [ ] **Step 7: Commit**
```bash
git add src/views/admin/Tables.vue
git commit -m "Migrate Tables.vue to the shadcn-vue nychthemeron components"
```
---
### Task 12: Migrate `src/views/admin/ApiKeys.vue`
Last file — includes the one `NychMessage``NychAlert` conversion, and the non-dismissible "key reveal" dialog that has no direct equivalent in the new library.
> **Amendment (post-implementation):** Step 3's original `onRevealOpenChange`
> guard (below) shipped in commit `b742f1f` but was Critical-flagged by task
> review as a no-op: the library's Dialog drives visibility off an internal
> ref that its dismiss handler sets directly, decoupled from the parent's
> `open` prop via `useVModel`'s passive mode — the guard never actually
> changed that prop, so the internal ref never resynced back to `true` and
> the dialog would silently stay closed after any outside-click/Escape.
>
> The shipped fix (commit `0f77a5f`, independently verified against the
> library's source and compiled bundle) instead uses standard
> `v-model:open="showReveal"` plus a capture-phase `document` `pointerdown`
> listener that calls `event.preventDefault()` for clicks outside
> `[data-slot="dialog-content"]` — this preempts a `defaultPrevented` check
> the library's own dismiss handler already makes for outside-clicks, so it
> blocks that path with no flicker. Escape has no equivalent hook and still
> closes the dialog early; the maintainer explicitly chose to accept that
> gap rather than add a flicker-based workaround for it too.
>
> **This depends on the library's internal `data-slot="dialog-content"`
> attribute** — re-check this dialog specifically if `@nychthemeron/library`
> is ever bumped again. See `src/views/admin/ApiKeys.vue`'s
> `blockOutsideDismiss` for the current implementation.
**Files:**
- Modify: `src/views/admin/ApiKeys.vue`
- [ ] **Step 1: Update the header button and table action button**
Find:
```html
<NychButton @click="showCreate = true" label="+ New Key" />
```
Replace with:
```html
<NychButton @click="showCreate = true">+ New Key</NychButton>
```
Find:
```html
<NychButton size="small" severity="danger" @click="revoke(k.id, k.name)" label="Revoke" />
```
Replace with:
```html
<NychButton size="sm" variant="danger" @click="revoke(k.id, k.name)">Revoke</NychButton>
```
- [ ] **Step 2: Update the create dialog**
Find:
```html
<!-- Create dialog -->
<NychDialog v-model:visible="showCreate" header="New API Key" :modal="true" :draggable="false" style="width: min(560px, 95vw)">
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>Name</label>
<NychInputText v-model="form.name" placeholder="e.g. CI pipeline, data importer" fluid />
</div>
<div class="field">
<label>Role</label>
<NychSelect v-model="form.permissions_mask" :options="ROLES" optionLabel="label" optionValue="value" placeholder="Select a role" fluid />
<p class="hint">{{ ROLES.find(r => r.value === form.permissions_mask)?.description ?? '' }}</p>
</div>
<div class="field">
<label>Expires <span class="optional">(optional — leave blank for no expiry)</span></label>
<NychInputText v-model="form.expires_at" type="datetime-local" fluid />
</div>
<NychButton type="submit" :disabled="!form.name || !form.permissions_mask" fluid label="Generate Key" />
</form>
</NychDialog>
```
Replace with:
```html
<!-- Create dialog -->
<NychDialog v-model:open="showCreate">
<NychDialogContent class="w-[min(560px,95vw)]">
<NychDialogHeader><NychDialogTitle>New API Key</NychDialogTitle></NychDialogHeader>
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>Name</label>
<NychInput v-model="form.name" placeholder="e.g. CI pipeline, data importer" class="w-full" />
</div>
<div class="field">
<label>Role</label>
<NychSelect v-model="form.permissions_mask" class="w-full">
<NychSelectTrigger class="w-full"><NychSelectValue placeholder="Select a role" /></NychSelectTrigger>
<NychSelectContent>
<NychSelectItem v-for="r in ROLES" :key="r.value" :value="r.value">{{ r.label }}</NychSelectItem>
</NychSelectContent>
</NychSelect>
<p class="hint">{{ ROLES.find(r => r.value === form.permissions_mask)?.description ?? '' }}</p>
</div>
<div class="field">
<label>Expires <span class="optional">(optional — leave blank for no expiry)</span></label>
<NychInput v-model="form.expires_at" type="datetime-local" class="w-full" />
</div>
<NychButton type="submit" :disabled="!form.name || !form.permissions_mask" class="w-full">Generate Key</NychButton>
</form>
</NychDialogContent>
</NychDialog>
```
- [ ] **Step 3: Update the key-reveal dialog — `NychMessage` → `NychAlert`, and make it non-dismissible**
The old `:closable="false"` prevented the user from closing this dialog any way other than clicking "I've saved the key". The new `NychDialogContent` accepts `:show-close-button="false"` to drop the X button, but its overlay/Escape dismiss isn't independently disableable — so we also ignore `update:open` events that would close it, via a guarded handler.
Find:
```html
<!-- Key reveal dialog — shown once after creation -->
<NychDialog v-model:visible="showReveal" header="API Key Created" :modal="true" :draggable="false" :closable="false" style="width: min(600px, 95vw)">
<div class="reveal-body">
<NychMessage severity="warn" class="reveal-warning">
Copy this key now — it will <strong>not</strong> be shown again.
</NychMessage>
<div class="key-display">
<code class="key-text">{{ newKey }}</code>
<button type="button" class="copy-btn" :class="{ copied }" @click="copyKey" title="Copy to clipboard">
{{ copied ? '✓' : '⎘' }}
</button>
</div>
<NychButton fluid @click="closeReveal" label="I've saved the key" />
</div>
</NychDialog>
```
Replace with:
```html
<!-- Key reveal dialog — shown once after creation -->
<NychDialog :open="showReveal" @update:open="onRevealOpenChange">
<NychDialogContent class="w-[min(600px,95vw)]" :show-close-button="false">
<NychDialogHeader><NychDialogTitle>API Key Created</NychDialogTitle></NychDialogHeader>
<div class="reveal-body">
<NychAlert variant="warning" class="reveal-warning">
Copy this key now — it will <strong>not</strong> be shown again.
</NychAlert>
<div class="key-display">
<code class="key-text">{{ newKey }}</code>
<button type="button" class="copy-btn" :class="{ copied }" @click="copyKey" title="Copy to clipboard">
{{ copied ? '✓' : '⎘' }}
</button>
</div>
<NychButton class="w-full" @click="closeReveal">I've saved the key</NychButton>
</div>
</NychDialogContent>
</NychDialog>
```
- [ ] **Step 4: Add the `onRevealOpenChange` guard to the script**
Find:
```ts
function closeReveal() {
showReveal.value = false
newKey.value = ''
}
```
Replace with:
```ts
function closeReveal() {
showReveal.value = false
newKey.value = ''
}
// The reveal dialog must only close via the "I've saved the key" button
// (closeReveal), never via outside-click/Escape — ignore any attempt to
// set it back to false that didn't go through that path.
function onRevealOpenChange(open: boolean) {
if (open) showReveal.value = true
}
```
- [ ] **Step 5: Type-check**
Run: `bun run build`
Expected: no errors referencing `ApiKeys.vue`.
- [ ] **Step 6: Manual browser check**
Navigate to API Keys. Verify: "+ New Key" dialog's Role select works and its hint updates; submitting opens the "API Key Created" dialog with a visible warning alert (amber/warning-colored, not the default info-blue), no X close button in the corner, and clicking outside the dialog or pressing Escape does **not** close it; clicking the copy icon copies the key and shows a checkmark for 2 seconds; clicking "I've saved the key" closes it and the new key appears in the table; per-row "Revoke" works after confirm.
- [ ] **Step 7: Commit**
```bash
git add src/views/admin/ApiKeys.vue
git commit -m "Migrate ApiKeys.vue to the shadcn-vue nychthemeron components"
```
---
### Task 13: Final full-app verification
**Files:** none (verification only)
- [ ] **Step 1: Full type-check and production build**
Run: `bun run build`
Expected: exits 0 with no TypeScript errors and a `dist/` build produced.
- [ ] **Step 2: Full click-through**
Run: `bun run dev`, log in, and walk every admin page (Cache, Cors, Queries, Blacklist, Permissions, Users, Cdn, Tables, ApiKeys) — for each, exercise its create, edit (where applicable), and delete/revoke flow once. Confirm the theme toggle (`useTheme().toggle()`, wired in `Layout.vue`) still switches between `apollo`/`hades` correctly across all the new components (dialogs, selects, alerts should all pick up the new theme's colors immediately, since they're driven by the same `data-theme` attribute and CSS custom properties as before).
- [ ] **Step 3: Grep for anything left behind**
Run: `grep -rn "primevue" src/ vite.config.ts package.json --include="*.vue" --include="*.ts" --include="*.json" -i`
Expected: no matches.
Run: `grep -rn "NychInputText\|severity=\|v-model:visible\|fluid\b\|label=\"" src/ --include="*.vue"`
Expected: no matches (all old-API usages converted). If this turns up hits outside the 11 files this plan touched, treat that as a signal a call site was missed during the original inventory — fix it before closing out.
- [ ] **Step 4: Commit (only if Step 3 found and fixed something; otherwise nothing to commit)**
## Post-implementation note: what's unverified
This migration shipped with `bun run build` clean across the whole app and
every view resolving under the dev server, but no automated tests (none
exist in this repo) and no live browser click-through (no chromium-cli/
Playwright/Puppeteer available in the environment this was built in). A
human should do a manual pass covering, at minimum:
- The theme toggle (`useTheme().toggle()` in `Layout.vue`) across all the
new components — dialogs, selects, alerts should all repaint correctly
between `apollo`/`hades`.
- **The ApiKeys reveal dialog's outside-click-blocked behavior specifically**
— the highest-value thing to check by hand, since it's the one piece of
runtime DOM-event logic in this migration (everything else is declarative
template conversion). Confirm clicking outside the "API Key Created"
dialog does nothing, and confirm Escape still closes it (accepted gap).
- One create/edit/delete (or revoke) cycle per admin view, per the
per-task "Manual browser check" notes throughout this plan.