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:
1 parent
24f95a8fd7
commit
eed661bb24
38 files changed
+2365
-108
No files matched your search
+3
-7
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell>
|
||||
<p>Editor placeholder.</p>
|
||||
</DashboardShell>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell>
|
||||
<p>Integration placeholder.</p>
|
||||
</DashboardShell>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user