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
@@ -0,0 +1,146 @@
|
||||
Feature: Nuxt UI shell foundation and router guards
|
||||
|
||||
As the maintainer of RenewCMS
|
||||
I want the SPA to run on Nuxt UI instead of PrimeVue, with AuthShell and DashboardShell layouts,
|
||||
an AppBoot skeleton, and a router guard that resolves and caches the session once per app load
|
||||
So that every later screen slice can build on a themed, guarded shell instead of an unguarded router
|
||||
with no layout
|
||||
|
||||
Background:
|
||||
Given web/src/main.ts wires the app through the @nuxt/ui/vite plugin, and no longer imports
|
||||
primevue, @primeuix/themes, primeicons or tailwindcss-primeui
|
||||
And web/src/theme/ui.config.ts and web/src/assets/main.css hold the token files copied from
|
||||
docs/design/source/tokens/ (ui.config.ts, renewcms.css), and public/favicon.svg is the file
|
||||
copied from docs/design/source/brand/favicon.svg
|
||||
And the router declares route meta.auth as one of "public", "session", "verified" or "any", and
|
||||
meta.title for every verified route
|
||||
And a global router.beforeEach guard runs on every navigation, and on the first navigation of the
|
||||
app it resolves the session by calling GET /v1/auth/me once and caches the result (session or
|
||||
"no session") in a session Pinia store; later navigations in the same app load reuse that
|
||||
cached result instead of calling GET /v1/auth/me again
|
||||
And /home, /article and /integration currently render only a trivial placeholder view inside
|
||||
DashboardShell; later screen slices (Home, Articles, Integration) replace that placeholder
|
||||
content with the real screens
|
||||
|
||||
Scenario: A public route never triggers a session check
|
||||
Given the target route is /login, whose meta.auth is "public"
|
||||
When the router navigates to it as the first navigation of the app
|
||||
Then GET /v1/auth/me is not called
|
||||
And AuthShell renders immediately, with no AppBoot skeleton shown
|
||||
|
||||
Scenario: The first navigation to a non-public route resolves the session and shows AppBoot meanwhile
|
||||
Given the target route is /home, whose meta.auth is "verified"
|
||||
And GET /v1/auth/me has not resolved yet
|
||||
When the router navigates to /home as the first navigation of the app
|
||||
Then DashboardShell renders with the AppBoot skeleton in place of the page content
|
||||
When GET /v1/auth/me then resolves 200 with is_verified true
|
||||
Then the AppBoot skeleton is replaced by the routed page's content
|
||||
And the navigation is not redirected
|
||||
|
||||
Scenario: A second navigation reuses the cached session instead of calling GET /v1/auth/me again
|
||||
Given GET /v1/auth/me has already resolved 200 with is_verified true during the first navigation
|
||||
When the router navigates from /home to /article, whose meta.auth is also "verified"
|
||||
Then GET /v1/auth/me is not called a second time
|
||||
And no AppBoot skeleton is shown for this navigation
|
||||
|
||||
Scenario: A verified-only route admits a verified session
|
||||
Given the target route is /article, whose meta.auth is "verified"
|
||||
And GET /v1/auth/me resolves 200 with is_verified true
|
||||
When the router navigates to /article
|
||||
Then the navigation completes to /article
|
||||
And there is no redirect
|
||||
|
||||
Scenario: A verified-only route redirects an unverified session to the pending page
|
||||
Given the target route is /article, whose meta.auth is "verified"
|
||||
And GET /v1/auth/me resolves 200 with is_verified false
|
||||
When the router navigates to /article
|
||||
Then the router redirects to /register/pending
|
||||
And the navigation to /article does not complete
|
||||
|
||||
Scenario: A session-only route admits an unverified session
|
||||
Given the target route is /register/pending, whose meta.auth is "session"
|
||||
And GET /v1/auth/me resolves 200 with is_verified false
|
||||
When the router navigates to /register/pending
|
||||
Then the navigation completes to /register/pending
|
||||
And there is no redirect
|
||||
|
||||
Scenario: No session redirects to login with the original path and an expired reason
|
||||
Given the target route is /article/12/edit, whose meta.auth is "verified"
|
||||
And GET /v1/auth/me resolves 401
|
||||
When the router navigates to /article/12/edit
|
||||
Then the router redirects to "/login?redirect=/article/12/edit&reason=expired"
|
||||
And the navigation to /article/12/edit does not complete
|
||||
|
||||
Scenario Outline: The root path resolves to /home, /login or /register/pending depending on the session
|
||||
Given GET /v1/auth/me resolves <response>
|
||||
When the router navigates to /
|
||||
Then the router redirects to <destination>
|
||||
|
||||
Examples:
|
||||
| response | destination |
|
||||
| 200 with is_verified true | /home |
|
||||
| 401 | /login |
|
||||
| 200 with is_verified false | /register/pending |
|
||||
|
||||
Scenario: A session check that fails with a server error renders the Generic error view instead of redirecting
|
||||
Given the target route is /, whose meta.auth is "any"
|
||||
And GET /v1/auth/me fails with a 500 response
|
||||
When the router navigates to /
|
||||
Then the router does not redirect anywhere
|
||||
And the Generic error view renders in place, with breadcrumb "Home", 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.", a primary action "Try again" and a secondary action "Go to Home"
|
||||
When "Try again" is activated
|
||||
Then GET /v1/auth/me is called again to re-run the session check
|
||||
|
||||
Scenario: A session check that fails with a network error on a non-root verified route also renders the Generic error view
|
||||
Given the target route is /article, whose meta.auth is "verified"
|
||||
And GET /v1/auth/me fails with a network error
|
||||
When the router navigates to /article
|
||||
Then the router does not redirect to /login or /register/pending
|
||||
And DashboardShell renders the Generic error view in place of the AppBoot skeleton and the page
|
||||
content, with title "Something went wrong" and a primary action "Try again"
|
||||
When "Try again" is activated
|
||||
Then GET /v1/auth/me is called again to re-run the session check
|
||||
|
||||
Scenario Outline: Each verified placeholder route renders inside DashboardShell with its own heading, breadcrumb and active nav item
|
||||
Given GET /v1/auth/me resolves 200 with is_verified true
|
||||
When the router navigates to <path>
|
||||
Then the route renders inside DashboardShell
|
||||
And the page heading is <title>
|
||||
And the breadcrumb is <title>
|
||||
And the nav item for <title> has aria-current="page"
|
||||
|
||||
Examples:
|
||||
| path | title |
|
||||
| /home | "Home" |
|
||||
| /article | "Articles" |
|
||||
| /integration | "Integration" |
|
||||
|
||||
Scenario: AuthShell renders its title as the page heading and sets the document title
|
||||
Given a screen renders AuthShell with title "Log in" and no description
|
||||
When AuthShell mounts
|
||||
Then AuthShell renders an h1 containing "Log in"
|
||||
And document.title is "Log in · RenewCMS"
|
||||
|
||||
Scenario: AuthShell renders its notice and footer slots
|
||||
Given a screen renders AuthShell with a "notice" slot containing "Your session expired." and a
|
||||
"footer" slot containing a "Forgot password?" link
|
||||
When AuthShell mounts
|
||||
Then the alert above the title contains "Your session expired."
|
||||
And the footer area outside the card contains a "Forgot password?" link
|
||||
|
||||
Scenario: DashboardShell renders the three navigation destinations
|
||||
Given the current route is /article
|
||||
When DashboardShell mounts
|
||||
Then the sidebar navigation lists exactly three destinations: "Home" linking to /home, "Articles"
|
||||
linking to /article, and "Integration" linking to /integration
|
||||
And the "Articles" destination has aria-current="page"
|
||||
And neither "Home" nor "Integration" has aria-current set
|
||||
|
||||
Scenario: DashboardShell shows the offline banner when the browser reports offline
|
||||
Given navigator.onLine is false
|
||||
When DashboardShell mounts
|
||||
Then an offline banner is shown
|
||||
When navigator.onLine becomes true
|
||||
Then the offline banner is hidden
|
||||
@@ -0,0 +1,73 @@
|
||||
# QA: Nuxt UI shell foundation and router guards
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `useApi` / the `/v1/auth/me` call per case; no real backend, no httptest server.
|
||||
|
||||
1. **Public route skips session check** — mount the router at /login; assert the mocked
|
||||
GET /v1/auth/me function is never invoked, and AuthShell is rendered with no AppBoot skeleton.
|
||||
2. **AppBoot shown while GET /v1/auth/me is pending** — mount the router at /home with an
|
||||
unresolved GET /v1/auth/me promise; assert the AppBoot skeleton (USkeleton) is present and the
|
||||
route's own content is absent; resolve the promise with 200 is_verified true; assert the skeleton
|
||||
is gone and the route's content is present.
|
||||
3. **Session cached across navigations** — resolve GET /v1/auth/me once (200 is_verified true),
|
||||
navigate /home → /article; assert the mock was called exactly once.
|
||||
4. **Verified route + verified session** — mock 200 is_verified true, navigate to /article; assert
|
||||
the router's current route is /article (no redirect).
|
||||
5. **Verified route + unverified session** — mock 200 is_verified false, navigate to /article; assert
|
||||
the router redirects to /register/pending.
|
||||
6. **Session route + unverified session** — mock 200 is_verified false, navigate to
|
||||
/register/pending; assert no redirect.
|
||||
7. **No session** — mock a 401, navigate to /article/12/edit; assert redirect to
|
||||
/login?redirect=/article/12/edit&reason=expired.
|
||||
8. **Root path outcomes** — one test per Examples row: 200 is_verified true → /home, 401 → /login,
|
||||
200 is_verified false → /register/pending.
|
||||
9. **Boot failure at root renders the Generic error view** — mock GET /v1/auth/me rejecting with a
|
||||
500, navigate to /; assert no redirect happens, and the Generic error view renders with title
|
||||
"Something went wrong" and text "This page couldn't load. Your saved work is safe. Try again, and
|
||||
if it keeps happening, restart the RenewCMS server."; click "Try again"; assert
|
||||
GET /v1/auth/me is called a second time.
|
||||
10. **Boot failure at a non-root verified route renders the Generic error view** — mock
|
||||
GET /v1/auth/me rejecting with a network error, navigate to /article; assert no redirect to
|
||||
/login or /register/pending happens, DashboardShell shows the Generic error view instead of
|
||||
AppBoot or page content; click "Try again"; assert GET /v1/auth/me is called a second time.
|
||||
11. **Placeholder verified routes wire up shell, heading, breadcrumb and active nav** — one test per
|
||||
Examples row (/home → "Home", /article → "Articles", /integration → "Integration"): mock 200
|
||||
is_verified true, navigate to the path; assert the route renders inside DashboardShell, the page
|
||||
heading and breadcrumb both equal the expected title, and the corresponding nav item has
|
||||
aria-current="page" while the other two do not.
|
||||
12. **AuthShell renders title as h1 and sets document.title** — mount AuthShell with
|
||||
title="Log in"; assert an h1 with text "Log in" and document.title === "Log in · RenewCMS".
|
||||
13. **AuthShell notice/footer slots** — mount AuthShell with notice and footer slot content; assert
|
||||
both render outside/above the card content as specified.
|
||||
14. **DashboardShell nav + aria-current** — mount DashboardShell with current route /article; assert
|
||||
3 links (Home→/home, Articles→/article, Integration→/integration), and only the Articles link
|
||||
has aria-current="page".
|
||||
15. **DashboardShell offline banner** — mock navigator.onLine false, mount DashboardShell; assert
|
||||
the offline banner is visible; set navigator.onLine true and dispatch the 'online' event; assert
|
||||
it hides.
|
||||
|
||||
## Visual (manual verification — desktop and mobile, light and dark)
|
||||
|
||||
Reference: `docs/design/source/RenewCMS Screens Back-office.dc.html`, groups "Boot" and "Global",
|
||||
plus the components.md AuthShell/DashboardShell sections.
|
||||
|
||||
- [ ] AuthShell at desktop (sm+): 400px UPageCard variant="outline", centered, top offset 12vh,
|
||||
light and dark.
|
||||
- [ ] AuthShell below sm: card loses border, 20px padding fills screen, fields/buttons use xl size,
|
||||
light and dark.
|
||||
- [ ] AuthShell footer: UColorModeSelect size="sm" plus footer links render outside the card.
|
||||
- [ ] DashboardShell desktop: sidebar with RenewCMS logo header, 3 nav items, UserMenu footer;
|
||||
collapsing the sidebar leaves a 56px icon rail with tooltips on hover/focus.
|
||||
- [ ] DashboardShell mobile (below lg): UDashboardSidebarToggle appears, sidebar becomes a
|
||||
slideover, closes after navigating.
|
||||
- [ ] DashboardShell active nav item uses the sand-fill/ink-text bend, not Nuxt UI's default
|
||||
primary-tinted background.
|
||||
- [ ] AppBoot skeleton visual match to the boot board, light and dark.
|
||||
- [ ] Generic error view visual match to the Global board's "Generic error" state, desktop and
|
||||
mobile, light and dark (breadcrumb "Home", title "Something went wrong", primary "Try again",
|
||||
secondary "Go to Home").
|
||||
- [ ] Keyboard: first Tab stop on a DashboardShell route is the "Skip to content" link; sidebar is
|
||||
a `nav` labeled "Main"; collapse button exposes `aria-expanded`; AuthShell moves focus to its
|
||||
h1 on route change; the Generic error view's title gets focus on render.
|
||||
- [ ] Favicon renders as the copied favicon.svg mark in the browser tab, dark-aware.
|
||||
@@ -28,6 +28,9 @@ coverage
|
||||
|
||||
.eslintcache
|
||||
|
||||
/auto-imports.d.ts
|
||||
/components.d.ts
|
||||
|
||||
# Cypress
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
+641
-22
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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')
|
||||
})
|
||||
})
|
||||
+13
-3
@@ -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)),
|
||||
|
||||
Reference in new issue
Block a user