feat: PrimeVue replaced by Nuxt UI using token files from web/src/theme, with AuthShell, DashboardShell, AppBoot and the route guards from routes.md

This commit is contained in:
fsylvain committed 2026-09-25 17:09:34 +02:00
1 parent 24f95a8fd7
commit eed661bb24
38 files changed
+2365 -108

No files matched your search

+3
View File
@@ -28,6 +28,9 @@ coverage
.eslintcache
/auto-imports.d.ts
/components.d.ts
# Cypress
/cypress/videos/
/cypress/screenshots/
+641 -22
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RenewCMS</title>
</head>
+1 -4
View File
@@ -16,13 +16,10 @@
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"@primeuix/themes": "^2.0.2",
"@nuxt/ui": "^4.11.2",
"@tailwindcss/vite": "^4.1.18",
"pinia": "^3.0.4",
"primeicons": "^7.0.0",
"primevue": "^4.5.4",
"tailwindcss": "^4.1.18",
"tailwindcss-primeui": "^0.6.1",
"vue": "^3.5.25",
"vue-router": "^4.6.3"
},
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" shape-rendering="crispEdges" xmlns:c2pa="http://c2pa.org/manifest"><metadata><c2pa:manifest>AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTplOTQ3NWYzYi1iMzk1LTQwYTItYTc0NC02OTBiNWNhN2ExYmEAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaBZb8xjia8MHL36GYqgFh9IAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDphMzk4M2EzMy03MTA2LTQwODctYjI1Yy05ZDBjMDgyNTBhNWNscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNoZLnQVKFQmfShOH339JG7cQAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggm30c/9ae4hGpd7ZqF9l6g7kujDirGaXyTi536jB+bcekZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaLY9Kv67+CqoKTGwY2CZrToAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCB5yyMJKOtdrWszMUivonxA1KFo3L7r2RCA4Mx1H0/3uWRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBiYZmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOmU5NDc1ZjNiLWIzOTUtNDBhMi1hNzQ0LTY5MGI1Y2E3YTFiYS9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjViZTQwMjZjLWM3ZjctNDUzNC1hYmFiLTY3OTc0OWZjNGFkY3JjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCCbfRz/1p7iEal3tmoX2XqDuS6MOKsZpfJOLnfqMH5tx6JjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggM1R3FsRboXyA4rm7pAVOcC/HIi2cgqmrpa/mZrSD7n6iY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggoD1kSgBM7/QspOsFvy5FPeDatbpPPR1Z+79+Ox5QvZZ0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALine truncated

After

Width:  |  Height:  |  Size: 7.9 KiB

+3 -7
View File
@@ -1,13 +1,9 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import NavComponent from './components/NavComponent.vue'
</script>
<template>
<nav-component />
<main>
<router-view />
</main>
<UApp class="isolate">
<RouterView />
</UApp>
</template>
<style scoped></style>
+327 -7
View File
@@ -1,14 +1,334 @@
@import "tailwindcss";
@import "tailwindcss-primeui";
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;1,8..60,400&display=swap');
@import 'tailwindcss';
@import '@nuxt/ui';
* {
box-sizing: border-box;
@theme static {
--font-sans: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif;
--font-serif: 'Source Serif 4', ui-serif, Georgia, serif;
--font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, monospace;
--color-ink-50: #fafafa;
--color-ink-100: #f2f2f2;
--color-ink-200: #e5e5e5;
--color-ink-300: #cccccc;
--color-ink-400: #999999;
--color-ink-500: #666666;
--color-ink-600: #444444;
--color-ink-700: #333333;
--color-ink-800: #222222;
--color-ink-900: #111111;
--color-ink-950: #0a0a0a;
--color-chalk-50: #fafafa;
--color-chalk-100: #f3f2f0;
--color-chalk-200: #e6e4e0;
--color-chalk-300: #cfccc7;
--color-chalk-400: #999999;
--color-chalk-450: #8a8783;
--color-chalk-500: #666666;
--color-chalk-600: #444444;
--color-chalk-700: #333333;
--color-chalk-800: #222222;
--color-chalk-850: #1a1a1a;
--color-chalk-900: #111111;
--color-chalk-950: #0a0a0a;
--color-sand-50: #f7f4ef;
--color-sand-100: #f0eae1;
--color-sand-200: #e8dfd1;
--color-sand-300: #d4c5b0;
--color-sand-400: #bba88e;
--color-sand-500: #9c8a70;
--color-sand-600: #7d6d56;
--color-sand-700: #635643;
--color-sand-800: #4a4033;
--color-sand-900: #322b23;
--color-sand-950: #211c17;
--color-moss-50: #f0faf0;
--color-moss-100: #e1f3e1;
--color-moss-200: #c7e2c7;
--color-moss-300: #a3caa3;
--color-moss-400: #7fac7f;
--color-moss-500: #5f8e60;
--color-moss-600: #457347;
--color-moss-700: #315b33;
--color-moss-800: #1f4422;
--color-moss-900: #143015;
--color-moss-950: #081e0a;
--color-slate-50: #f0f8ff;
--color-slate-100: #e2effa;
--color-slate-200: #c8dcee;
--color-slate-300: #a5c2db;
--color-slate-400: #81a3c0;
--color-slate-500: #6284a2;
--color-slate-600: #486a86;
--color-slate-700: #34536d;
--color-slate-800: #223d53;
--color-slate-900: #162b3c;
--color-slate-950: #0a1a26;
--color-ochre-50: #fff5e8;
--color-ochre-100: #fdead1;
--color-ochre-200: #f1d4ad;
--color-ochre-300: #dfb67d;
--color-ochre-400: #c4954f;
--color-ochre-500: #a67628;
--color-ochre-600: #8a5b00;
--color-ochre-700: #6c4700;
--color-ochre-800: #503400;
--color-ochre-900: #3a2400;
--color-ochre-950: #241500;
--color-brick-50: #fef4f2;
--color-brick-100: #ffe7e2;
--color-brick-200: #ffcbc2;
--color-brick-300: #fba394;
--color-brick-400: #e37e6e;
--color-brick-500: #c45d4e;
--color-brick-600: #a54134;
--color-brick-700: #882c21;
--color-brick-800: #6a1a11;
--color-brick-900: #4d0f08;
--color-brick-950: #330503;
--text-xs: 0.75rem;
--text-xs--line-height: 16px;
--text-sm: 0.8125rem;
--text-sm--line-height: 20px;
--text-base: 0.875rem;
--text-base--line-height: 22px;
--text-lg: 1rem;
--text-lg--line-height: 24px;
--text-xl: 1.25rem;
--text-xl--line-height: 28px;
--text-2xl: 1.75rem;
--text-2xl--line-height: 36px;
--breakpoint-xs: 22.5rem;
--breakpoint-sm: 40rem;
--breakpoint-md: 48rem;
--breakpoint-lg: 64rem;
--breakpoint-xl: 80rem;
--shadow-popover: 0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06);
--shadow-modal: 0 16px 48px rgb(17 17 17 / 0.18);
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-enter: cubic-bezier(0, 0, 0, 1);
--ease-exit: cubic-bezier(0.4, 0, 1, 1);
}
body{
:root {
--ui-radius: 0rem;
--ui-header-height: 3rem;
--ui-container: 72rem;
--duration-fast: 120ms;
--duration-base: 180ms;
--duration-slow: 240ms;
--z-sticky: 10;
--z-sidebar: 20;
--z-banner: 30;
--z-popover: 40;
--z-overlay: 50;
--z-toast: 60;
--z-tooltip: 70;
--ui-bg: var(--color-white);
--ui-bg-muted: var(--color-chalk-50);
--ui-bg-elevated: var(--color-chalk-100);
--ui-bg-accented: var(--color-chalk-200);
--ui-bg-inverted: var(--color-chalk-900);
--ui-text-dimmed: var(--color-chalk-400);
--ui-text-muted: var(--color-chalk-500);
--ui-text-toned: var(--color-chalk-600);
--ui-text: var(--color-chalk-800);
--ui-text-highlighted: var(--color-chalk-900);
--ui-text-inverted: var(--color-white);
--ui-border: var(--color-chalk-200);
--ui-border-muted: var(--color-chalk-100);
--ui-border-accented: var(--color-chalk-450);
--ui-border-inverted: var(--color-chalk-900);
--ui-primary: var(--ui-color-primary-900);
--ui-secondary: var(--ui-color-secondary-700);
--ui-success: var(--ui-color-success-700);
--ui-info: var(--ui-color-info-700);
--ui-warning: var(--ui-color-warning-700);
--ui-error: var(--ui-color-error-700);
}
.dark {
--ui-bg: var(--color-chalk-900);
--ui-bg-muted: var(--color-chalk-950);
--ui-bg-elevated: var(--color-chalk-850);
--ui-bg-accented: var(--color-chalk-800);
--ui-bg-inverted: var(--color-chalk-50);
--ui-text-dimmed: var(--color-chalk-500);
--ui-text-muted: var(--color-chalk-400);
--ui-text-toned: var(--color-chalk-300);
--ui-text: var(--color-chalk-200);
--ui-text-highlighted: var(--color-chalk-50);
--ui-text-inverted: var(--color-chalk-900);
--ui-border: var(--color-chalk-700);
--ui-border-muted: var(--color-chalk-800);
--ui-border-accented: var(--color-chalk-500);
--ui-border-inverted: var(--color-chalk-50);
--ui-primary: var(--ui-color-primary-50);
--ui-secondary: var(--ui-color-secondary-300);
--ui-success: var(--ui-color-success-300);
--ui-info: var(--ui-color-info-300);
--ui-warning: var(--ui-color-warning-300);
--ui-error: var(--ui-color-error-300);
--shadow-popover: 0 4px 16px rgb(0 0 0 / 0.5);
--shadow-modal: 0 16px 48px rgb(0 0 0 / 0.6);
}
body {
background: var(--ui-bg-muted);
color: var(--ui-text);
font-family: var(--font-sans);
font-size: var(--text-sm);
line-height: var(--text-sm--line-height);
-webkit-font-smoothing: antialiased;
}
.article-content {
font-family: var(--font-serif);
font-size: 1.0625rem;
line-height: 1.75rem;
color: var(--ui-text-highlighted);
max-width: 42rem;
overflow-wrap: break-word;
}
.article-content > :first-child {
margin-top: 0;
}
.article-content h1,
.article-content h2,
.article-content h3,
.article-content h4 {
font-weight: 600;
text-wrap: balance;
color: var(--ui-text-highlighted);
}
.article-content h1 {
font-size: 1.75rem;
line-height: 2.25rem;
margin: 0 0 1rem;
}
.article-content h2 {
font-size: 1.4375rem;
line-height: 1.9375rem;
margin: 2.5rem 0 0.75rem;
}
.article-content h3 {
font-size: 1.1875rem;
line-height: 1.75rem;
margin: 2rem 0 0.5rem;
}
.article-content h4 {
font-size: 1.0625rem;
line-height: 1.625rem;
margin: 1.5rem 0 0.5rem;
}
.article-content p {
font-size: 1.0625rem;
line-height: 1.75rem;
margin: 0 0 1.25rem;
}
.article-content ul,
.article-content ol {
margin: 0 0 1.25rem;
padding-left: 1.75rem;
}
.article-content ul {
list-style: square;
}
.article-content ol {
list-style: decimal;
}
.article-content li {
margin: 0 0 0.375rem;
}
.article-content li::marker {
color: var(--ui-text-muted);
}
.article-content blockquote {
margin: 1.75rem 0;
padding-left: 1.25rem;
border-left: 2px solid var(--ui-primary);
font-style: italic;
color: var(--ui-text-toned);
}
.article-content a {
color: var(--ui-text-highlighted);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
.article-content a:hover {
text-decoration-thickness: 2px;
}
.article-content img {
display: block;
max-width: 100%;
height: auto;
margin: 1.75rem 0;
}
.article-content figure {
margin: 1.75rem 0;
}
.article-content figure img {
margin: 0;
}
.article-content figcaption {
margin-top: 0.5rem;
font-family: var(--font-sans);
font-size: 0.8125rem;
line-height: 1.25rem;
color: var(--ui-text-muted);
}
@media (min-width: 48rem) {
.article-content h1 {
font-size: 2rem;
line-height: 2.5rem;
}
.article-content h2 {
font-size: 1.625rem;
line-height: 2.125rem;
}
.article-content h3 {
font-size: 1.3125rem;
line-height: 1.875rem;
}
.article-content h4 {
font-size: 1.125rem;
line-height: 1.75rem;
}
.article-content p {
font-size: 1.125rem;
line-height: 1.875rem;
}
.article-content {
font-size: 1.125rem;
line-height: 1.875rem;
}
.article-content figcaption {
font-size: 0.875rem;
}
}
.button-position {
@apply fixed bottom-0 left-0 m-3;
@media (prefers-reduced-motion: reduce) {
*,
::before,
::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
.animate-spin {
animation-duration: 1.6s !important;
animation-iteration-count: infinite !important;
}
}
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts"></script>
<template>
<div class="space-y-4 p-6" role="status" aria-label="Loading">
<USkeleton class="h-6 w-40" />
<USkeleton class="h-32 w-full" />
<USkeleton class="h-32 w-full" />
</div>
</template>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { useSlots, watch } from 'vue'
const props = defineProps<{
title: string
description?: string
}>()
const slots = useSlots()
watch(
() => props.title,
(title) => {
document.title = `${title} · RenewCMS`
},
{ immediate: true },
)
</script>
<template>
<main class="flex min-h-screen flex-col items-center bg-default px-5 pt-[12vh] sm:px-0">
<UAlert v-if="slots.notice" class="mb-4 w-full sm:max-w-[400px]">
<template #description>
<slot name="notice" />
</template>
</UAlert>
<UPageCard variant="outline" class="w-full sm:max-w-[400px]">
<span role="img" aria-label="RenewCMS" class="i-lucide-square inline-block h-8 w-8" />
<h1>{{ title }}</h1>
<p v-if="description">{{ description }}</p>
<slot />
</UPageCard>
<footer class="mt-4 flex w-full items-center justify-between sm:max-w-[400px]">
<slot name="footer" />
<UColorModeSelect size="sm" />
</footer>
</main>
</template>
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useSessionStore } from '@/stores/session'
import AppBoot from '@/components/AppBoot.vue'
import ErrorView from '@/components/ErrorView.vue'
import OfflineBanner from '@/components/OfflineBanner.vue'
const route = useRoute()
const session = useSessionStore()
const navItems = [
{ label: 'Home', to: '/home', icon: 'i-lucide-home' },
{ label: 'Articles', to: '/article', icon: 'i-lucide-file-text' },
{ label: 'Integration', to: '/integration', icon: 'i-lucide-plug' },
]
const pageTitle = computed(() => (route.meta.title as string) ?? '')
function onRetry() {
session.retry()
}
</script>
<template>
<UDashboardGroup unit="rem">
<a href="#dashboard-content" class="sr-only focus:not-sr-only">Skip to content</a>
<UDashboardSidebar collapsible :resizable="false" :default-size="16" :collapsed-size="4">
<template #header>
<span class="font-semibold">RenewCMS</span>
</template>
<UNavigationMenu orientation="vertical" :items="navItems" aria-label="Main" />
<template #footer>
<span>Log out</span>
</template>
</UDashboardSidebar>
<UDashboardPanel>
<template #header>
<UDashboardNavbar :title="pageTitle">
<template #leading>
<UDashboardSidebarCollapse />
</template>
<template #right>
<slot name="navbar-right" />
</template>
</UDashboardNavbar>
<UBreadcrumb :items="[{ label: pageTitle }]" />
</template>
<template #body>
<div id="dashboard-content">
<OfflineBanner />
<AppBoot v-if="session.status === 'checking'" />
<ErrorView
v-else-if="session.status === 'error'"
kind="error"
@retry="onRetry"
/>
<div v-else>
<h1>{{ pageTitle }}</h1>
<slot />
</div>
</div>
</template>
</UDashboardPanel>
</UDashboardGroup>
</template>
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
const props = withDefaults(
defineProps<{
kind: 'not-found' | 'error'
breadcrumb?: string
}>(),
{
breadcrumb: 'Home',
},
)
const emit = defineEmits<{
retry: []
}>()
const router = useRouter()
const copy = computed(() =>
props.kind === 'not-found'
? {
title: 'Page not found',
text: 'The link may be mistyped, or the page may have moved.',
primary: 'Go to Home',
secondary: 'All articles',
}
: {
title: 'Something went wrong',
text: "This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.",
primary: 'Try again',
secondary: 'Go to Home',
},
)
function onPrimary() {
if (props.kind === 'error') {
emit('retry')
return
}
router.push('/home')
}
function onSecondary() {
if (props.kind === 'error') {
router.push('/home')
return
}
router.push('/article')
}
</script>
<template>
<div role="alert" class="space-y-4 p-6">
<nav aria-label="Breadcrumb">
<span>{{ breadcrumb }}</span>
</nav>
<h1 tabindex="-1">{{ copy.title }}</h1>
<p>{{ copy.text }}</p>
<div class="flex gap-2">
<UButton @click="onPrimary">{{ copy.primary }}</UButton>
<UButton variant="outline" color="neutral" @click="onSecondary">{{
copy.secondary
}}</UButton>
</div>
</div>
</template>
-33
View File
@@ -1,33 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Button, Drawer } from 'primevue'
import { RouterLink } from 'vue-router'
const visible = ref(false)
</script>
<template>
<header>
<Drawer v-model:visible="visible">
<template #container="{ closeCallback }">
<ul @click="closeCallback">
<li><RouterLink to="/">Home</RouterLink></li>
<li><RouterLink to="/article">Article</RouterLink></li>
</ul>
<div class="button-position">
<Button
type="button"
@click="closeCallback"
icon="pi pi-times"
variant="outlined"
></Button>
</div>
</template>
</Drawer>
</header>
<div class="button-position">
<Button icon="pi pi-bars" @click="visible = true" />
</div>
</template>
<style scoped></style>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
const isOffline = ref(!navigator.onLine)
function updateOnlineStatus() {
isOffline.value = !navigator.onLine
}
onMounted(() => {
window.addEventListener('online', updateOnlineStatus)
window.addEventListener('offline', updateOnlineStatus)
})
onUnmounted(() => {
window.removeEventListener('online', updateOnlineStatus)
window.removeEventListener('offline', updateOnlineStatus)
})
</script>
<template>
<UBanner
v-if="isOffline"
color="neutral"
title="You're offline. You can keep writing, but saving and publishing will wait until you're back online."
/>
</template>
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import AuthShell from '../AuthShell.vue'
function mountShell(props: Record<string, unknown>, slots: Record<string, unknown> = {}) {
return mount(AuthShell, {
props,
slots,
global: {
plugins: [uiPlugin],
},
})
}
describe('AuthShell renders its title as the page heading and sets the document title', () => {
it('renders an h1 containing the title and sets document.title', () => {
const wrapper = mountShell({ title: 'Log in' })
expect(wrapper.find('h1').text()).toBe('Log in')
expect(document.title).toBe('Log in · RenewCMS')
})
})
describe('AuthShell renders its notice and footer slots', () => {
it('renders the notice slot in an alert above the title and the footer slot outside the card', () => {
const wrapper = mountShell(
{ title: 'Log in' },
{
notice: '<span>Your session expired.</span>',
footer: '<a href="/register/reset/request">Forgot password?</a>',
},
)
const alertDescription = wrapper.find('[data-slot="description"]')
expect(alertDescription.text()).toBe('Your session expired.')
const html = wrapper.html()
expect(html.indexOf('Your session expired.')).toBeLessThan(html.indexOf('<h1>'))
const footerLink = wrapper.find('a[href="/register/reset/request"]')
expect(footerLink.exists()).toBe(true)
expect(footerLink.text()).toBe('Forgot password?')
})
})
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { useApi } from '@/composables/useApi'
import DashboardShell from '../DashboardShell.vue'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return {
...actual,
useApi: vi.fn(),
}
})
async function mountShell(path: string) {
setActivePinia(createPinia())
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn().mockResolvedValue({ verified: true }) })
await router.push(path)
const wrapper = mount(DashboardShell, {
global: {
plugins: [router, uiPlugin],
},
})
await flushPromises()
return wrapper
}
afterEach(async () => {
await router.push('/login')
})
describe('DashboardShell renders the three navigation destinations', () => {
it('lists Home, Articles and Integration, with only Articles carrying aria-current', async () => {
const wrapper = await mountShell('/article')
const home = wrapper.find('a[href="/home"]')
const articles = wrapper.find('a[href="/article"]')
const integration = wrapper.find('a[href="/integration"]')
expect(home.exists()).toBe(true)
expect(articles.exists()).toBe(true)
expect(integration.exists()).toBe(true)
expect(articles.attributes('aria-current')).toBe('page')
expect(home.attributes('aria-current')).toBeUndefined()
expect(integration.attributes('aria-current')).toBeUndefined()
})
})
describe('DashboardShell shows the offline banner when the browser reports offline', () => {
it('shows the banner while offline and hides it once back online', async () => {
const onLineSpy = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
const wrapper = await mountShell('/article')
expect(wrapper.text()).toContain("You're offline")
onLineSpy.mockReturnValue(true)
window.dispatchEvent(new Event('online'))
await flushPromises()
expect(wrapper.text()).not.toContain("You're offline")
onLineSpy.mockRestore()
})
})
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import ErrorView from '../ErrorView.vue'
function makeRouter() {
return createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: { template: '<div />' } },
{ path: '/home', component: { template: '<div />' } },
{ path: '/article', component: { template: '<div />' } },
],
})
}
async function mountNotFound(breadcrumb?: string) {
const router = makeRouter()
await router.push('/')
return mount(ErrorView, {
props: breadcrumb ? { kind: 'not-found', breadcrumb } : { kind: 'not-found' },
global: {
plugins: [router],
},
})
}
describe('ErrorView not-found kind', () => {
it('renders the not-found copy with a breadcrumb, primary and secondary actions', async () => {
const wrapper = await mountNotFound('Not found')
expect(wrapper.find('nav[aria-label="Breadcrumb"]').text()).toBe('Not found')
expect(wrapper.find('h1').text()).toBe('Page not found')
expect(wrapper.text()).toContain('The link may be mistyped, or the page may have moved.')
const buttons = wrapper.findAll('button')
expect(buttons.some((button) => button.text() === 'Go to Home')).toBe(true)
expect(buttons.some((button) => button.text() === 'All articles')).toBe(true)
})
it('routes the primary action to /home and the secondary action to /article', async () => {
const wrapper = await mountNotFound()
const router = wrapper.vm.$router
const primary = wrapper.findAll('button').find((button) => button.text() === 'Go to Home')
await primary?.trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/home')
const secondary = wrapper
.findAll('button')
.find((button) => button.text() === 'All articles')
await secondary?.trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/article')
})
})
+25
View File
@@ -0,0 +1,25 @@
export class ApiError extends Error {
status: number
constructor(status: number) {
super(`Request failed with status ${status}`)
this.status = status
}
}
export interface MeResult {
verified: boolean
}
export function useApi() {
async function getMe(): Promise<MeResult> {
const response = await fetch('/v1/auth/me', { credentials: 'include' })
if (!response.ok) {
throw new ApiError(response.status)
}
const body = await response.json()
return { verified: Boolean(body.is_verified) }
}
return { getMe }
}
+2 -10
View File
@@ -1,24 +1,16 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Aura from '@primeuix/themes/aura'
import ui from '@nuxt/ui/vue-plugin'
import App from './App.vue'
import router from './router'
import 'primeicons/primeicons.css'
import '@/assets/main.css'
const app = createApp(App)
app.use(PrimeVue, {
theme: {
preset: Aura,
},
})
app.use(createPinia())
app.use(ui)
app.use(router)
app.mount('#app')
@@ -0,0 +1,250 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import { defineComponent, h } from 'vue'
import { RouterView } from 'vue-router'
import router from '@/router'
import { useApi, ApiError } from '@/composables/useApi'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return {
...actual,
useApi: vi.fn(),
}
})
const RootComponent = defineComponent({
setup() {
return () => h(RouterView)
},
})
let wrapper: VueWrapper | null = null
function setUpSession() {
const pinia = createPinia()
setActivePinia(pinia)
}
function mountApp() {
wrapper = mount(RootComponent, {
global: {
plugins: [router, uiPlugin],
},
})
return wrapper
}
beforeEach(() => {
vi.mocked(useApi).mockReset()
})
afterEach(async () => {
wrapper?.unmount()
wrapper = null
await router.push('/login')
})
describe('Nuxt UI shell foundation and router guards', () => {
it('A public route never triggers a session check', async () => {
const getMe = vi.fn()
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/login')
const app = mountApp()
await flushPromises()
expect(getMe).not.toHaveBeenCalled()
expect(app.find('[aria-label="Loading"]').exists()).toBe(false)
expect(app.find('h1').text()).toBe('Log in')
})
it('The first navigation to a non-public route resolves the session and shows AppBoot meanwhile', async () => {
let resolveMe: (value: { verified: boolean }) => void = () => {}
const getMe = vi.fn(
() => new Promise<{ verified: boolean }>((resolve) => (resolveMe = resolve)),
)
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/home')
const app = mountApp()
await flushPromises()
expect(app.find('[aria-label="Loading"]').exists()).toBe(true)
expect(app.text()).not.toContain('Home placeholder')
resolveMe({ verified: true })
await flushPromises()
expect(app.find('[aria-label="Loading"]').exists()).toBe(false)
expect(app.text()).toContain('Home placeholder')
expect(router.currentRoute.value.path).toBe('/home')
})
it('A second navigation reuses the cached session instead of calling GET /v1/auth/me again', async () => {
const getMe = vi.fn().mockResolvedValue({ verified: true })
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/home')
const app = mountApp()
await flushPromises()
await router.push('/article')
await flushPromises()
expect(getMe).toHaveBeenCalledTimes(1)
expect(app.find('[aria-label="Loading"]').exists()).toBe(false)
})
it('A verified-only route admits a verified session', async () => {
const getMe = vi.fn().mockResolvedValue({ verified: true })
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/article')
mountApp()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/article')
})
it('A verified-only route redirects an unverified session to the pending page', async () => {
const getMe = vi.fn().mockResolvedValue({ verified: false })
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/article')
mountApp()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
})
it('A session-only route admits an unverified session', async () => {
const getMe = vi.fn().mockResolvedValue({ verified: false })
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/register/pending')
mountApp()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/register/pending')
})
it('No session redirects to login with the original path and an expired reason', async () => {
const getMe = vi.fn().mockRejectedValue(new ApiError(401))
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/article/12/edit')
mountApp()
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(
'/login?redirect=/article/12/edit&reason=expired',
)
})
it.each([
{ verified: true, destination: '/home' },
{ verified: false, destination: '/register/pending' },
])(
'The root path resolves depending on the session (verified $verified)',
async ({ verified, destination }) => {
const getMe = vi.fn().mockResolvedValue({ verified })
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/')
mountApp()
await flushPromises()
expect(router.currentRoute.value.path).toBe(destination)
},
)
it('The root path resolves to /login when there is no session', async () => {
const getMe = vi.fn().mockRejectedValue(new ApiError(401))
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/')
mountApp()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/login')
})
it('A session check that fails with a server error renders the Generic error view instead of redirecting', async () => {
const getMe = vi.fn().mockRejectedValue(new ApiError(500))
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/')
const app = mountApp()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/')
expect(app.find('h1').text()).toBe('Something went wrong')
expect(app.text()).toContain(
"This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.",
)
expect(app.text()).toContain('Home')
const primaryButton = app.findAll('button').find((button) => button.text() === 'Try again')
await primaryButton?.trigger('click')
await flushPromises()
expect(getMe).toHaveBeenCalledTimes(2)
})
it('A session check that fails with a network error on a non-root verified route also renders the Generic error view', async () => {
const getMe = vi.fn().mockRejectedValue(new TypeError('network error'))
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/article')
const app = mountApp()
await vi.waitFor(() =>
expect(app.find('[role="alert"] h1').text()).toBe('Something went wrong'),
)
expect(router.currentRoute.value.path).toBe('/article')
const primaryButton = app.findAll('button').find((button) => button.text() === 'Try again')
await primaryButton?.trigger('click')
await flushPromises()
expect(getMe).toHaveBeenCalledTimes(2)
})
it.each([
{ path: '/home', title: 'Home' },
{ path: '/article', title: 'Articles' },
{ path: '/integration', title: 'Integration' },
])(
'Placeholder verified route $path renders inside DashboardShell with title $title',
async ({ path, title }) => {
const getMe = vi.fn().mockResolvedValue({ verified: true })
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push(path)
const app = mountApp()
await flushPromises()
const headings = app.findAll('h1')
expect(headings.some((heading) => heading.text() === title)).toBe(true)
expect(app.find('nav[aria-label="breadcrumb"]').text()).toBe(title)
const activeLink = app.find(`a[href="${path}"][aria-current="page"]`)
expect(activeLink.exists()).toBe(true)
},
)
})
+143 -3
View File
@@ -1,11 +1,151 @@
import { createRouter, createWebHistory } from 'vue-router'
import {
createRouter,
createWebHistory,
type RouteLocationNormalized,
type RouteLocationRaw,
} from 'vue-router'
import { useSessionStore } from '@/stores/session'
export type AuthRequirement = 'public' | 'session' | 'verified' | 'any'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/', component: () => import('@/views/HomeView.vue') },
{ path: '/article', component: () => import('@/views/ArticleView.vue') },
{ path: '/', component: () => import('@/views/RootView.vue'), meta: { auth: 'any' } },
{
path: '/login',
component: () => import('@/views/LoginView.vue'),
meta: { auth: 'public' },
},
{
path: '/register',
component: () => import('@/views/RegisterView.vue'),
meta: { auth: 'public' },
},
{
path: '/register/pending',
component: () => import('@/views/PendingView.vue'),
meta: { auth: 'session' },
},
{
path: '/register/validate',
component: () => import('@/views/VerifyView.vue'),
meta: { auth: 'public' },
},
{
path: '/register/reset/request',
component: () => import('@/views/ResetRequestView.vue'),
meta: { auth: 'public' },
},
{
path: '/register/reset/validate',
component: () => import('@/views/ResetConfirmView.vue'),
meta: { auth: 'public' },
},
{
path: '/home',
component: () => import('@/views/HomeView.vue'),
meta: { auth: 'verified', title: 'Home' },
},
{
path: '/article',
component: () => import('@/views/ArticleView.vue'),
meta: { auth: 'verified', title: 'Articles' },
},
{
path: '/article/new',
component: () => import('@/views/ArticleView.vue'),
meta: { auth: 'verified', title: 'Articles' },
},
{
path: '/article/:id/edit',
component: () => import('@/views/EditorView.vue'),
meta: { auth: 'verified', title: 'Editor' },
},
{
path: '/integration',
component: () => import('@/views/IntegrationView.vue'),
meta: { auth: 'verified', title: 'Integration' },
},
{
path: '/:pathMatch(.*)*',
component: () => import('@/views/NotFoundView.vue'),
meta: { auth: 'any' },
},
],
})
export function resolveAuthRedirect(
to: RouteLocationNormalized,
session: ReturnType<typeof useSessionStore>,
): RouteLocationRaw | true {
const auth = to.meta.auth as AuthRequirement
if (auth === 'public') {
return true
}
if (session.status !== 'ready') {
return true
}
const account = session.session
if (!account) {
if (auth === 'any') {
return { path: '/login' }
}
return { path: '/login', query: { redirect: to.fullPath, reason: 'expired' } }
}
if (!account.verified) {
if (auth === 'verified' || auth === 'any') {
return { path: '/register/pending' }
}
return true
}
if (auth === 'any') {
return { path: '/home' }
}
return true
}
router.beforeEach((to) => {
if (to.meta.auth === 'public') {
return true
}
const session = useSessionStore()
if (session.status === 'idle') {
session.check()
return true
}
if (session.status === 'checking') {
return true
}
return resolveAuthRedirect(to, session)
})
router.afterEach((to) => {
if (to.meta.auth === 'public') {
return
}
const session = useSessionStore()
const settled =
session.status === 'idle' || session.status === 'checking'
? session.check()
: Promise.resolve()
settled.then(() => {
const destination = resolveAuthRedirect(router.currentRoute.value, session)
if (destination !== true) {
router.replace(destination)
}
})
})
export default router
+50
View File
@@ -0,0 +1,50 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ApiError, useApi } from '@/composables/useApi'
export type SessionStatus = 'idle' | 'checking' | 'ready' | 'error'
export interface Session {
verified: boolean
}
export const useSessionStore = defineStore('session', () => {
const status = ref<SessionStatus>('idle')
const session = ref<Session | null>(null)
let pending: Promise<void> | null = null
function check() {
if (pending) {
return pending
}
status.value = 'checking'
const { getMe } = useApi()
pending = getMe()
.then((result) => {
session.value = result
status.value = 'ready'
})
.catch((error) => {
if (error instanceof ApiError && error.status === 401) {
session.value = null
status.value = 'ready'
return
}
status.value = 'error'
})
.finally(() => {
pending = null
})
return pending
}
function retry() {
status.value = 'idle'
return check()
}
return { status, session, check, retry }
})
+183
View File
@@ -0,0 +1,183 @@
export const renewUi = {
colors: {
primary: 'ink',
secondary: 'sand',
success: 'moss',
info: 'slate',
warning: 'ochre',
error: 'brick',
neutral: 'chalk',
},
icons: {
loading: 'i-lucide-loader-circle',
close: 'i-lucide-x',
check: 'i-lucide-check',
search: 'i-lucide-search',
chevronDown: 'i-lucide-chevron-down',
chevronRight: 'i-lucide-chevron-right',
arrowLeft: 'i-lucide-arrow-left',
copy: 'i-lucide-copy',
copyCheck: 'i-lucide-check',
dark: 'i-lucide-moon',
light: 'i-lucide-sun',
system: 'i-lucide-monitor',
success: 'i-lucide-circle-check',
info: 'i-lucide-info',
warning: 'i-lucide-triangle-alert',
error: 'i-lucide-circle-x',
menu: 'i-lucide-menu',
panelClose: 'i-lucide-panel-left-close',
panelOpen: 'i-lucide-panel-left-open',
ellipsis: 'i-lucide-ellipsis',
upload: 'i-lucide-upload',
eye: 'i-lucide-eye',
eyeOff: 'i-lucide-eye-off',
},
button: {
slots: {
base: 'rounded-none font-medium transition-colors duration-(--duration-fast) ease-(--ease-standard)',
},
defaultVariants: {
size: 'md',
},
},
input: {
slots: {
base: 'rounded-none ring-(--ui-border-accented) placeholder:text-muted',
},
variants: {
size: {
md: {
base: 'text-base md:text-sm',
},
},
},
},
textarea: {
slots: {
base: 'rounded-none ring-(--ui-border-accented) placeholder:text-muted text-base md:text-sm',
},
},
select: {
slots: {
base: 'rounded-none ring-(--ui-border-accented)',
content: 'rounded-none shadow-(--shadow-popover)',
},
},
checkbox: {
slots: {
base: 'rounded-none ring-(--ui-border-accented)',
},
},
switch: {
slots: {
base: 'rounded-none',
thumb: 'rounded-none',
},
},
avatar: {
slots: {
root: 'rounded-none bg-(--ui-color-secondary-300)',
fallback: 'text-(--ui-color-primary-900) font-semibold',
},
},
chip: {
slots: {
base: 'rounded-none',
},
},
progress: {
slots: {
base: 'rounded-none',
indicator: 'rounded-none',
},
},
badge: {
slots: {
base: 'rounded-none font-medium',
},
},
kbd: {
base: 'rounded-none font-mono',
},
tabs: {
slots: {
list: 'rounded-none',
trigger: 'rounded-none',
indicator: 'rounded-none',
},
},
dropdownMenu: {
slots: {
content: 'rounded-none shadow-(--shadow-popover)',
item: 'rounded-none',
},
},
tooltip: {
slots: {
content: 'rounded-none bg-inverted text-inverted ring-0',
},
},
modal: {
slots: {
content: 'rounded-none shadow-(--shadow-modal) divide-default',
overlay: 'bg-(--ui-color-neutral-950)/40',
},
},
slideover: {
slots: {
content: 'rounded-none shadow-(--shadow-modal)',
overlay: 'bg-(--ui-color-neutral-950)/40',
},
},
toast: {
slots: {
root: 'rounded-none shadow-(--shadow-popover)',
progress: 'rounded-none',
},
},
toaster: {
slots: {
viewport: 'z-(--z-toast)',
},
},
alert: {
slots: {
root: 'rounded-none',
},
},
card: {
slots: {
root: 'rounded-none',
},
},
table: {
slots: {
thead: 'bg-elevated',
th: 'text-muted font-medium py-2',
td: 'py-3',
tr: 'data-[selectable=true]:hover:bg-elevated',
},
},
navigationMenu: {
compoundVariants: [
{
orientation: 'vertical',
active: true,
class: {
link: 'bg-(--ui-color-secondary-200) text-(--ui-color-primary-900) dark:bg-(--ui-color-secondary-300) before:hidden',
},
},
],
},
dashboardSidebar: {
slots: {
root: 'bg-default',
},
},
dashboardPanel: {
slots: {
body: 'bg-muted',
},
},
}
+6 -4
View File
@@ -1,7 +1,9 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import DashboardShell from '@/components/DashboardShell.vue'
</script>
<template>
<p>Article</p>
<DashboardShell>
<p>Articles placeholder.</p>
</DashboardShell>
</template>
<style scoped></style>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import DashboardShell from '@/components/DashboardShell.vue'
</script>
<template>
<DashboardShell>
<p>Editor placeholder.</p>
</DashboardShell>
</template>
+6 -4
View File
@@ -1,7 +1,9 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import DashboardShell from '@/components/DashboardShell.vue'
</script>
<template>
<p>Home</p>
<DashboardShell>
<p>Home placeholder.</p>
</DashboardShell>
</template>
<style scoped></style>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import DashboardShell from '@/components/DashboardShell.vue'
</script>
<template>
<DashboardShell>
<p>Integration placeholder.</p>
</DashboardShell>
</template>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import AuthShell from '@/components/AuthShell.vue'
</script>
<template>
<AuthShell title="Log in">
<p>Log in placeholder.</p>
</AuthShell>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { useSessionStore } from '@/stores/session'
import DashboardShell from '@/components/DashboardShell.vue'
import AuthShell from '@/components/AuthShell.vue'
import ErrorView from '@/components/ErrorView.vue'
const session = useSessionStore()
</script>
<template>
<DashboardShell v-if="session.session">
<ErrorView kind="not-found" breadcrumb="Not found" />
</DashboardShell>
<AuthShell v-else title="Page not found">
<ErrorView kind="not-found" breadcrumb="Not found" />
</AuthShell>
</template>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import AuthShell from '@/components/AuthShell.vue'
</script>
<template>
<AuthShell title="Check your inbox">
<p>Pending verification placeholder.</p>
</AuthShell>
</template>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import AuthShell from '@/components/AuthShell.vue'
</script>
<template>
<AuthShell title="Create your account">
<p>Register placeholder.</p>
</AuthShell>
</template>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import AuthShell from '@/components/AuthShell.vue'
</script>
<template>
<AuthShell title="Choose a new password">
<p>Reset confirm placeholder.</p>
</AuthShell>
</template>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import AuthShell from '@/components/AuthShell.vue'
</script>
<template>
<AuthShell title="Reset your password">
<p>Reset request placeholder.</p>
</AuthShell>
</template>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { useSessionStore } from '@/stores/session'
import ErrorView from '@/components/ErrorView.vue'
const session = useSessionStore()
function onRetry() {
session.retry()
}
</script>
<template>
<ErrorView v-if="session.status === 'error'" kind="error" @retry="onRetry" />
</template>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import AuthShell from '@/components/AuthShell.vue'
</script>
<template>
<AuthShell title="Verifying your account">
<p>Verify placeholder.</p>
</AuthShell>
</template>
-10
View File
@@ -1,10 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import HomeView from '../HomeView.vue'
describe('HomeView', () => {
it('renders a paragraph with the text Home', () => {
const wrapper = mount(HomeView)
expect(wrapper.text()).toContain('Home')
})
})
+13 -3
View File
@@ -3,9 +3,9 @@ import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
import tailwindcss from '@tailwindcss/vite'
import ui from '@nuxt/ui/vite'
import { renewUi } from './src/theme/ui.config'
// https://vite.dev/config/
export default defineConfig({
build: {
outDir: '../internal/infrastructure/assets/dist/',
@@ -17,7 +17,17 @@ export default defineConfig({
'/healthz': 'http://localhost:8080',
},
},
plugins: [vue(), vueDevTools(), tailwindcss()],
plugins: [
vue(),
vueDevTools(),
ui({
ui: renewUi,
colorMode: true,
theme: {
colors: ['primary', 'secondary', 'success', 'info', 'warning', 'error'],
},
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),