Compare commits

...
6 Commits
Author SHA1 Message Date
Twyco a43aff0477 feat(KAN-21): rebuild login verification and two-factor flows
CI/CD / test_and_check_app (push) Failing after 1m34s
CI/CD / Publish immutable image (push) Skipped
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-10 11:02:15 +02:00
Twyco 7edd8daf49 feat(KAN-20): unify dialogs feedback and protect ticket drafts
CI/CD / test_and_check_app (push) Successful in 4m48s
CI/CD / Publish immutable image (push) Skipped
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 5s
2026-09-10 09:26:12 +02:00
Twyco 718f6b991d feat(KAN-19): add responsive permission-aware navigation
CI/CD / test_and_check_app (push) Successful in 4m31s
CI/CD / Publish immutable image (push) Skipped
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-09 23:09:25 +02:00
Twyco 1b2349e977 feat(KAN-18): redesign foundational UI components
CI/CD / test_and_check_app (push) Successful in 4m25s
CI/CD / Publish immutable image (push) Skipped
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-09 15:31:04 +02:00
Twyco 95db152b91 feat(KAN-17): add Emerald design system and resilient themes
CI/CD / test_and_check_app (push) Successful in 4m13s
CI/CD / Publish immutable image (push) Skipped
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-09 14:29:16 +02:00
Twyco 5194ed0a17 feat(KAN-16): add English localization foundation
CI/CD / test_and_check_app (push) Successful in 4m9s
CI/CD / Publish immutable image (push) Skipped
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 3m45s
2026-09-09 14:02:20 +02:00
106 changed files with 6708 additions and 1562 deletions
+5 -1
View File
@@ -16,7 +16,11 @@ class HandleAppearance
*/ */
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
View::share('appearance', $request->cookie('appearance') ?? 'system'); $appearance = $request->cookie('appearance');
$appearance = in_array($appearance, ['light', 'dark', 'system'], true) ? $appearance : 'system';
$request->attributes->set('appearance', $appearance);
View::share('appearance', $appearance);
return $next($request); return $next($request);
} }
@@ -4,6 +4,7 @@ namespace App\Http\Middleware;
use App\Actions\Permissions\PermissionService; use App\Actions\Permissions\PermissionService;
use App\Enums\Permission; use App\Enums\Permission;
use App\Models\Project;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Middleware; use Inertia\Middleware;
@@ -46,6 +47,11 @@ class HandleInertiaRequests extends Middleware
return [ return [
...parent::share($request), ...parent::share($request),
'name' => config('app.name'), 'name' => config('app.name'),
'appearance' => $request->attributes->get('appearance', 'system'),
'localization' => [
'locale' => app()->getLocale(),
'timeZone' => config('app.timezone'),
],
'auth' => [ 'auth' => [
'permissions' => [ 'permissions' => [
'canManageTeams' => $user !== null && ($this->permissionService->canManageAnyTeam($user) || $this->permissionService->allows($user, Permission::TeamCreate)), 'canManageTeams' => $user !== null && ($this->permissionService->canManageAnyTeam($user) || $this->permissionService->allows($user, Permission::TeamCreate)),
@@ -55,6 +61,36 @@ class HandleInertiaRequests extends Middleware
'user' => $user, 'user' => $user,
], ],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'navigation' => fn (): array => [
'project' => $this->projectNavigation($request),
],
];
}
/**
* @return array{id: int, name: string, key: string, canManage: bool, canCreateTickets: bool}|null
*/
private function projectNavigation(Request $request): ?array
{
$project = $request->route('project');
$user = $request->user();
if (! $project instanceof Project || $user === null) {
return null;
}
$permissions = $this->permissionService->effectivePermissions($user, $project);
if (! in_array(Permission::ProjectRead, $permissions, true)) {
return null;
}
return [
'id' => $project->id,
'name' => $project->name,
'key' => $project->key,
'canManage' => in_array(Permission::ProjectAdmin, $permissions, true),
'canCreateTickets' => in_array(Permission::ProjectWrite, $permissions, true),
]; ];
} }
} }
Binary file not shown.
Binary file not shown.
+92
View File
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+87 -122
View File
@@ -1,56 +1,98 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import 'tw-animate-css'; @import 'tw-animate-css';
@import './theme.css';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php'; @source '../../storage/framework/views/*.php';
@source not '../../bootstrap/ssr';
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:where(.dark, .dark *));
@font-face {
font-family: Inter;
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/inter/InterVariable.woff2') format('woff2');
}
@font-face {
font-family: Inter;
font-style: italic;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/inter/InterVariable-Italic.woff2') format('woff2');
}
@theme inline { @theme inline {
--font-sans: --font-sans: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
Instrument Sans, ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--radius-lg: var(--radius); --radius-sm: 0.125rem;
--radius-md: calc(var(--radius) - 2px); --radius-md: var(--radius);
--radius-sm: calc(var(--radius) - 4px); --radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--spacing-page: 1.5rem;
--spacing-column: 1rem;
--spacing-card: 0.75rem;
--spacing-stack: 0.5rem;
--spacing-control: 2.75rem;
--text-headline: 1.5rem;
--text-headline--line-height: 2rem;
--text-headline--font-weight: 600;
--text-headline--letter-spacing: -0.02em;
--text-headline-mobile: 1.25rem;
--text-headline-mobile--line-height: 1.75rem;
--text-headline-mobile--font-weight: 600;
--text-headline-mobile--letter-spacing: -0.02em;
--text-title: 1.125rem;
--text-title--line-height: 1.5rem;
--text-title--font-weight: 600;
--text-title--letter-spacing: -0.01em;
--text-body: 0.875rem;
--text-body--line-height: 1.25rem;
--text-caption: 0.8125rem;
--text-caption--line-height: 1.125rem;
--text-label: 0.6875rem;
--text-label--line-height: 1rem;
--text-label--font-weight: 700;
--text-label--letter-spacing: 0.05em;
--shadow-card-hover: 0 4px 12px rgb(0 0 0 / 0.08);
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground); --color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
--color-brand: var(--brand);
--color-brand-foreground: var(--brand-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-info: var(--info);
--color-info-foreground: var(--info-foreground);
--color-chart-1: var(--chart-1); --color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar-background); --color-sidebar: var(--sidebar-background);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);
@@ -61,112 +103,35 @@
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
} }
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
@layer utilities {
body,
html {
--font-sans:
'Instrument Sans', ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
}
}
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(0 0% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(0 0% 3.9%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(0 0% 3.9%);
--primary: hsl(0 0% 9%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(0 0% 92.1%);
--secondary-foreground: hsl(0 0% 9%);
--muted: hsl(0 0% 96.1%);
--muted-foreground: hsl(0 0% 45.1%);
--accent: hsl(0 0% 96.1%);
--accent-foreground: hsl(0 0% 9%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(0 0% 92.8%);
--input: hsl(0 0% 89.8%);
--ring: hsl(0 0% 3.9%);
--chart-1: hsl(12 76% 61%);
--chart-2: hsl(173 58% 39%);
--chart-3: hsl(197 37% 24%);
--chart-4: hsl(43 74% 66%);
--chart-5: hsl(27 87% 67%);
--radius: 0.5rem;
--sidebar-background: hsl(0 0% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%);
--sidebar-primary: hsl(0 0% 10%);
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(0 0% 94%);
--sidebar-accent-foreground: hsl(0 0% 30%);
--sidebar-border: hsl(0 0% 91%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(0 0% 98%);
}
.dark {
--background: hsl(0 0% 3.9%);
--foreground: hsl(0 0% 98%);
--card: hsl(0 0% 3.9%);
--card-foreground: hsl(0 0% 98%);
--popover: hsl(0 0% 3.9%);
--popover-foreground: hsl(0 0% 98%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(0 0% 9%);
--secondary: hsl(0 0% 14.9%);
--secondary-foreground: hsl(0 0% 98%);
--muted: hsl(0 0% 16.08%);
--muted-foreground: hsl(0 0% 63.9%);
--accent: hsl(0 0% 14.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 84% 60%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(0 0% 14.9%);
--input: hsl(0 0% 14.9%);
--ring: hsl(0 0% 83.1%);
--chart-1: hsl(220 70% 50%);
--chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
--sidebar-background: hsl(0 0% 7%);
--sidebar-foreground: hsl(0 0% 95.9%);
--sidebar-primary: hsl(360, 100%, 100%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(0 0% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(0 0% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(240 5.9% 10%);
}
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background font-sans text-body text-foreground antialiased;
font-optical-sizing: auto;
}
:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 3px;
}
::selection {
background: var(--brand);
color: var(--brand-foreground);
}
}
/* Preserve completion events while removing visible nonessential motion. */
@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;
} }
} }
+96
View File
@@ -0,0 +1,96 @@
/* Fluid Emerald / Kinetic Kanban: shared by the first paint and the app bundle. */
:root {
color-scheme: light;
--radius: 0.25rem;
--background: #f7f9fb;
--foreground: #191c1e;
--card: #ffffff;
--card-foreground: #191c1e;
--popover: #ffffff;
--popover-foreground: #191c1e;
--primary: #006e2f;
--primary-foreground: #ffffff;
--secondary: #d0e1fb;
--secondary-foreground: #38485d;
--muted: #f2f4f6;
--muted-foreground: #505f76;
--accent: #dcfce7;
--accent-foreground: #005321;
--destructive: #ba1a1a;
--destructive-foreground: #ffffff;
--border: #e2e8f0;
--input: #6d7b6c;
--ring: #006e2f;
--brand: #22c55e;
--brand-foreground: #002109;
--success: #dcfce7;
--success-foreground: #005321;
--warning: #fef3c7;
--warning-foreground: #78350f;
--info: #dbeafe;
--info-foreground: #1e40af;
--chart-1: #006e2f;
--chart-2: #505f76;
--chart-3: #9e4036;
--chart-4: #92400e;
--chart-5: #1d4ed8;
--sidebar-background: #f2f4f6;
--sidebar-foreground: #191c1e;
--sidebar-primary: #006e2f;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #dcfce7;
--sidebar-accent-foreground: #005321;
--sidebar-border: #e2e8f0;
--sidebar-ring: #006e2f;
--sidebar: #f2f4f6;
}
.dark {
color-scheme: dark;
--background: #0f172a;
--foreground: #f1f5f9;
--card: #1e293b;
--card-foreground: #f1f5f9;
--popover: #1e293b;
--popover-foreground: #f1f5f9;
--primary: #4ae176;
--primary-foreground: #002109;
--secondary: #334155;
--secondary-foreground: #e2e8f0;
--muted: #1e293b;
--muted-foreground: #b7c8e1;
--accent: #173b2b;
--accent-foreground: #6bff8f;
--destructive: #fca5a5;
--destructive-foreground: #450a0a;
--border: #334155;
--input: #8293aa;
--ring: #4ae176;
--brand: #22c55e;
--brand-foreground: #002109;
--success: #173b2b;
--success-foreground: #6bff8f;
--warning: #422006;
--warning-foreground: #fde68a;
--info: #172554;
--info-foreground: #bfdbfe;
--chart-1: #4ae176;
--chart-2: #b7c8e1;
--chart-3: #ffb4a9;
--chart-4: #fbbf24;
--chart-5: #60a5fa;
--sidebar-background: #111c30;
--sidebar-foreground: #f1f5f9;
--sidebar-primary: #4ae176;
--sidebar-primary-foreground: #002109;
--sidebar-accent: #173b2b;
--sidebar-accent-foreground: #6bff8f;
--sidebar-border: #334155;
--sidebar-ring: #4ae176;
--sidebar: #111c30;
}
html {
background-color: var(--background);
color: var(--foreground);
}
+5 -5
View File
@@ -3,7 +3,8 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import type { DefineComponent } from 'vue'; import type { DefineComponent } from 'vue';
import { createApp, h } from 'vue'; import { createApp, h } from 'vue';
import '../css/app.css'; import '../css/app.css';
import { initializeTheme } from '@/composables/useAppearance'; import { createAppearance } from '@/composables/useAppearance';
import { createLocalization } from '@/lib/localization';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
@@ -17,12 +18,11 @@ createInertiaApp({
setup({ el, App, props, plugin }) { setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) }) createApp({ render: () => h(App, props) })
.use(plugin) .use(plugin)
.use(createLocalization(props.initialPage.props.localization))
.use(createAppearance(props.initialPage.props.appearance))
.mount(el); .mount(el);
}, },
progress: { progress: {
color: '#4B5563', color: 'var(--primary)',
}, },
}); });
// This will set light / dark mode on page load...
initializeTheme();
+4 -4
View File
@@ -2,15 +2,15 @@
import { AlertCircle } from 'lucide-vue-next'; import { AlertCircle } from 'lucide-vue-next';
import { computed } from 'vue'; import { computed } from 'vue';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { useLocalization } from '@/composables/useLocalization';
type Props = { type Props = {
errors: string[]; errors: string[];
title?: string; title?: string;
}; };
const props = withDefaults(defineProps<Props>(), { const props = defineProps<Props>();
title: 'Something went wrong.', const { t } = useLocalization();
});
const uniqueErrors = computed(() => Array.from(new Set(props.errors))); const uniqueErrors = computed(() => Array.from(new Set(props.errors)));
</script> </script>
@@ -18,7 +18,7 @@ const uniqueErrors = computed(() => Array.from(new Set(props.errors)));
<template> <template>
<Alert variant="destructive"> <Alert variant="destructive">
<AlertCircle class="size-4" /> <AlertCircle class="size-4" />
<AlertTitle>{{ title }}</AlertTitle> <AlertTitle>{{ title ?? t('common.error') }}</AlertTitle>
<AlertDescription> <AlertDescription>
<ul class="list-inside list-disc text-sm"> <ul class="list-inside list-disc text-sm">
<li v-for="(error, index) in uniqueErrors" :key="index"> <li v-for="(error, index) in uniqueErrors" :key="index">
+11 -10
View File
@@ -1,16 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import AppLogoIcon from '@/components/AppLogoIcon.vue'; import { Columns3 } from 'lucide-vue-next';
</script> </script>
<template> <template>
<div <span class="flex min-w-0 items-center gap-3">
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md" <span
> class="bg-brand text-brand-foreground flex size-8 shrink-0 items-center justify-center rounded-md"
<AppLogoIcon class="size-5 fill-current text-white dark:text-black" />
</div>
<div class="ml-1 grid flex-1 text-left text-sm">
<span class="mb-0.5 truncate font-semibold leading-tight"
>Laravel Starter Kit</span
> >
</div> <Columns3 class="size-5" aria-hidden="true" />
</span>
<span
class="text-title truncate font-semibold group-data-[collapsible=icon]:hidden"
>KanbanGit</span
>
</span>
</template> </template>
+86 -90
View File
@@ -1,19 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3'; import { Link, router } from '@inertiajs/vue3';
import { import { Settings2, X } from 'lucide-vue-next';
BookOpen, import { computed, onMounted, onUnmounted } from 'vue';
Folder,
FolderGit2,
LayoutGrid,
Layers3,
Shield,
Users,
} from 'lucide-vue-next';
import { computed } from 'vue';
import AppLogo from '@/components/AppLogo.vue'; import AppLogo from '@/components/AppLogo.vue';
import NavFooter from '@/components/NavFooter.vue';
import NavMain from '@/components/NavMain.vue'; import NavMain from '@/components/NavMain.vue';
import NavUser from '@/components/NavUser.vue'; import { Button } from '@/components/ui/button';
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -22,93 +13,98 @@ import {
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import { useAppNavigation } from '@/composables/useAppNavigation';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization';
import { dashboard } from '@/routes'; import { dashboard } from '@/routes';
import { index as adminUsers } from '@/routes/admin/users'; import { edit as profile } from '@/routes/profile';
import { index as projectTemplatesIndex } from '@/routes/project-templates'; import { show } from '@/routes/projects';
import { index as projectsIndex } from '@/routes/projects';
import { index as teamsIndex } from '@/routes/teams';
import type { NavItem } from '@/types';
const page = usePage(); const { t } = useLocalization();
const { currentUrl } = useCurrentUrl();
const mainNavItems = computed<NavItem[]>(() => { const { project, workspaceItems, projectItems, projectSettingsItems } =
const items: NavItem[] = [ useAppNavigation();
{ const { isMobile, setOpenMobile } = useSidebar();
title: 'Dashboard', const accountItems = computed(() => [
href: dashboard(), {
icon: LayoutGrid, title: t('navigation.settings'),
}, href: profile(),
{ icon: Settings2,
title: 'Projects', isActive: currentUrl.value.startsWith('/settings/'),
href: projectsIndex(), },
icon: Folder, ]);
}, let removeNavigationListener: (() => void) | undefined;
]; onMounted(() => {
removeNavigationListener = router.on('navigate', () =>
if (page.props.auth.permissions.projectCreate) { setOpenMobile(false),
items.push({ );
title: 'Project templates',
href: projectTemplatesIndex(),
icon: Layers3,
});
}
if (page.props.auth.permissions.globalAdmin) {
items.push({
title: 'Users',
href: adminUsers(),
icon: Shield,
});
}
if (page.props.auth.permissions.canManageTeams) {
items.push({
title: 'Teams',
href: teamsIndex(),
icon: Users,
});
}
return items;
}); });
onUnmounted(() => removeNavigationListener?.());
const footerNavItems: NavItem[] = [
{
title: 'Repository',
href: 'https://github.com/laravel/vue-starter-kit',
icon: FolderGit2,
},
{
title: 'Documentation',
href: 'https://laravel.com/docs/starter-kits#vue',
icon: BookOpen,
},
];
</script> </script>
<template> <template>
<Sidebar collapsible="icon" variant="inset"> <Sidebar id="app-navigation" collapsible="icon" variant="sidebar">
<SidebarHeader> <SidebarHeader class="border-sidebar-border gap-4 border-b p-3">
<SidebarMenu> <div class="flex items-center gap-2">
<SidebarMenuItem> <SidebarMenu class="min-w-0">
<SidebarMenuButton size="lg" as-child> <SidebarMenuItem>
<Link :href="dashboard()"> <SidebarMenuButton
<AppLogo /> size="lg"
</Link> as-child
</SidebarMenuButton> tooltip="KanbanGit"
</SidebarMenuItem> >
</SidebarMenu> <Link :href="dashboard()" aria-label="KanbanGit"
><AppLogo
/></Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<Button
v-if="isMobile"
variant="ghost"
size="icon"
:aria-label="t('navigation.close')"
@click="setOpenMobile(false)"
>
<X aria-hidden="true" />
</Button>
</div>
<Link
v-if="project"
:href="show(project.id)"
class="bg-sidebar-accent flex min-w-0 items-center gap-3 rounded-md p-3 group-data-[collapsible=icon]:hidden"
>
<span
class="text-label bg-brand/15 text-primary shrink-0 rounded-md px-2 py-1"
>{{ project.key }}</span
>
<span class="truncate font-semibold" :title="project.name">{{
project.name
}}</span>
</Link>
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
<NavMain :items="mainNavItems" /> <nav :aria-label="t('navigation.main')">
<NavMain
:items="workspaceItems"
:label="t('navigation.workspace')"
/>
<NavMain
v-if="projectItems.length"
:items="projectItems"
:label="t('navigation.project')"
/>
<NavMain
v-if="projectSettingsItems.length"
:items="projectSettingsItems"
:label="t('navigation.projectSettings')"
/>
</nav>
</SidebarContent> </SidebarContent>
<SidebarFooter class="border-sidebar-border border-t p-0">
<SidebarFooter> <NavMain :items="accountItems" :label="t('navigation.account')" />
<NavFooter :items="footerNavItems" />
<NavUser />
</SidebarFooter> </SidebarFooter>
</Sidebar> </Sidebar>
<slot />
</template> </template>
+131 -13
View File
@@ -1,27 +1,145 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { Monitor, Moon, Plus, Sun } from 'lucide-vue-next';
import { computed } from 'vue';
import Breadcrumbs from '@/components/Breadcrumbs.vue'; import Breadcrumbs from '@/components/Breadcrumbs.vue';
import UserMenuContent from '@/components/UserMenuContent.vue';
import { UserAvatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { SidebarTrigger } from '@/components/ui/sidebar'; import { SidebarTrigger } from '@/components/ui/sidebar';
import { useAppNavigation } from '@/composables/useAppNavigation';
import {
normalizeAppearance,
useAppearance,
} from '@/composables/useAppearance';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization';
import { create as createProject } from '@/routes/projects';
import { index as board } from '@/routes/projects/board';
import { create as createTicket } from '@/routes/projects/tickets';
import type { BreadcrumbItem } from '@/types'; import type { BreadcrumbItem } from '@/types';
withDefaults( withDefaults(defineProps<{ breadcrumbs?: BreadcrumbItem[] }>(), {
defineProps<{ breadcrumbs: () => [],
breadcrumbs?: BreadcrumbItem[]; });
}>(), const page = usePage();
{ const { project } = useAppNavigation();
breadcrumbs: () => [], const { isCurrentUrl } = useCurrentUrl();
}, const { t } = useLocalization();
const { appearance, updateAppearance } = useAppearance();
const themeIcon = computed(
() => ({ light: Sun, dark: Moon, system: Monitor })[appearance.value],
); );
const createAction = computed(() => {
if (project.value?.canCreateTickets) {
return {
title: t('navigation.createTicket'),
href: createTicket(project.value.id, {
query: {
source: isCurrentUrl(board(project.value.id))
? 'board'
: 'backlog',
},
}),
};
}
if (!project.value && page.props.auth.permissions.projectCreate) {
return { title: t('navigation.createProject'), href: createProject() };
}
return null;
});
</script> </script>
<template> <template>
<header <header
class="border-sidebar-border/70 group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 flex h-16 shrink-0 items-center gap-2 border-b px-6 transition-[width,height] ease-linear md:px-4" class="bg-background md:px-page sticky top-0 z-20 flex min-h-16 min-w-0 shrink-0 items-center justify-between gap-2 border-b px-3 md:gap-4"
> >
<div class="flex items-center gap-2"> <div class="flex min-w-0 flex-1 items-center gap-2">
<SidebarTrigger class="-ml-1" /> <SidebarTrigger class="shrink-0" />
<template v-if="breadcrumbs && breadcrumbs.length > 0"> <Breadcrumbs v-if="breadcrumbs.length" :breadcrumbs="breadcrumbs" />
<Breadcrumbs :breadcrumbs="breadcrumbs" /> <span v-else class="truncate font-semibold">KanbanGit</span>
</template> </div>
<div class="flex shrink-0 items-center gap-1 sm:gap-2">
<Button
v-if="createAction"
as-child
:aria-label="createAction.title"
class="px-3"
>
<Link :href="createAction.href"
><Plus aria-hidden="true" /><span
class="hidden sm:inline"
>{{ createAction.title }}</span
></Link
>
</Button>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="icon"
:aria-label="t('appearance.label')"
><component :is="themeIcon" aria-hidden="true"
/></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{{
t('appearance.label')
}}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
:model-value="appearance"
@update:model-value="
(value) =>
updateAppearance(normalizeAppearance(value))
"
>
<DropdownMenuRadioItem value="light"
><Sun aria-hidden="true" />{{
t('appearance.light')
}}</DropdownMenuRadioItem
>
<DropdownMenuRadioItem value="dark"
><Moon aria-hidden="true" />{{
t('appearance.dark')
}}</DropdownMenuRadioItem
>
<DropdownMenuRadioItem value="system"
><Monitor aria-hidden="true" />{{
t('appearance.system')
}}</DropdownMenuRadioItem
>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="icon"
:aria-label="
t('navigation.userMenu', {
name: page.props.auth.user.name,
})
"
data-test="sidebar-menu-button"
>
<UserAvatar :user="page.props.auth.user" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="min-w-60">
<UserMenuContent :user="page.props.auth.user" />
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</header> </header>
</template> </template>
+15 -9
View File
@@ -1,33 +1,39 @@
<script setup lang="ts"> <script setup lang="ts">
import { Monitor, Moon, Sun } from 'lucide-vue-next'; import { Monitor, Moon, Sun } from 'lucide-vue-next';
import { useAppearance } from '@/composables/useAppearance'; import { useAppearance } from '@/composables/useAppearance';
import { useLocalization } from '@/composables/useLocalization';
const { appearance, updateAppearance } = useAppearance(); const { appearance, updateAppearance } = useAppearance();
const { t } = useLocalization();
const tabs = [ const tabs = [
{ value: 'light', Icon: Sun, label: 'Light' }, { value: 'light', Icon: Sun, label: 'appearance.light' },
{ value: 'dark', Icon: Moon, label: 'Dark' }, { value: 'dark', Icon: Moon, label: 'appearance.dark' },
{ value: 'system', Icon: Monitor, label: 'System' }, { value: 'system', Icon: Monitor, label: 'appearance.system' },
] as const; ] as const;
</script> </script>
<template> <template>
<div <div
class="inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800" role="group"
:aria-label="t('appearance.label')"
class="border-border bg-muted inline-flex max-w-full gap-1 rounded-lg border p-1"
> >
<button <button
v-for="{ value, Icon, label } in tabs" v-for="{ value, Icon, label } in tabs"
:key="value" :key="value"
type="button"
:aria-pressed="appearance === value"
@click="updateAppearance(value)" @click="updateAppearance(value)"
:class="[ :class="[
'flex items-center rounded-md px-3.5 py-1.5 transition-colors', 'min-h-control text-body focus-visible:outline-ring flex min-w-0 items-center justify-center gap-1.5 rounded-md px-3 font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
appearance === value appearance === value
? 'shadow-xs bg-white dark:bg-neutral-700 dark:text-neutral-100' ? 'bg-primary text-primary-foreground'
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60', : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
]" ]"
> >
<component :is="Icon" class="-ml-1 h-4 w-4" /> <component :is="Icon" class="size-4 shrink-0" aria-hidden="true" />
<span class="ml-1.5 text-sm">{{ label }}</span> <span>{{ t(label) }}</span>
</button> </button>
</div> </div>
</template> </template>
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { computed } from 'vue';
import FormFeedback from '@/components/FormFeedback.vue';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import type { FormFeedbackState } from '@/lib/form-feedback';
import { login } from '@/routes/index';
const props = defineProps<{
state: FormFeedbackState;
responseStatus?: number | null;
loadingTitle?: string;
successTitle?: string;
}>();
defineEmits<{ retry: [] }>();
const { t } = useLocalization();
const expired = computed(
() => props.state === 'server' && props.responseStatus === 419,
);
const limited = computed(
() => props.state === 'server' && props.responseStatus === 429,
);
const title = computed(() => {
if (expired.value) {
return t('auth.feedback.expiredTitle');
}
if (limited.value) {
return t('auth.feedback.limitedTitle');
}
if (props.state === 'loading') {
return props.loadingTitle ?? t('auth.feedback.pending');
}
if (props.state === 'success') {
return props.successTitle ?? t('auth.feedback.completed');
}
if (props.state === 'validation') {
return t('auth.feedback.validationTitle');
}
return undefined;
});
const description = computed(() => {
if (expired.value) {
return t('auth.feedback.expiredDescription');
}
if (limited.value) {
return t('auth.feedback.limitedDescription');
}
if (props.state === 'validation') {
return t('auth.feedback.validationDescription');
}
if (props.state === 'network') {
return t('auth.feedback.networkDescription');
}
if (props.state === 'server') {
return t('auth.feedback.serverDescription');
}
if (props.state === 'cancelled') {
return t('auth.feedback.cancelledDescription');
}
return undefined;
});
</script>
<template>
<div class="gap-card grid" v-show="state !== 'idle'">
<FormFeedback
:state="state"
:title="title"
:description="description"
:retryable="!expired"
@retry="$emit('retry')"
/>
<Button v-if="expired" as-child variant="outline">
<!-- A fresh document renews the session/CSRF state and drops in-memory secrets. -->
<a :href="login.url()">{{ t('auth.backToLogin') }}</a>
</Button>
</div>
</template>
+25 -6
View File
@@ -9,29 +9,48 @@ import {
BreadcrumbSeparator, BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'; } from '@/components/ui/breadcrumb';
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types'; import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
import { useLocalization } from '@/composables/useLocalization';
type Props = { type Props = {
breadcrumbs: BreadcrumbItemType[]; breadcrumbs: BreadcrumbItemType[];
}; };
defineProps<Props>(); defineProps<Props>();
const { t } = useLocalization();
</script> </script>
<template> <template>
<Breadcrumb> <Breadcrumb class="min-w-0" :aria-label="t('navigation.breadcrumbs')">
<BreadcrumbList> <BreadcrumbList class="flex-nowrap">
<template v-for="(item, index) in breadcrumbs" :key="index"> <template v-for="(item, index) in breadcrumbs" :key="index">
<BreadcrumbItem> <BreadcrumbItem
:class="[
'min-w-0',
index !== breadcrumbs.length - 1
? 'hidden md:inline-flex'
: '',
]"
>
<template v-if="index === breadcrumbs.length - 1"> <template v-if="index === breadcrumbs.length - 1">
<BreadcrumbPage>{{ item.title }}</BreadcrumbPage> <BreadcrumbPage class="truncate" :title="item.title">{{
item.title
}}</BreadcrumbPage>
</template> </template>
<template v-else> <template v-else>
<BreadcrumbLink as-child> <BreadcrumbLink as-child>
<Link :href="item.href">{{ item.title }}</Link> <Link
:href="item.href"
class="max-w-40 truncate"
:title="item.title"
>{{ item.title }}</Link
>
</BreadcrumbLink> </BreadcrumbLink>
</template> </template>
</BreadcrumbItem> </BreadcrumbItem>
<BreadcrumbSeparator v-if="index !== breadcrumbs.length - 1" /> <BreadcrumbSeparator
v-if="index !== breadcrumbs.length - 1"
class="hidden md:block"
/>
</template> </template>
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { CircleAlert, CircleCheck, Inbox, LoaderCircle } from 'lucide-vue-next';
import { computed } from 'vue';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
const props = withDefaults(
defineProps<{
state: 'empty' | 'loading' | 'error' | 'success';
title?: string;
description?: string;
retryable?: boolean;
}>(),
{ retryable: false },
);
defineEmits<{ retry: [] }>();
const { t } = useLocalization();
const icons = {
empty: Inbox,
loading: LoaderCircle,
error: CircleAlert,
success: CircleCheck,
};
const defaultTitles = {
empty: 'feedback.empty',
loading: 'common.loading',
error: 'common.error',
success: 'feedback.saved',
} as const;
const heading = computed(() => props.title ?? t(defaultTitles[props.state]));
</script>
<template>
<div
:role="state === 'error' ? 'alert' : 'status'"
:aria-busy="state === 'loading' || undefined"
:data-feedback="state"
class="gap-card bg-card p-card text-body text-card-foreground flex min-w-0 items-start rounded-md border"
:class="{
'border-destructive/40': state === 'error',
'border-success/40': state === 'success',
}"
>
<component
:is="icons[state]"
aria-hidden="true"
class="mt-0.5 size-5 shrink-0"
:class="{
'text-primary animate-spin motion-reduce:animate-none':
state === 'loading',
'text-destructive': state === 'error',
'text-success-foreground': state === 'success',
'text-muted-foreground': state === 'empty',
}"
/>
<div class="gap-stack grid min-w-0 flex-1 break-words">
<p class="font-semibold">{{ heading }}</p>
<p v-if="description" class="text-muted-foreground">
{{ description }}
</p>
<div v-if="$slots.default"><slot /></div>
<Button
v-if="retryable && state === 'error'"
type="button"
variant="outline"
class="justify-self-start"
@click="$emit('retry')"
>{{ t('common.retry') }}</Button
>
</div>
</div>
</template>
+76
View File
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { computed, nextTick, useTemplateRef, watch } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { useLocalization } from '@/composables/useLocalization';
import type { FormFeedbackState } from '@/lib/form-feedback';
const props = withDefaults(
defineProps<{
state: FormFeedbackState;
title?: string;
description?: string;
retryable?: boolean;
}>(),
{ retryable: true },
);
defineEmits<{ retry: [] }>();
const { t } = useLocalization();
const feedback = useTemplateRef<HTMLDivElement>('feedback');
watch(
() => props.state,
async (state) => {
if (['validation', 'network', 'server', 'cancelled'].includes(state)) {
await nextTick();
if (props.state === state) {
feedback.value?.focus();
}
}
},
);
const titles = {
idle: 'feedback.saved',
loading: 'forms.saving',
success: 'feedback.saved',
validation: 'forms.validationTitle',
network: 'forms.networkTitle',
server: 'forms.serverTitle',
cancelled: 'forms.cancelledTitle',
} as const;
const descriptions = {
validation: 'forms.validationDescription',
network: 'forms.networkDescription',
server: 'forms.serverDescription',
cancelled: 'forms.cancelledDescription',
} as const;
const description = computed(() =>
props.state in descriptions
? t(descriptions[props.state as keyof typeof descriptions])
: undefined,
);
</script>
<template>
<div
ref="feedback"
v-show="state !== 'idle'"
:data-form-state="state"
tabindex="-1"
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
>
<FeedbackState
v-if="state !== 'idle'"
:state="
state === 'loading' || state === 'success' ? state : 'error'
"
:title="props.title ?? t(titles[state])"
:description="props.description ?? description"
:retryable="
props.retryable !== false &&
(state === 'network' ||
state === 'server' ||
state === 'cancelled')
"
@retry="$emit('retry')"
/>
</div>
</template>
+2 -2
View File
@@ -5,8 +5,8 @@ defineProps<{
</script> </script>
<template> <template>
<div v-show="message"> <div v-show="message" role="alert">
<p class="text-sm text-red-600 dark:text-red-500"> <p class="text-body text-destructive">
{{ message }} {{ message }}
</p> </p>
</div> </div>
+29 -6
View File
@@ -12,23 +12,46 @@ import type { NavItem } from '@/types';
defineProps<{ defineProps<{
items: NavItem[]; items: NavItem[];
label: string;
}>(); }>();
const { isCurrentUrl } = useCurrentUrl(); const { isCurrentUrl } = useCurrentUrl();
</script> </script>
<template> <template>
<SidebarGroup class="px-2 py-0"> <SidebarGroup class="px-3 py-2">
<SidebarGroupLabel>Platform</SidebarGroupLabel> <SidebarGroupLabel class="text-label text-muted-foreground uppercase">{{
<SidebarMenu> label
}}</SidebarGroupLabel>
<SidebarMenu role="list" :aria-label="label">
<SidebarMenuItem v-for="item in items" :key="item.title"> <SidebarMenuItem v-for="item in items" :key="item.title">
<SidebarMenuButton <SidebarMenuButton
as-child as-child
:is-active="isCurrentUrl(item.href)" :is-active="
item.isActive ??
isCurrentUrl(
item.href,
undefined,
item.match === 'section',
)
"
:tooltip="item.title" :tooltip="item.title"
class="min-h-control data-[active=true]:border-brand border-l-2 border-transparent px-3 data-[active=true]:font-semibold group-data-[collapsible=icon]:min-h-8"
> >
<Link :href="item.href"> <Link
<component :is="item.icon" /> :href="item.href"
:aria-current="
(item.isActive ??
isCurrentUrl(
item.href,
undefined,
item.match === 'section',
))
? 'page'
: undefined
"
>
<component :is="item.icon" aria-hidden="true" />
<span>{{ item.title }}</span> <span>{{ item.title }}</span>
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
+11 -4
View File
@@ -2,6 +2,7 @@
import { Eye, EyeOff } from 'lucide-vue-next'; import { Eye, EyeOff } from 'lucide-vue-next';
import { ref, useTemplateRef } from 'vue'; import { ref, useTemplateRef } from 'vue';
import type { HTMLAttributes } from 'vue'; import type { HTMLAttributes } from 'vue';
import { useLocalization } from '@/composables/useLocalization';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -9,12 +10,14 @@ defineOptions({ inheritAttrs: false });
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes['class']; class?: HTMLAttributes['class'];
disabled?: boolean;
}>(); }>();
const model = defineModel<string | number | null>(); const model = defineModel<string | number | null>();
const showPassword = ref(false); const showPassword = ref(false);
const inputRef = useTemplateRef('inputRef'); const inputRef = useTemplateRef('inputRef');
const { t } = useLocalization();
defineExpose({ defineExpose({
$el: inputRef, $el: inputRef,
@@ -28,7 +31,8 @@ defineExpose({
ref="inputRef" ref="inputRef"
v-model="model" v-model="model"
:type="showPassword ? 'text' : 'password'" :type="showPassword ? 'text' : 'password'"
:class="cn('pr-10', props.class)" :class="cn('pr-12', props.class)"
:disabled="disabled"
v-bind="$attrs" v-bind="$attrs"
/> />
<button <button
@@ -36,11 +40,14 @@ defineExpose({
@click="showPassword = !showPassword" @click="showPassword = !showPassword"
:class=" :class="
cn( cn(
'text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 focus-visible:outline-none focus-visible:ring-[3px]', 'text-muted-foreground hover:text-foreground focus-visible:ring-ring w-control absolute inset-y-0 right-0 flex items-center justify-center rounded-r-md focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
) )
" "
:aria-label="showPassword ? 'Hide password' : 'Show password'" :aria-label="
:tabindex="-1" t(showPassword ? 'auth.password.hide' : 'auth.password.show')
"
:aria-pressed="showPassword"
:disabled="disabled"
> >
<EyeOff v-if="showPassword" class="size-4" /> <EyeOff v-if="showPassword" class="size-4" />
<Eye v-else class="size-4" /> <Eye v-else class="size-4" />
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ChevronDown, Settings2 } from 'lucide-vue-next';
import { computed } from 'vue';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAppNavigation } from '@/composables/useAppNavigation';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization';
const { projectItems, projectSettingsItems } = useAppNavigation();
const { isCurrentUrl } = useCurrentUrl();
const { t } = useLocalization();
const settingsActive = computed(() =>
projectSettingsItems.value.some((item) => isCurrentUrl(item.href)),
);
</script>
<template>
<div
v-if="projectItems.length"
class="bg-card md:px-page flex min-w-0 items-center gap-2 border-b px-4"
>
<nav
:aria-label="t('navigation.project')"
class="flex min-w-0 flex-1 gap-4 overflow-x-auto"
>
<Link
v-for="item in projectItems"
:key="item.title"
:href="item.href"
:aria-current="isCurrentUrl(item.href) ? 'page' : undefined"
:class="[
'min-h-control text-body flex shrink-0 items-center gap-2 border-b-2 px-1 py-3 font-medium outline-offset-[-3px]',
isCurrentUrl(item.href)
? 'border-brand text-primary'
: 'text-muted-foreground hover:border-border hover:text-foreground border-transparent',
]"
>
<component
:is="item.icon"
class="size-4"
aria-hidden="true"
/>{{ item.title }}
</Link>
</nav>
<DropdownMenu v-if="projectSettingsItems.length">
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="sm"
:aria-label="t('navigation.projectSettings')"
:class="{
'bg-accent text-accent-foreground': settingsActive,
}"
>
<Settings2 aria-hidden="true" /><span
class="hidden sm:inline"
>{{ t('navigation.projectSettings') }}</span
><ChevronDown class="hidden sm:block" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
v-for="item in projectSettingsItems"
:key="item.title"
as-child
>
<Link
:href="item.href"
:aria-current="
isCurrentUrl(item.href) ? 'page' : undefined
"
:class="{ 'bg-accent': isCurrentUrl(item.href) }"
>
<component :is="item.icon" aria-hidden="true" />{{
item.title
}}
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
+12 -23
View File
@@ -1,33 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import { useInitials } from '@/composables/useInitials';
import type { User } from '@/types';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue'; import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import type { User } from '@/types';
type Props = { withDefaults(defineProps<{ user: User; showEmail?: boolean }>(), {
user: User;
showEmail?: boolean;
};
const props = withDefaults(defineProps<Props>(), {
showEmail: false, showEmail: false,
}); });
const { getInitials } = useInitials();
// Compute whether we should show the avatar image
const showAvatar = computed(
() => props.user.avatar && props.user.avatar !== '',
);
</script> </script>
<template> <template>
<UserAvatar /> <div class="flex min-w-0 items-center gap-2">
<UserAvatar :user="user" />
<div class="grid flex-1 text-left text-sm leading-tight"> <div class="grid min-w-0 flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{{ user.name }}</span> <span class="truncate font-medium">{{ user.name }}</span>
<span v-if="showEmail" class="text-muted-foreground truncate text-xs">{{ <span
user.email v-if="showEmail"
}}</span> class="text-muted-foreground truncate text-xs"
>{{ user.email }}</span
>
</div>
</div> </div>
</template> </template>
+4 -2
View File
@@ -11,6 +11,7 @@ import UserInfo from '@/components/UserInfo.vue';
import { logout } from '@/routes/index'; import { logout } from '@/routes/index';
import { edit } from '@/routes/profile/index'; import { edit } from '@/routes/profile/index';
import type { User } from '@/types'; import type { User } from '@/types';
import { useLocalization } from '@/composables/useLocalization';
type Props = { type Props = {
user: User; user: User;
@@ -21,6 +22,7 @@ const handleLogout = () => {
}; };
defineProps<Props>(); defineProps<Props>();
const { t } = useLocalization();
</script> </script>
<template> <template>
@@ -34,7 +36,7 @@ defineProps<Props>();
<DropdownMenuItem :as-child="true"> <DropdownMenuItem :as-child="true">
<Link class="block w-full cursor-pointer" :href="edit()" prefetch> <Link class="block w-full cursor-pointer" :href="edit()" prefetch>
<Settings class="mr-2 h-4 w-4" /> <Settings class="mr-2 h-4 w-4" />
Settings {{ t('navigation.settings') }}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
@@ -48,7 +50,7 @@ defineProps<Props>();
data-test="logout-button" data-test="logout-button"
> >
<LogOut class="mr-2 h-4 w-4" /> <LogOut class="mr-2 h-4 w-4" />
Log out {{ t('navigation.logout') }}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
</template> </template>
+29 -12
View File
@@ -1,18 +1,35 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { AvatarRootProps } from "reka-ui";
import { AvatarRoot } from "reka-ui" import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils" import type { AvatarVariants } from ".";
import { reactiveOmit } from "@vueuse/core";
import { AvatarRoot, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
import { avatarVariants } from ".";
const props = defineProps<{ const props = withDefaults(
class?: HTMLAttributes["class"] defineProps<
}>() AvatarRootProps & {
class?: HTMLAttributes["class"];
size?: AvatarVariants["size"];
shape?: AvatarVariants["shape"];
}
>(),
{ size: "default", shape: "circle" },
);
const delegatedProps = reactiveOmit(props, "class", "size", "shape");
const forwardedProps = useForwardProps(delegatedProps);
</script> </script>
<template> <template>
<AvatarRoot <AvatarRoot
data-slot="avatar" data-slot="avatar"
:class="cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', props.class)" :data-size="size"
> :data-shape="shape"
<slot /> v-bind="forwardedProps"
</AvatarRoot> :class="cn(avatarVariants({ size, shape }), props.class)"
>
<slot />
</AvatarRoot>
</template> </template>
@@ -1,21 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AvatarFallbackProps } from "reka-ui" import type { AvatarFallbackProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { AvatarFallback } from "reka-ui" import { AvatarFallback } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<AvatarFallbackProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<
AvatarFallbackProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class");
</script> </script>
<template> <template>
<AvatarFallback <AvatarFallback
data-slot="avatar-fallback" data-slot="avatar-fallback"
v-bind="delegatedProps" v-bind="delegatedProps"
:class="cn('bg-muted flex size-full items-center justify-center rounded-full', props.class)" :class="
> cn(
<slot /> 'bg-accent text-accent-foreground flex size-full items-center justify-center font-semibold',
</AvatarFallback> props.class,
)
"
>
<slot />
</AvatarFallback>
</template> </template>
@@ -1,41 +1,39 @@
<script setup lang="ts"> <script setup lang="ts">
import { getInitials } from "@/composables/useInitials"; import type { AvatarVariants } from "@/components/ui/avatar";
import { import type { UserModel } from "@/types/models/user-model";
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar/index";
import { usePage } from "@inertiajs/vue3"; import { usePage } from "@inertiajs/vue3";
import { computed, PropType } from "vue"; import { computed } from "vue";
import { UserModel } from "@/types/models/user-model"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { getInitials } from "@/composables/useInitials";
const props = defineProps({ const props = withDefaults(
user: { defineProps<{
type: Object as PropType<UserModel>, user?: UserModel;
rounded?: boolean;
size?: AvatarVariants["size"];
label?: string;
}>(),
{
rounded: true,
size: "default",
}, },
rounded: { );
type: Boolean,
default: false,
},
})
const page = usePage(); const page = usePage();
const auth = computed(() => page.props.auth); const user = computed(() => props.user ?? page.props.auth.user);
const accessibleLabel = computed(() => props.label ?? user.value.name);
const user = computed(() => props.user ?? auth.value.user) const initials = computed(() => getInitials(user.value.name));
</script> </script>
<template> <template>
<Avatar class="size-8 overflow-hidden" :class="rounded ? 'rounded-full' : 'rounded-lg'"> <Avatar :size="size" :shape="rounded ? 'circle' : 'rounded'">
<AvatarImage <AvatarImage
v-if="user.avatar" v-if="user.avatar"
:src="user.avatar" :src="user.avatar"
:alt="user.name" :alt="accessibleLabel"
/> />
<AvatarFallback <AvatarFallback :aria-label="accessibleLabel">
class="rounded-lg bg-neutral-200 font-semibold text-black dark:bg-neutral-700 dark:text-white" {{ initials }}
>
{{ getInitials(auth.user?.name) }}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
</template> </template>
+31 -3
View File
@@ -1,3 +1,31 @@
export { default as Avatar } from "./Avatar.vue" import type { VariantProps } from "class-variance-authority";
export { default as AvatarFallback } from "./AvatarFallback.vue" import { cva } from "class-variance-authority";
export { default as AvatarImage } from "./AvatarImage.vue"
export { default as Avatar } from "./Avatar.vue";
export { default as AvatarFallback } from "./AvatarFallback.vue";
export { default as AvatarImage } from "./AvatarImage.vue";
export { default as UserAvatar } from "./UserAvatar.vue";
export const avatarVariants = cva(
"border-border bg-muted relative flex shrink-0 overflow-hidden border",
{
variants: {
size: {
sm: "size-6 text-label",
default: "size-8 text-caption",
lg: "size-10 text-body",
xl: "size-16 text-title",
},
shape: {
circle: "rounded-full",
rounded: "rounded-lg",
square: "rounded-sm",
},
},
defaultVariants: {
size: "default",
shape: "circle",
},
},
);
export type AvatarVariants = VariantProps<typeof avatarVariants>;
+21 -19
View File
@@ -1,26 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PrimitiveProps } from "reka-ui" import type { PrimitiveProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import type { BadgeVariants } from "." import type { BadgeVariants } from ".";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { Primitive } from "reka-ui" import { Primitive } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { badgeVariants } from "." import { badgeVariants } from ".";
const props = defineProps<PrimitiveProps & { const props = defineProps<
variant?: BadgeVariants["variant"] PrimitiveProps & {
class?: HTMLAttributes["class"] variant?: BadgeVariants["variant"];
}>() class?: HTMLAttributes["class"];
}
>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class", "variant");
</script> </script>
<template> <template>
<Primitive <Primitive
data-slot="badge" data-slot="badge"
:class="cn(badgeVariants({ variant }), props.class)" :class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps" v-bind="delegatedProps"
> >
<slot /> <slot />
</Primitive> </Primitive>
</template> </template>
+27 -22
View File
@@ -1,26 +1,31 @@
import type { VariantProps } from "class-variance-authority" import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority" import { cva } from "class-variance-authority";
export { default as Badge } from "./Badge.vue" export { default as Badge } from "./Badge.vue";
export const badgeVariants = cva( export const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2.5 py-1 text-label whitespace-nowrap transition-[background-color,border-color,color,box-shadow] outline-none [&>svg]:pointer-events-none [&>svg]:size-3 [&>svg]:shrink-0 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
{ {
variants: { variants: {
variant: { variant: {
default: default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary: secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/80",
destructive: destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", "border-transparent bg-destructive text-destructive-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/35",
outline: outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", "border-border bg-card text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
}, success:
"border-transparent bg-success text-success-foreground [a&]:hover:brightness-95",
warning:
"border-transparent bg-warning text-warning-foreground [a&]:hover:brightness-95",
info: "border-transparent bg-info text-info-foreground [a&]:hover:brightness-95",
},
},
defaultVariants: {
variant: "default",
},
}, },
defaultVariants: { );
variant: "default", export type BadgeVariants = VariantProps<typeof badgeVariants>;
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>
+37 -19
View File
@@ -1,29 +1,47 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PrimitiveProps } from "reka-ui" import type { PrimitiveProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import type { ButtonVariants } from "." import type { ButtonVariants } from ".";
import { Primitive } from "reka-ui" import { Primitive } from "reka-ui";
import { cn } from "@/lib/utils" import { computed } from "vue";
import { buttonVariants } from "." import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
import { buttonVariants } from ".";
interface Props extends PrimitiveProps { interface Props extends PrimitiveProps {
variant?: ButtonVariants["variant"] variant?: ButtonVariants["variant"];
size?: ButtonVariants["size"] size?: ButtonVariants["size"];
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"];
disabled?: boolean;
loading?: boolean;
loadingLabel?: string;
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
as: "button", as: "button",
}) disabled: false,
loading: false,
});
const isDisabled = computed(() => props.disabled || props.loading);
</script> </script>
<template> <template>
<Primitive <Primitive
data-slot="button" data-slot="button"
:as="as" :data-loading="loading ? '' : undefined"
:as-child="asChild" :as="as"
:class="cn(buttonVariants({ variant, size }), props.class)" :as-child="asChild"
> :disabled="isDisabled"
<slot /> :aria-disabled="isDisabled ? 'true' : undefined"
</Primitive> :aria-busy="loading ? 'true' : undefined"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<Spinner
v-if="loading"
data-slot="button-spinner"
:label="loadingLabel"
/>
<slot />
</Primitive>
</template> </template>
+33 -34
View File
@@ -1,38 +1,37 @@
import type { VariantProps } from "class-variance-authority" import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority" import { cva } from "class-variance-authority";
export { default as Button } from "./Button.vue" export { default as Button } from "./Button.vue";
export const buttonVariants = cva( export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-body font-semibold transition-[background-color,border-color,color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[loading]:cursor-wait data-[loading]:opacity-80 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 focus-visible:ring-offset-2 focus-visible:ring-offset-background [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
variant: { variant: {
default: default:
"bg-primary text-primary-foreground hover:bg-primary/90", "border border-transparent bg-brand text-brand-foreground shadow-xs hover:brightness-95",
destructive: destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", "border border-transparent bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/35",
outline: outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", "border border-input bg-card text-foreground shadow-xs hover:border-primary/45 hover:bg-accent hover:text-accent-foreground",
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80", "border border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: ghost: "border border-transparent hover:bg-accent hover:text-accent-foreground",
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "min-h-0 border-0 px-0 text-primary underline-offset-4 shadow-none hover:underline",
link: "text-primary underline-offset-4 hover:underline", },
}, size: {
size: { default: "min-h-control px-4 py-2 has-[>svg]:px-3",
"default": "h-9 px-4 py-2 has-[>svg]:px-3", sm: "min-h-9 gap-1.5 px-3 py-1.5 has-[>svg]:px-2.5",
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", lg: "min-h-12 px-6 py-3 has-[>svg]:px-4",
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4", icon: "size-control",
"icon": "size-9", "icon-sm": "size-9",
"icon-sm": "size-8", "icon-lg": "size-12",
"icon-lg": "size-10", },
}, },
defaultVariants: {
variant: "default",
size: "default",
},
}, },
defaultVariants: { );
variant: "default", export type ButtonVariants = VariantProps<typeof buttonVariants>;
size: "default",
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
@@ -1,35 +1,48 @@
<script setup lang="ts"> <script setup lang="ts">
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui" import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { Check } from "lucide-vue-next" import { Check } from "lucide-vue-next";
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui" import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>() const props = withDefaults(
const emits = defineEmits<CheckboxRootEmits>() defineProps<
CheckboxRootProps & {
class?: HTMLAttributes["class"];
invalid?: boolean;
}
>(),
{ invalid: false },
);
const emits = defineEmits<CheckboxRootEmits>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class", "invalid");
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script> </script>
<template> <template>
<CheckboxRoot <CheckboxRoot
v-slot="slotProps" v-slot="slotProps"
data-slot="checkbox" data-slot="checkbox"
v-bind="forwarded" :data-invalid="invalid ? '' : undefined"
:class=" :aria-invalid="invalid ? 'true' : undefined"
cn('peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50', v-bind="forwarded"
props.class)" :class="
> cn(
<CheckboxIndicator 'peer border-input bg-card size-5 shrink-0 rounded-md border shadow-xs transition-[background-color,border-color,box-shadow] outline-none hover:border-primary/55 data-[state=checked]:border-brand data-[state=checked]:bg-brand data-[state=checked]:text-brand-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25',
data-slot="checkbox-indicator" props.class,
class="grid place-content-center text-current transition-none" )
"
> >
<slot v-bind="slotProps"> <CheckboxIndicator
<Check class="size-3.5" /> data-slot="checkbox-indicator"
</slot> class="grid place-content-center text-current transition-none"
</CheckboxIndicator> >
</CheckboxRoot> <slot v-bind="slotProps">
<Check class="size-4" stroke-width="2.5" />
</slot>
</CheckboxIndicator>
</CheckboxRoot>
</template> </template>
@@ -1,53 +1,67 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui" import type { DialogContentEmits, DialogContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { X } from "lucide-vue-next" import { X } from "lucide-vue-next";
import { import {
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogPortal, DialogPortal,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import DialogOverlay from "./DialogOverlay.vue" import DialogOverlay from "./DialogOverlay.vue";
import { useDialogFocus } from "@/composables/useDialogFocus";
import { useLocalization } from "@/composables/useLocalization";
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) });
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), { const props = withDefaults(
showCloseButton: true, defineProps<
}) DialogContentProps & {
const emits = defineEmits<DialogContentEmits>() class?: HTMLAttributes["class"];
showCloseButton?: boolean;
}
>(),
{
showCloseButton: true,
},
);
const emits = defineEmits<DialogContentEmits>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class", "showCloseButton");
const { restoreFocus } = useDialogFocus();
const { t } = useLocalization();
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script> </script>
<template> <template>
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay />
<DialogContent <DialogContent
data-slot="dialog-content" data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }" v-bind="{ ...$attrs, ...forwarded }"
:class=" @close-auto-focus="restoreFocus"
cn( :class="
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg', cn(
props.class, 'bg-card text-card-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-column overflow-y-auto overscroll-contain rounded-md border p-page shadow-lg duration-200 motion-reduce:animate-none sm:max-w-lg',
)" props.class,
> )
<slot /> "
>
<slot />
<DialogClose <DialogClose
v-if="showCloseButton" v-if="showCloseButton"
data-slot="dialog-close" data-slot="dialog-close"
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" class="ring-offset-background focus-visible:ring-ring absolute top-1 right-1 flex size-control items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
> >
<X /> <X />
<span class="sr-only">Close</span> <span class="sr-only">{{ t("common.close") }}</span>
</DialogClose> </DialogClose>
</DialogContent> </DialogContent>
</DialogPortal> </DialogPortal>
</template> </template>
@@ -1,17 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"];
}>() }>();
</script> </script>
<template> <template>
<div <div
data-slot="dialog-header" data-slot="dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)" :class="cn('flex flex-col gap-stack pr-6 text-left', props.class)"
> >
<slot /> <slot />
</div> </div>
</template> </template>
@@ -1,59 +1,71 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui" import type { DialogContentEmits, DialogContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { X } from "lucide-vue-next" import { X } from "lucide-vue-next";
import { import {
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogOverlay, DialogOverlay,
DialogPortal, DialogPortal,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { useDialogFocus } from "@/composables/useDialogFocus";
import { useLocalization } from "@/composables/useLocalization";
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) });
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<
const emits = defineEmits<DialogContentEmits>() DialogContentProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DialogContentEmits>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
const { restoreFocus } = useDialogFocus();
const { t } = useLocalization();
</script> </script>
<template> <template>
<DialogPortal> <DialogPortal>
<DialogOverlay <DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class,
)
"
v-bind="{ ...$attrs, ...forwarded }"
@pointer-down-outside="(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
event.preventDefault();
}
}"
>
<slot />
<DialogClose
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
> >
<X class="w-4 h-4" /> <DialogContent
<span class="sr-only">Close</span> :class="
</DialogClose> cn(
</DialogContent> 'relative z-50 my-4 grid w-full max-w-[calc(100%-2rem)] gap-column rounded-md border bg-card p-page text-card-foreground shadow-lg duration-200 sm:max-w-lg',
</DialogOverlay> props.class,
</DialogPortal> )
"
v-bind="{ ...$attrs, ...forwarded }"
@close-auto-focus="restoreFocus"
@pointer-down-outside="
(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (
originalEvent.offsetX > target.clientWidth ||
originalEvent.offsetY > target.clientHeight
) {
event.preventDefault();
}
}
"
>
<slot />
<DialogClose
class="absolute top-1 right-1 flex size-control items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"
>
<X class="w-4 h-4" />
<span class="sr-only">{{ t("common.close") }}</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template> </template>
@@ -1,39 +1,48 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from "reka-ui" import type {
import type { HTMLAttributes } from "vue" DropdownMenuCheckboxItemEmits,
import { reactiveOmit } from "@vueuse/core" DropdownMenuCheckboxItemProps,
import { Check } from "lucide-vue-next" } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { Check } from "lucide-vue-next";
import { import {
DropdownMenuCheckboxItem, DropdownMenuCheckboxItem,
DropdownMenuItemIndicator, DropdownMenuItemIndicator,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<
const emits = defineEmits<DropdownMenuCheckboxItemEmits>() DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DropdownMenuCheckboxItemEmits>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script> </script>
<template> <template>
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
v-bind="forwarded" v-bind="forwarded"
:class=" cn( :class="
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', cn(
props.class, 'focus:bg-accent focus:text-accent-foreground relative flex min-h-control cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
)" props.class,
> )
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> "
<DropdownMenuItemIndicator> >
<slot name="indicator-icon"> <span
<Check class="size-4" /> class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"
</slot> >
</DropdownMenuItemIndicator> <DropdownMenuItemIndicator>
</span> <slot name="indicator-icon">
<slot /> <Check class="size-4" />
</DropdownMenuCheckboxItem> </slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuCheckboxItem>
</template> </template>
@@ -1,39 +1,49 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui" import type {
import type { HTMLAttributes } from "vue" DropdownMenuContentEmits,
import { reactiveOmit } from "@vueuse/core" DropdownMenuContentProps,
} from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuPortal, DropdownMenuPortal,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) });
const props = withDefaults( const props = withDefaults(
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(), defineProps<
{ DropdownMenuContentProps & { class?: HTMLAttributes["class"] }
sideOffset: 4, >(),
}, {
) sideOffset: 4,
const emits = defineEmits<DropdownMenuContentEmits>() },
);
const emits = defineEmits<DropdownMenuContentEmits>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script> </script>
<template> <template>
<DropdownMenuPortal> <DropdownMenuPortal>
<DropdownMenuContent <DropdownMenuContent
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
v-bind="{ ...$attrs, ...forwarded }" v-bind="{ ...$attrs, ...forwarded }"
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', props.class)" :class="
> cn(
<slot /> 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] max-w-[calc(100vw-2rem)] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
</DropdownMenuContent> props.class,
</DropdownMenuPortal> )
"
>
<slot />
</DropdownMenuContent>
</DropdownMenuPortal>
</template> </template>
@@ -1,31 +1,41 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuItemProps } from "reka-ui" import type { DropdownMenuItemProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { DropdownMenuItem, useForwardProps } from "reka-ui" import { DropdownMenuItem, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = withDefaults(defineProps<DropdownMenuItemProps & { const props = withDefaults(
class?: HTMLAttributes["class"] defineProps<
inset?: boolean DropdownMenuItemProps & {
variant?: "default" | "destructive" class?: HTMLAttributes["class"];
}>(), { inset?: boolean;
variant: "default", variant?: "default" | "destructive";
}) }
>(),
{
variant: "default",
},
);
const delegatedProps = reactiveOmit(props, "inset", "variant", "class") const delegatedProps = reactiveOmit(props, "inset", "variant", "class");
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps);
</script> </script>
<template> <template>
<DropdownMenuItem <DropdownMenuItem
data-slot="dropdown-menu-item" data-slot="dropdown-menu-item"
:data-inset="inset ? '' : undefined" :data-inset="inset ? '' : undefined"
:data-variant="variant" :data-variant="variant"
v-bind="forwardedProps" v-bind="forwardedProps"
:class="cn('focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', props.class)" :class="
> cn(
<slot /> 'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex min-h-control cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
</DropdownMenuItem> props.class,
)
"
>
<slot />
</DropdownMenuItem>
</template> </template>
@@ -1,40 +1,49 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from "reka-ui" import type {
import type { HTMLAttributes } from "vue" DropdownMenuRadioItemEmits,
import { reactiveOmit } from "@vueuse/core" DropdownMenuRadioItemProps,
import { Circle } from "lucide-vue-next" } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { Circle } from "lucide-vue-next";
import { import {
DropdownMenuItemIndicator, DropdownMenuItemIndicator,
DropdownMenuRadioItem, DropdownMenuRadioItem,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<
DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DropdownMenuRadioItemEmits>() const emits = defineEmits<DropdownMenuRadioItemEmits>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script> </script>
<template> <template>
<DropdownMenuRadioItem <DropdownMenuRadioItem
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
v-bind="forwarded" v-bind="forwarded"
:class="cn( :class="
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', cn(
props.class, 'focus:bg-accent focus:text-accent-foreground relative flex min-h-control cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
)" props.class,
> )
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> "
<DropdownMenuItemIndicator> >
<slot name="indicator-icon"> <span
<Circle class="size-2 fill-current" /> class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"
</slot> >
</DropdownMenuItemIndicator> <DropdownMenuItemIndicator>
</span> <slot name="indicator-icon">
<slot /> <Circle class="size-2 fill-current" />
</DropdownMenuRadioItem> </slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuRadioItem>
</template> </template>
@@ -1,27 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui" import type {
import type { HTMLAttributes } from "vue" DropdownMenuSubContentEmits,
import { reactiveOmit } from "@vueuse/core" DropdownMenuSubContentProps,
import { } from "reka-ui";
DropdownMenuSubContent, import type { HTMLAttributes } from "vue";
useForwardPropsEmits, import { reactiveOmit } from "@vueuse/core";
} from "reka-ui" import { DropdownMenuSubContent, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<
const emits = defineEmits<DropdownMenuSubContentEmits>() DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DropdownMenuSubContentEmits>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script> </script>
<template> <template>
<DropdownMenuSubContent <DropdownMenuSubContent
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
v-bind="forwarded" v-bind="forwarded"
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', props.class)" :class="
> cn(
<slot /> 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] max-w-[calc(100vw-2rem)] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-lg',
</DropdownMenuSubContent> props.class,
)
"
>
<slot />
</DropdownMenuSubContent>
</template> </template>
@@ -1,30 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuSubTriggerProps } from "reka-ui" import type { DropdownMenuSubTriggerProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { ChevronRight } from "lucide-vue-next" import { ChevronRight } from "lucide-vue-next";
import { import { DropdownMenuSubTrigger, useForwardProps } from "reka-ui";
DropdownMenuSubTrigger, import { cn } from "@/lib/utils";
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes["class"], inset?: boolean }>() const props = defineProps<
DropdownMenuSubTriggerProps & {
class?: HTMLAttributes["class"];
inset?: boolean;
}
>();
const delegatedProps = reactiveOmit(props, "class", "inset") const delegatedProps = reactiveOmit(props, "class", "inset");
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps);
</script> </script>
<template> <template>
<DropdownMenuSubTrigger <DropdownMenuSubTrigger
data-slot="dropdown-menu-sub-trigger" data-slot="dropdown-menu-sub-trigger"
v-bind="forwardedProps" v-bind="forwardedProps"
:class="cn( :class="
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8', cn(
props.class, 'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex min-h-control cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
)" props.class,
> )
<slot /> "
<ChevronRight class="ml-auto size-4" /> >
</DropdownMenuSubTrigger> <slot />
<ChevronRight class="ml-auto size-4" />
</DropdownMenuSubTrigger>
</template> </template>
+27 -22
View File
@@ -1,33 +1,38 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { useVModel } from "@vueuse/core" import { useVModel } from "@vueuse/core";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<{ const props = defineProps<{
defaultValue?: string | number defaultValue?: string | number;
modelValue?: string | number | null modelValue?: string | number | null;
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"];
}>() invalid?: boolean;
}>();
const emits = defineEmits<{ const emits = defineEmits<{
(e: "update:modelValue", payload: string | number): void (e: "update:modelValue", payload: string | number): void;
}>() }>();
const modelValue = useVModel(props, "modelValue", emits, { const modelValue = useVModel(props, "modelValue", emits, {
passive: true, passive: true,
defaultValue: props.defaultValue, defaultValue: props.defaultValue,
}) });
</script> </script>
<template> <template>
<input <input
v-model="modelValue" v-model="modelValue"
data-slot="input" data-slot="input"
:class="cn( :data-invalid="invalid ? '' : undefined"
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', :aria-invalid="invalid ? 'true' : undefined"
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]', :class="
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', cn(
props.class, 'border-input bg-card placeholder:text-muted-foreground selection:bg-brand selection:text-brand-foreground min-h-control w-full min-w-0 rounded-md border px-3 py-2 text-base shadow-xs transition-[border-color,box-shadow] outline-none file:mr-3 file:inline-flex file:min-h-8 file:border-0 file:bg-transparent file:text-body file:font-semibold disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 md:text-body',
)" 'hover:border-primary/45 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35',
> 'aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25',
props.class,
)
"
/>
</template> </template>
@@ -1,51 +1,60 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectContentEmits, SelectContentProps } from "reka-ui" import type { SelectContentEmits, SelectContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { import {
SelectContent, SelectContent,
SelectPortal, SelectPortal,
SelectViewport, SelectViewport,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { SelectScrollDownButton, SelectScrollUpButton } from "." import { SelectScrollDownButton, SelectScrollUpButton } from ".";
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) });
const props = withDefaults( const props = withDefaults(
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(), defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
{ {
position: "popper", position: "popper",
}, },
) );
const emits = defineEmits<SelectContentEmits>() const emits = defineEmits<SelectContentEmits>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script> </script>
<template> <template>
<SelectPortal> <SelectPortal>
<SelectContent <SelectContent
data-slot="select-content" data-slot="select-content"
v-bind="{ ...$attrs, ...forwarded }" v-bind="{ ...$attrs, ...forwarded }"
:class="cn( :class="
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md', cn(
position === 'popper' 'bg-popover text-popover-foreground border-border data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-lg',
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', position === 'popper' &&
props.class, 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
) props.class,
" )
> "
<SelectScrollUpButton /> >
<SelectViewport :class="cn('p-1', position === 'popper' && 'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1')"> <SelectScrollUpButton />
<slot /> <SelectViewport
</SelectViewport> :class="
<SelectScrollDownButton /> cn(
</SelectContent> 'p-1',
</SelectPortal> position === 'popper' &&
'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1',
)
"
>
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template> </template>
@@ -1,44 +1,48 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectItemProps } from "reka-ui" import type { SelectItemProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { Check } from "lucide-vue-next" import { Check } from "lucide-vue-next";
import { import {
SelectItem, SelectItem,
SelectItemIndicator, SelectItemIndicator,
SelectItemText, SelectItemText,
useForwardProps, useForwardProps,
} from "reka-ui" } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<SelectItemProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<
SelectItemProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps);
</script> </script>
<template> <template>
<SelectItem <SelectItem
data-slot="select-item" data-slot="select-item"
v-bind="forwardedProps" v-bind="forwardedProps"
:class=" :class="
cn( cn(
'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2', 'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex min-h-9 w-full cursor-default items-center gap-2 rounded-sm py-2 pr-8 pl-2 text-body outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
props.class, props.class,
) )
" "
> >
<span class="absolute right-2 flex size-3.5 items-center justify-center"> <span
<SelectItemIndicator> class="absolute right-2 flex size-3.5 items-center justify-center"
<slot name="indicator-icon"> >
<Check class="size-4" /> <SelectItemIndicator>
</slot> <slot name="indicator-icon">
</SelectItemIndicator> <Check class="size-4" />
</span> </slot>
</SelectItemIndicator>
</span>
<SelectItemText> <SelectItemText>
<slot /> <slot />
</SelectItemText> </SelectItemText>
</SelectItem> </SelectItem>
</template> </template>
@@ -1,33 +1,45 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectTriggerProps } from "reka-ui" import type { SelectTriggerProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { ChevronDown } from "lucide-vue-next" import { ChevronDown } from "lucide-vue-next";
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui" import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = withDefaults( const props = withDefaults(
defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"], size?: "sm" | "default" }>(), defineProps<
{ size: "default" }, SelectTriggerProps & {
) class?: HTMLAttributes["class"];
size?: "sm" | "default";
invalid?: boolean;
}
>(),
{ size: "default", invalid: false },
);
const delegatedProps = reactiveOmit(props, "class", "size") const delegatedProps = reactiveOmit(props, "class", "size", "invalid");
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps);
</script> </script>
<template> <template>
<SelectTrigger <SelectTrigger
data-slot="select-trigger" data-slot="select-trigger"
:data-size="size" :data-size="size"
v-bind="forwardedProps" :data-invalid="invalid ? '' : undefined"
:class="cn( :aria-invalid="invalid ? 'true' : undefined"
'border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', v-bind="forwardedProps"
props.class, :class="
)" cn(
> 'group/select-trigger border-input bg-card data-[placeholder]:text-muted-foreground flex w-fit items-center justify-between gap-2 rounded-md border px-3 py-2 text-body whitespace-nowrap shadow-xs transition-[border-color,box-shadow] outline-none hover:border-primary/45 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 data-[state=open]:border-primary data-[size=default]:min-h-control data-[size=sm]:min-h-9 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 [&_svg:not([class*=\'text-\'])]:text-muted-foreground',
<slot /> props.class,
<SelectIcon as-child> )
<ChevronDown class="size-4 opacity-50" /> "
</SelectIcon> >
</SelectTrigger> <slot />
<SelectIcon as-child>
<ChevronDown
class="size-4 transition-transform duration-150 group-data-[state=open]/select-trigger:rotate-180"
/>
</SelectIcon>
</SelectTrigger>
</template> </template>
@@ -1,62 +1,70 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui" import type { DialogContentEmits, DialogContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { X } from "lucide-vue-next" import { X } from "lucide-vue-next";
import { import {
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogPortal, DialogPortal,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import SheetOverlay from "./SheetOverlay.vue" import SheetOverlay from "./SheetOverlay.vue";
import { useDialogFocus } from "@/composables/useDialogFocus";
import { useLocalization } from "@/composables/useLocalization";
interface SheetContentProps extends DialogContentProps { interface SheetContentProps extends DialogContentProps {
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"];
side?: "top" | "right" | "bottom" | "left" side?: "top" | "right" | "bottom" | "left";
} }
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) });
const props = withDefaults(defineProps<SheetContentProps>(), { const props = withDefaults(defineProps<SheetContentProps>(), {
side: "right", side: "right",
}) });
const emits = defineEmits<DialogContentEmits>() const emits = defineEmits<DialogContentEmits>();
const delegatedProps = reactiveOmit(props, "class", "side") const delegatedProps = reactiveOmit(props, "class", "side");
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
const { restoreFocus } = useDialogFocus();
const { t } = useLocalization();
</script> </script>
<template> <template>
<DialogPortal> <DialogPortal>
<SheetOverlay /> <SheetOverlay />
<DialogContent <DialogContent
data-slot="sheet-content" data-slot="sheet-content"
:class="cn( :class="
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500', cn(
side === 'right' 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
&& 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm', side === 'right' &&
side === 'left' 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
&& 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm', side === 'left' &&
side === 'top' 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
&& 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b', side === 'top' &&
side === 'bottom' 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
&& 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t', side === 'bottom' &&
props.class)" 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
v-bind="{ ...$attrs, ...forwarded }" props.class,
> )
<slot /> "
v-bind="{ ...$attrs, ...forwarded }"
@close-auto-focus="restoreFocus"
>
<slot />
<DialogClose <DialogClose
class="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none" class="ring-offset-background focus-visible:ring-ring absolute top-1 right-1 flex size-control items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none"
> >
<X class="size-4" /> <X class="size-4" />
<span class="sr-only">Close</span> <span class="sr-only">{{ t("common.close") }}</span>
</DialogClose> </DialogClose>
</DialogContent> </DialogContent>
</DialogPortal> </DialogPortal>
</template> </template>
+104 -82
View File
@@ -1,96 +1,118 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SidebarProps } from "." import type { SidebarProps } from ".";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Sheet, SheetContent } from '@/components/ui/sheet' import { Sheet, SheetContent } from "@/components/ui/sheet";
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue' import SheetDescription from "@/components/ui/sheet/SheetDescription.vue";
import SheetHeader from '@/components/ui/sheet/SheetHeader.vue' import SheetHeader from "@/components/ui/sheet/SheetHeader.vue";
import SheetTitle from '@/components/ui/sheet/SheetTitle.vue' import SheetTitle from "@/components/ui/sheet/SheetTitle.vue";
import { SIDEBAR_WIDTH_MOBILE, useSidebar } from "./utils" import { SIDEBAR_WIDTH_MOBILE, useSidebar } from "./utils";
import { useLocalization } from "@/composables/useLocalization";
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) });
const props = withDefaults(defineProps<SidebarProps>(), { const props = withDefaults(defineProps<SidebarProps>(), {
side: "left", side: "left",
variant: "sidebar", variant: "sidebar",
collapsible: "offcanvas", collapsible: "offcanvas",
}) });
const { isMobile, state, openMobile, setOpenMobile } = useSidebar() const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
const { t } = useLocalization();
function restoreTriggerFocus(event: Event) {
event.preventDefault();
document
.querySelector<HTMLButtonElement>('[data-sidebar="trigger"]')
?.focus();
}
</script> </script>
<template> <template>
<div
v-if="collapsible === 'none'"
data-slot="sidebar"
:class="cn('bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col', props.class)"
v-bind="$attrs"
>
<slot />
</div>
<Sheet v-else-if="isMobile" :open="openMobile" v-bind="$attrs" @update:open="setOpenMobile">
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
:side="side"
class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
:style="{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
}"
>
<SheetHeader class="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div class="flex h-full w-full flex-col">
<slot />
</div>
</SheetContent>
</Sheet>
<div
v-else
class="group peer text-sidebar-foreground hidden md:block"
data-slot="sidebar"
:data-state="state"
:data-collapsible="state === 'collapsed' ? collapsible : ''"
:data-variant="variant"
:data-side="side"
>
<!-- This is what handles the sidebar gap on desktop -->
<div <div
:class="cn( v-if="collapsible === 'none'"
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear', data-slot="sidebar"
'group-data-[collapsible=offcanvas]:w-0', :class="
'group-data-[side=right]:rotate-180', cn(
variant === 'floating' || variant === 'inset' 'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]' props.class,
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)', )
)" "
/> v-bind="$attrs"
<div
:class="cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
props.class,
)"
v-bind="$attrs"
> >
<div
data-sidebar="sidebar"
class="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
<slot /> <slot />
</div>
</div> </div>
</div>
<Sheet v-else-if="isMobile" :open="openMobile" @update:open="setOpenMobile">
<SheetContent
v-bind="$attrs"
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
:side="side"
class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) max-w-[calc(100vw-2rem)] gap-0 p-0 [&>button]:hidden"
@close-auto-focus="restoreTriggerFocus"
:style="{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
}"
>
<SheetHeader class="sr-only">
<SheetTitle>{{ t("navigation.main") }}</SheetTitle>
<SheetDescription>{{
t("navigation.mobileDescription")
}}</SheetDescription>
</SheetHeader>
<div class="flex min-h-0 h-full w-full flex-col">
<slot />
</div>
</SheetContent>
</Sheet>
<div
v-else
class="group peer text-sidebar-foreground hidden md:block"
data-slot="sidebar"
:data-state="state"
:data-collapsible="state === 'collapsed' ? collapsible : ''"
:data-variant="variant"
:data-side="side"
>
<!-- This is what handles the sidebar gap on desktop -->
<div
:class="
cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
)
"
/>
<div
:class="
cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
props.class,
)
"
v-bind="$attrs"
>
<div
data-sidebar="sidebar"
class="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
<slot />
</div>
</div>
</div>
</template> </template>
@@ -1,18 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"];
}>() }>();
</script> </script>
<template> <template>
<div <div
data-slot="sidebar-content" data-slot="sidebar-content"
data-sidebar="content" data-sidebar="content"
:class="cn('flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden', props.class)" :class="
> cn(
<slot /> 'flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto',
</div> props.class,
)
"
>
<slot />
</div>
</template> </template>
@@ -1,21 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"];
}>() }>();
</script> </script>
<template> <template>
<main <main
data-slot="sidebar-inset" data-slot="sidebar-inset"
:class="cn( :class="
'bg-background relative flex w-full flex-1 flex-col', cn(
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2', 'bg-background relative flex min-w-0 w-full flex-1 flex-col',
props.class, 'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
)" props.class,
> )
<slot /> "
</main> >
<slot />
</main>
</template> </template>
@@ -1,48 +1,60 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Component } from "vue" import type { Component } from "vue";
import type { SidebarMenuButtonProps } from "./SidebarMenuButtonChild.vue" import type { SidebarMenuButtonProps } from "./SidebarMenuButtonChild.vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import {
import SidebarMenuButtonChild from "./SidebarMenuButtonChild.vue" Tooltip,
import { useSidebar } from "./utils" TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import SidebarMenuButtonChild from "./SidebarMenuButtonChild.vue";
import { useSidebar } from "./utils";
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) });
const props = withDefaults(defineProps<SidebarMenuButtonProps & { const props = withDefaults(
tooltip?: string | Component defineProps<
}>(), { SidebarMenuButtonProps & {
as: "button", tooltip?: string | Component;
variant: "default", }
size: "default", >(),
}) {
as: "button",
variant: "default",
size: "default",
},
);
const { isMobile, state } = useSidebar() const { isMobile, state } = useSidebar();
const delegatedProps = reactiveOmit(props, "tooltip") const delegatedProps = reactiveOmit(props, "tooltip");
</script> </script>
<template> <template>
<SidebarMenuButtonChild v-if="!tooltip" v-bind="{ ...delegatedProps, ...$attrs }"> <SidebarMenuButtonChild
<slot /> v-if="!tooltip || isMobile || state !== 'collapsed'"
</SidebarMenuButtonChild> v-bind="{ ...delegatedProps, ...$attrs }"
<Tooltip v-else>
<TooltipTrigger as-child>
<SidebarMenuButtonChild v-bind="{ ...delegatedProps, ...$attrs }">
<slot />
</SidebarMenuButtonChild>
</TooltipTrigger>
<TooltipContent
side="right"
align="center"
:hidden="state !== 'collapsed' || isMobile"
> >
<template v-if="typeof tooltip === 'string'"> <slot />
{{ tooltip }} </SidebarMenuButtonChild>
</template>
<component :is="tooltip" v-else /> <Tooltip v-else>
</TooltipContent> <TooltipTrigger as-child>
</Tooltip> <SidebarMenuButtonChild v-bind="{ ...delegatedProps, ...$attrs }">
<slot />
</SidebarMenuButtonChild>
</TooltipTrigger>
<TooltipContent
side="right"
align="center"
:hidden="state !== 'collapsed' || isMobile"
>
<template v-if="typeof tooltip === 'string'">
{{ tooltip }}
</template>
<component :is="tooltip" v-else />
</TooltipContent>
</Tooltip>
</template> </template>
@@ -1,82 +1,112 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes, Ref } from "vue" import type { HTMLAttributes, Ref } from "vue";
import { defaultDocument, useEventListener, useMediaQuery, useVModel } from "@vueuse/core" import {
import { TooltipProvider } from "reka-ui" defaultDocument,
import { computed, ref } from "vue" useEventListener,
import { cn } from "@/lib/utils" useMediaQuery,
import { provideSidebarContext, SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, SIDEBAR_KEYBOARD_SHORTCUT, SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON } from "./utils" useVModel,
} from "@vueuse/core";
import { TooltipProvider } from "reka-ui";
import { computed, ref, watch } from "vue";
import { cn } from "@/lib/utils";
import {
provideSidebarContext,
SIDEBAR_COOKIE_MAX_AGE,
SIDEBAR_COOKIE_NAME,
SIDEBAR_KEYBOARD_SHORTCUT,
SIDEBAR_WIDTH,
SIDEBAR_WIDTH_ICON,
} from "./utils";
const props = withDefaults(defineProps<{ const props = withDefaults(
defaultOpen?: boolean defineProps<{
open?: boolean defaultOpen?: boolean;
class?: HTMLAttributes["class"] open?: boolean;
}>(), { class?: HTMLAttributes["class"];
defaultOpen: !defaultDocument?.cookie.includes(`${SIDEBAR_COOKIE_NAME}=false`), }>(),
open: undefined, {
}) defaultOpen: !defaultDocument?.cookie.includes(
`${SIDEBAR_COOKIE_NAME}=false`,
),
open: undefined,
},
);
const emits = defineEmits<{ const emits = defineEmits<{
"update:open": [open: boolean] "update:open": [open: boolean];
}>() }>();
const isMobile = useMediaQuery("(max-width: 768px)") const isMobile = useMediaQuery("(width < 768px)");
const openMobile = ref(false) const openMobile = ref(false);
watch(isMobile, () => {
openMobile.value = false;
});
const open = useVModel(props, "open", emits, { const open = useVModel(props, "open", emits, {
defaultValue: props.defaultOpen ?? false, defaultValue: props.defaultOpen ?? false,
passive: (props.open === undefined) as false, passive: (props.open === undefined) as false,
}) as Ref<boolean> }) as Ref<boolean>;
function setOpen(value: boolean) { function setOpen(value: boolean) {
open.value = value // emits('update:open', value) open.value = value; // emits('update:open', value)
// This sets the cookie to keep the sidebar state. // This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
} }
function setOpenMobile(value: boolean) { function setOpenMobile(value: boolean) {
openMobile.value = value openMobile.value = value;
} }
// Helper to toggle the sidebar. // Helper to toggle the sidebar.
function toggleSidebar() { function toggleSidebar() {
return isMobile.value ? setOpenMobile(!openMobile.value) : setOpen(!open.value) return isMobile.value
? setOpenMobile(!openMobile.value)
: setOpen(!open.value);
} }
useEventListener("keydown", (event: KeyboardEvent) => { useEventListener("keydown", (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { if (
event.preventDefault() event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
toggleSidebar() (event.metaKey || event.ctrlKey)
} ) {
}) event.preventDefault();
toggleSidebar();
}
});
// We add a state so that we can do data-state="expanded" or "collapsed". // We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes. // This makes it easier to style the sidebar with Tailwind classes.
const state = computed(() => open.value ? "expanded" : "collapsed") const state = computed(() => (open.value ? "expanded" : "collapsed"));
provideSidebarContext({ provideSidebarContext({
state, state,
open, open,
setOpen, setOpen,
isMobile, isMobile,
openMobile, openMobile,
setOpenMobile, setOpenMobile,
toggleSidebar, toggleSidebar,
}) });
</script> </script>
<template> <template>
<TooltipProvider :delay-duration="0"> <TooltipProvider :delay-duration="0">
<div <div
data-slot="sidebar-wrapper" data-slot="sidebar-wrapper"
:style="{ :style="{
'--sidebar-width': SIDEBAR_WIDTH, '--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON, '--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
}" }"
:class="cn('group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full', props.class)" :class="
v-bind="$attrs" cn(
> 'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
<slot /> props.class,
</div> )
</TooltipProvider> "
v-bind="$attrs"
>
<slot />
</div>
</TooltipProvider>
</template> </template>
@@ -1,28 +1,44 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { PanelLeftClose, PanelLeftOpen } from "lucide-vue-next" import { PanelLeftClose, PanelLeftOpen } from "lucide-vue-next";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { useSidebar } from "./utils" import { useSidebar } from "./utils";
import { useLocalization } from "@/composables/useLocalization";
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"];
}>() }>();
const { isMobile, state, toggleSidebar } = useSidebar() const { isMobile, state, openMobile, toggleSidebar } = useSidebar();
const { t } = useLocalization();
</script> </script>
<template> <template>
<Button <Button
data-sidebar="trigger" data-sidebar="trigger"
data-slot="sidebar-trigger" data-slot="sidebar-trigger"
variant="ghost" variant="ghost"
size="icon" size="icon"
:class="cn('h-7 w-7', props.class)" type="button"
@click="toggleSidebar" aria-controls="app-navigation"
> :aria-expanded="isMobile ? openMobile : state === 'expanded'"
<PanelLeftOpen v-if="isMobile || state === 'collapsed'" /> :aria-label="
<PanelLeftClose v-else /> isMobile
<span class="sr-only">Toggle sidebar</span> ? t(openMobile ? 'navigation.close' : 'navigation.open')
</Button> : t(
state === 'collapsed'
? 'navigation.expand'
: 'navigation.collapse',
)
"
:class="cn('size-control', props.class)"
@click="toggleSidebar"
>
<PanelLeftOpen
v-if="isMobile || state === 'collapsed'"
aria-hidden="true"
/>
<PanelLeftClose v-else aria-hidden="true" />
</Button>
</template> </template>
+11 -10
View File
@@ -1,8 +1,8 @@
import { createContext } from "reka-ui" import { createContext } from "reka-ui";
import type { ComputedRef, Ref } from 'vue'; import type { ComputedRef, Ref } from "vue";
export type SidebarContext = { export type SidebarContext = {
state: ComputedRef<'expanded' | 'collapsed'>; state: ComputedRef<"expanded" | "collapsed">;
open: Ref<boolean>; open: Ref<boolean>;
setOpen: (value: boolean) => void; setOpen: (value: boolean) => void;
isMobile: Ref<boolean>; isMobile: Ref<boolean>;
@@ -11,11 +11,12 @@ export type SidebarContext = {
toggleSidebar: () => void; toggleSidebar: () => void;
}; };
export const SIDEBAR_COOKIE_NAME = "sidebar_state" export const SIDEBAR_COOKIE_NAME = "sidebar_state";
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
export const SIDEBAR_WIDTH = "16rem" export const SIDEBAR_WIDTH = "16rem";
export const SIDEBAR_WIDTH_MOBILE = "18rem" export const SIDEBAR_WIDTH_MOBILE = "18rem";
export const SIDEBAR_WIDTH_ICON = "3rem" export const SIDEBAR_WIDTH_ICON = "4rem";
export const SIDEBAR_KEYBOARD_SHORTCUT = "b" export const SIDEBAR_KEYBOARD_SHORTCUT = "b";
export const [useSidebar, provideSidebarContext] = createContext<SidebarContext>("Sidebar") export const [useSidebar, provideSidebarContext] =
createContext<SidebarContext>("Sidebar");
+16 -10
View File
@@ -1,17 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { Loader2Icon } from "lucide-vue-next" import { Loader2Icon } from "lucide-vue-next";
import { cn } from "@/lib/utils" import { computed } from "vue";
import { useLocalization } from "@/composables/useLocalization";
import { cn } from "@/lib/utils";
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"];
}>() label?: string;
}>();
const { t } = useLocalization();
const accessibleLabel = computed(() => props.label ?? t("common.loading"));
</script> </script>
<template> <template>
<Loader2Icon <Loader2Icon
role="status" role="status"
aria-label="Loading" :aria-label="accessibleLabel"
:class="cn('size-4 animate-spin', props.class)" :class="cn('size-4 animate-spin', props.class)"
/> />
</template> </template>
@@ -0,0 +1,25 @@
import { usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
import { useLocalization } from '@/composables/useLocalization';
import {
projectNavigation,
projectSettingsNavigation,
workspaceNavigation,
} from '@/lib/navigation';
export function useAppNavigation() {
const page = usePage();
const { t } = useLocalization();
const project = computed(() => page.props.navigation?.project ?? null);
return {
project,
workspaceItems: computed(() =>
workspaceNavigation(page.props.auth.permissions, t),
),
projectItems: computed(() => projectNavigation(project.value, t)),
projectSettingsItems: computed(() =>
projectSettingsNavigation(project.value, t),
),
};
}
+80 -97
View File
@@ -1,124 +1,107 @@
import type { ComputedRef, Ref } from 'vue'; import type { App, ComputedRef, InjectionKey, Ref } from 'vue';
import { computed, onMounted, ref } from 'vue'; import { computed, inject, readonly, ref } from 'vue';
import type { Appearance, ResolvedAppearance } from '@/types'; import type { Appearance, ResolvedAppearance } from '@/types';
export type { Appearance, ResolvedAppearance }; export type { Appearance, ResolvedAppearance };
export type UseAppearanceReturn = { export type UseAppearanceReturn = {
appearance: Ref<Appearance>; appearance: Readonly<Ref<Appearance>>;
resolvedAppearance: ComputedRef<ResolvedAppearance>; resolvedAppearance: ComputedRef<ResolvedAppearance>;
updateAppearance: (value: Appearance) => void; updateAppearance: (value: Appearance) => void;
}; };
export function updateTheme(value: Appearance): void { const appearanceKey: InjectionKey<UseAppearanceReturn> = Symbol('appearance');
if (typeof window === 'undefined') {
return;
}
if (value === 'system') { export function normalizeAppearance(value: unknown): Appearance {
const mediaQueryList = window.matchMedia( return value === 'light' || value === 'dark' ? value : 'system';
'(prefers-color-scheme: dark)', }
);
const systemTheme = mediaQueryList.matches ? 'dark' : 'light';
/** Each Vue app owns its state, including each server-rendered request. */
export function createAppearance(initialAppearance: Appearance = 'system') {
const appearance = ref<Appearance>(normalizeAppearance(initialAppearance));
const systemDark = ref(false);
const resolvedAppearance = computed<ResolvedAppearance>(() =>
appearance.value === 'system'
? systemDark.value
? 'dark'
: 'light'
: appearance.value,
);
function applyTheme(): void {
document.documentElement.classList.toggle( document.documentElement.classList.toggle(
'dark', 'dark',
systemTheme === 'dark', resolvedAppearance.value === 'dark',
); );
} else { document.documentElement.style.colorScheme = resolvedAppearance.value;
document.documentElement.classList.toggle('dark', value === 'dark'); document.documentElement.dataset.appearance = appearance.value;
}
}
const setCookie = (name: string, value: string, days = 365) => {
if (typeof document === 'undefined') {
return;
} }
const maxAge = days * 24 * 60 * 60; function updateAppearance(value: Appearance): void {
appearance.value = normalizeAppearance(value);
document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`; if (typeof window === 'undefined') {
}; return;
const mediaQuery = () => {
if (typeof window === 'undefined') {
return null;
}
return window.matchMedia('(prefers-color-scheme: dark)');
};
const getStoredAppearance = () => {
if (typeof window === 'undefined') {
return null;
}
return localStorage.getItem('appearance') as Appearance | null;
};
const prefersDark = (): boolean => {
if (typeof window === 'undefined') {
return false;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches;
};
const handleSystemThemeChange = () => {
const currentAppearance = getStoredAppearance();
updateTheme(currentAppearance || 'system');
};
export function initializeTheme(): void {
if (typeof window === 'undefined') {
return;
}
// Initialize theme from saved preference or default to system...
const savedAppearance = getStoredAppearance();
updateTheme(savedAppearance || 'system');
// Set up system theme change listener...
mediaQuery()?.addEventListener('change', handleSystemThemeChange);
}
const appearance = ref<Appearance>('system');
export function useAppearance(): UseAppearanceReturn {
onMounted(() => {
const savedAppearance = localStorage.getItem(
'appearance',
) as Appearance | null;
if (savedAppearance) {
appearance.value = savedAppearance;
}
});
const resolvedAppearance = computed<ResolvedAppearance>(() => {
if (appearance.value === 'system') {
return prefersDark() ? 'dark' : 'light';
} }
return appearance.value; applyTheme();
}); try {
window.localStorage.setItem('appearance', appearance.value);
} catch {
// Private or embedded browsers may deny storage; the current theme still works.
}
document.cookie = `appearance=${appearance.value};path=/;max-age=31536000;SameSite=Lax`;
}
function updateAppearance(value: Appearance) { function handleSystemChange(event: MediaQueryListEvent): void {
appearance.value = value; systemDark.value = event.matches;
applyTheme();
}
// Store in localStorage for client-side persistence... function handleStorageChange(event: StorageEvent): void {
localStorage.setItem('appearance', value); if (event.key === 'appearance' || event.key === null) {
updateAppearance(normalizeAppearance(event.newValue));
// Store in cookie for SSR... }
setCookie('appearance', value);
updateTheme(value);
} }
return { return {
appearance, appearance: readonly(appearance),
resolvedAppearance, resolvedAppearance,
updateAppearance, updateAppearance,
install(app: App): void {
app.provide(appearanceKey, {
appearance: readonly(appearance),
resolvedAppearance,
updateAppearance,
});
if (typeof window === 'undefined') {
return;
}
// The blocking head script already reconciled local storage and the SSR cookie.
appearance.value = normalizeAppearance(
document.documentElement.dataset.appearance ??
initialAppearance,
);
const media = window.matchMedia('(prefers-color-scheme: dark)');
systemDark.value = media.matches;
applyTheme();
media.addEventListener('change', handleSystemChange);
window.addEventListener('storage', handleStorageChange);
app.onUnmount(() => {
media.removeEventListener('change', handleSystemChange);
window.removeEventListener('storage', handleStorageChange);
});
},
}; };
} }
export function useAppearance(): UseAppearanceReturn {
const appearance = inject(appearanceKey);
if (!appearance) {
throw new Error(
'The appearance plugin must be installed before useAppearance().',
);
}
return appearance;
}
+14 -32
View File
@@ -3,6 +3,7 @@ import { usePage } from '@inertiajs/vue3';
import type { ComputedRef, DeepReadonly } from 'vue'; import type { ComputedRef, DeepReadonly } from 'vue';
import { computed, readonly } from 'vue'; import { computed, readonly } from 'vue';
import { toUrl } from '@/lib/utils'; import { toUrl } from '@/lib/utils';
import { matchesNavigationUrl } from '@/lib/navigation';
export type UseCurrentUrlReturn = { export type UseCurrentUrlReturn = {
currentUrl: DeepReadonly<ComputedRef<string>>; currentUrl: DeepReadonly<ComputedRef<string>>;
@@ -22,40 +23,21 @@ export type UseCurrentUrlReturn = {
) => T | F; ) => T | F;
}; };
const page = usePage();
const currentUrlReactive = computed(
() =>
new URL(
page.url,
typeof window !== 'undefined'
? window.location.origin
: 'http://localhost',
).pathname,
);
export function useCurrentUrl(): UseCurrentUrlReturn { export function useCurrentUrl(): UseCurrentUrlReturn {
const page = usePage();
const currentUrlReactive = computed(
() => new URL(page.url, 'http://localhost').pathname,
);
function isCurrentUrl( function isCurrentUrl(
urlToCheck: NonNullable<InertiaLinkProps['href']>, urlToCheck: NonNullable<InertiaLinkProps['href']>,
currentUrl?: string, currentUrl?: string,
startsWith: boolean = false, startsWith: boolean = false,
) { ) {
const urlToCompare = currentUrl ?? currentUrlReactive.value; return matchesNavigationUrl(
const urlString = toUrl(urlToCheck); toUrl(urlToCheck),
currentUrl ?? currentUrlReactive.value,
const comparePath = (path: string): boolean => startsWith,
startsWith ? urlToCompare.startsWith(path) : path === urlToCompare; );
if (!urlString.startsWith('http')) {
return comparePath(urlString);
}
try {
const absoluteUrl = new URL(urlString);
return comparePath(absoluteUrl.pathname);
} catch {
return false;
}
} }
function isCurrentOrParentUrl( function isCurrentOrParentUrl(
@@ -65,11 +47,11 @@ export function useCurrentUrl(): UseCurrentUrlReturn {
return isCurrentUrl(urlToCheck, currentUrl, true); return isCurrentUrl(urlToCheck, currentUrl, true);
} }
function whenCurrentUrl( function whenCurrentUrl<T, F = null>(
urlToCheck: NonNullable<InertiaLinkProps['href']>, urlToCheck: NonNullable<InertiaLinkProps['href']>,
ifTrue: any, ifTrue: T,
ifFalse: any = null, ifFalse: F = null as F,
) { ): T | F {
return isCurrentUrl(urlToCheck) ? ifTrue : ifFalse; return isCurrentUrl(urlToCheck) ? ifTrue : ifFalse;
} }
@@ -0,0 +1,34 @@
import { injectDialogRootContext } from 'reka-ui';
import { watch } from 'vue';
/** Reka restores registered triggers; also support dialogs opened programmatically. */
export function useDialogFocus() {
const context = injectDialogRootContext();
let previousFocus: HTMLElement | null = null;
watch(
context.open,
(open) => {
if (open && typeof document !== 'undefined') {
previousFocus =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
}
},
{ flush: 'sync', immediate: true },
);
function restoreFocus(event: Event): void {
if (
!event.defaultPrevented &&
!context.triggerElement.value &&
previousFocus?.isConnected
) {
event.preventDefault();
previousFocus.focus({ preventScroll: true });
}
}
return { restoreFocus };
}
@@ -0,0 +1,122 @@
import type {
PendingVisit,
UrlMethodPair,
UseFormSubmitOptions,
} from '@inertiajs/core';
import { router } from '@inertiajs/vue3';
import type { InertiaForm } from '@inertiajs/vue3';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useUnsavedChanges } from '@/composables/useUnsavedChanges';
import {
formRequestHeader,
isFormNetworkFailure,
matchesFormRequest,
} from '@/lib/form-feedback';
import type { FormFeedbackState } from '@/lib/form-feedback';
/** Pair with a keyed useForm/useRemember for restoration on browser Back/Forward. */
export function useFormProtection<T extends object>(
form: InertiaForm<T>,
options: { isDirty?: () => boolean } = {},
) {
const failure = ref<FormFeedbackState | null>(null);
const responseStatus = ref<number | null>(null);
let ownVisit: PendingVisit | null = null;
let requestId: string | null = null;
const removeListeners: Array<() => void> = [];
const { confirmLeave } = useUnsavedChanges(
() => form.processing || (options.isDirty?.() ?? form.isDirty),
(visit) => visit === ownVisit,
);
const state = computed<FormFeedbackState>(() => {
if (form.processing) {
return 'loading';
}
if (failure.value) {
return failure.value;
}
if (Object.keys(form.errors).length > 0) {
return 'validation';
}
return form.recentlySuccessful ? 'success' : 'idle';
});
onMounted(() => {
removeListeners.push(
router.on('exception', (event) => {
if (isFormNetworkFailure(event.detail.exception, requestId)) {
event.preventDefault();
failure.value = 'network';
}
}),
router.on('invalid', (event) => {
if (
matchesFormRequest(event.detail.response.config, requestId)
) {
event.preventDefault();
failure.value = 'server';
responseStatus.value = event.detail.response.status;
}
}),
);
});
onUnmounted(() => removeListeners.forEach((remove) => remove()));
function submit(
target: UrlMethodPair,
visitOptions: UseFormSubmitOptions = {},
): void {
if (form.processing) {
return;
}
const nextRequestId = crypto.randomUUID();
form.submit(target, {
...visitOptions,
preserveState: true,
headers: {
...visitOptions.headers,
[formRequestHeader]: nextRequestId,
},
onBefore: (visit) => {
if (visitOptions.onBefore?.(visit) === false) {
return false;
}
ownVisit = visit;
},
onStart: (visit) => {
requestId = nextRequestId;
failure.value = null;
responseStatus.value = null;
visitOptions.onStart?.(visit);
},
onError: (errors) => {
failure.value = 'validation';
return visitOptions.onError?.(errors);
},
onSuccess: (page) => {
failure.value = null;
return visitOptions.onSuccess?.(page);
},
onCancel: () => {
failure.value = 'cancelled';
visitOptions.onCancel?.();
},
onFinish: (visit) => {
requestId = null;
ownVisit = null;
visitOptions.onFinish?.(visit);
},
});
}
function clearFeedback(): void {
failure.value = null;
responseStatus.value = null;
}
return { state, responseStatus, submit, confirmLeave, clearFeedback };
}
@@ -0,0 +1,12 @@
import { inject } from 'vue';
import { localizationKey } from '@/lib/localization';
export function useLocalization() {
const localization = inject(localizationKey);
if (!localization) {
throw new Error(
'The localization plugin must be installed before useLocalization().',
);
}
return localization;
}
@@ -0,0 +1,42 @@
import type { PendingVisit } from '@inertiajs/core';
import { router } from '@inertiajs/vue3';
import { onMounted, onUnmounted, toValue } from 'vue';
import type { MaybeRefOrGetter } from 'vue';
import { useLocalization } from '@/composables/useLocalization';
export function useUnsavedChanges(
dirty: MaybeRefOrGetter<boolean>,
allowVisit: (visit: PendingVisit) => boolean = () => false,
) {
const { t } = useLocalization();
let removeBeforeListener: (() => void) | undefined;
const confirmLeave = (): boolean =>
!toValue(dirty) || window.confirm(t('forms.leaveConfirm'));
const beforeUnload = (event: BeforeUnloadEvent): void => {
if (toValue(dirty)) {
event.preventDefault();
event.returnValue = '';
}
};
onMounted(() => {
removeBeforeListener = router.on('before', (event) => {
if (event.defaultPrevented || allowVisit(event.detail.visit)) {
return;
}
// Keep the original visit and its callbacks; never replay a mutation.
return confirmLeave();
});
window.addEventListener('beforeunload', beforeUnload);
});
onUnmounted(() => {
removeBeforeListener?.();
window.removeEventListener('beforeunload', beforeUnload);
});
return { confirmLeave };
}
+17 -2
View File
@@ -3,6 +3,8 @@ import AppContent from '@/components/AppContent.vue';
import AppShell from '@/components/AppShell.vue'; import AppShell from '@/components/AppShell.vue';
import AppSidebar from '@/components/AppSidebar.vue'; import AppSidebar from '@/components/AppSidebar.vue';
import AppSidebarHeader from '@/components/AppSidebarHeader.vue'; import AppSidebarHeader from '@/components/AppSidebarHeader.vue';
import ProjectNavigation from '@/components/ProjectNavigation.vue';
import { useLocalization } from '@/composables/useLocalization';
import type { BreadcrumbItem } from '@/types'; import type { BreadcrumbItem } from '@/types';
type Props = { type Props = {
@@ -12,14 +14,27 @@ type Props = {
withDefaults(defineProps<Props>(), { withDefaults(defineProps<Props>(), {
breadcrumbs: () => [], breadcrumbs: () => [],
}); });
const { t } = useLocalization();
</script> </script>
<template> <template>
<AppShell variant="sidebar"> <AppShell variant="sidebar">
<a
href="#main-content"
class="bg-primary text-primary-foreground sr-only fixed left-4 top-4 z-50 rounded-md p-3 focus:not-sr-only"
>{{ t('navigation.skipToContent') }}</a
>
<AppSidebar /> <AppSidebar />
<AppContent variant="sidebar" class="overflow-x-hidden"> <AppContent variant="sidebar" class="min-w-0">
<AppSidebarHeader :breadcrumbs="breadcrumbs" /> <AppSidebarHeader :breadcrumbs="breadcrumbs" />
<slot /> <ProjectNavigation />
<div
id="main-content"
tabindex="-1"
class="flex min-w-0 flex-1 flex-col"
>
<slot />
</div>
</AppContent> </AppContent>
</AppShell> </AppShell>
</template> </template>
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ShieldCheck } from 'lucide-vue-next';
import type { Component } from 'vue';
import AppLogo from '@/components/AppLogo.vue';
import AppearanceTabs from '@/components/AppearanceTabs.vue';
import { useLocalization } from '@/composables/useLocalization';
import { home } from '@/routes/index';
defineProps<{ title: string; description: string; icon?: Component }>();
const { t } = useLocalization();
</script>
<template>
<div class="bg-background text-foreground flex min-h-dvh flex-col">
<header
class="bg-card px-page flex justify-center border-b py-4 sm:justify-start"
>
<Link
:href="home()"
:aria-label="t('auth.home')"
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
><AppLogo
/></Link>
</header>
<main
class="gap-column sm:px-page mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-8 sm:py-12"
>
<section
aria-labelledby="auth-title"
class="bg-card p-page text-card-foreground shadow-xs grid gap-6 rounded-md border"
>
<div class="gap-column grid">
<span
class="bg-primary/10 text-primary flex size-11 items-center justify-center rounded-md"
>
<component
:is="icon ?? ShieldCheck"
class="size-5"
aria-hidden="true"
/>
</span>
<div class="gap-stack grid">
<p
class="text-label text-muted-foreground uppercase tracking-wide"
>
{{ t('auth.accountAccess') }}
</p>
<h1
id="auth-title"
class="text-headline-mobile sm:text-headline"
>
{{ title }}
</h1>
<p class="text-body text-muted-foreground">
{{ description }}
</p>
</div>
</div>
<slot />
</section>
<div
v-if="$slots.footer"
class="px-card text-body text-muted-foreground text-center"
>
<slot name="footer" />
</div>
</main>
<footer class="pb-page flex justify-center px-4">
<AppearanceTabs />
</footer>
</div>
</template>
+27 -17
View File
@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link } from '@inertiajs/vue3'; import { Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import Heading from '@/components/Heading.vue'; import Heading from '@/components/Heading.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { useCurrentUrl } from '@/composables/useCurrentUrl'; import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization';
import { toUrl } from '@/lib/utils'; import { toUrl } from '@/lib/utils';
import { edit as editAppearance } from '@/routes/appearance/index'; import { edit as editAppearance } from '@/routes/appearance/index';
import { edit as editApplications } from '@/routes/applications/index'; import { edit as editApplications } from '@/routes/applications/index';
@@ -12,28 +14,29 @@ import { edit as editProfile } from '@/routes/profile/index';
import { edit as editSecurity } from '@/routes/security/index'; import { edit as editSecurity } from '@/routes/security/index';
import type { NavItem } from '@/types'; import type { NavItem } from '@/types';
const sidebarNavItems: NavItem[] = [ const { t } = useLocalization();
const sidebarNavItems = computed<NavItem[]>(() => [
{ {
title: 'Profile', title: t('navigation.profile'),
href: editProfile(), href: editProfile(),
}, },
{ {
title: 'Security', title: t('navigation.security'),
href: editSecurity(), href: editSecurity(),
}, },
{ {
title: 'Git connections', title: t('navigation.gitConnections'),
href: editGitConnections(), href: editGitConnections(),
}, },
{ {
title: 'Applications', title: t('navigation.applications'),
href: editApplications(), href: editApplications(),
}, },
{ {
title: 'Appearance', title: t('navigation.appearance'),
href: editAppearance(), href: editAppearance(),
}, },
]; ]);
const { isCurrentOrParentUrl } = useCurrentUrl(); const { isCurrentOrParentUrl } = useCurrentUrl();
</script> </script>
@@ -41,27 +44,34 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
<template> <template>
<div class="px-4 py-6"> <div class="px-4 py-6">
<Heading <Heading
title="Settings" :title="t('navigation.settings')"
description="Manage your profile, security, and Git integration settings" :description="t('navigation.settingsDescription')"
/> />
<div class="flex flex-col lg:flex-row lg:space-x-12"> <div class="flex min-w-0 flex-col gap-6 lg:flex-row lg:gap-12">
<aside class="w-full max-w-xl lg:w-48"> <aside class="w-full min-w-0 lg:w-52 lg:shrink-0">
<nav <nav
class="flex flex-col space-x-0 space-y-1" class="flex gap-1 overflow-x-auto lg:flex-col"
aria-label="Settings" :aria-label="t('navigation.settings')"
> >
<Button <Button
v-for="item in sidebarNavItems" v-for="item in sidebarNavItems"
:key="toUrl(item.href)" :key="toUrl(item.href)"
variant="ghost" variant="ghost"
:class="[ :class="[
'w-full justify-start', 'shrink-0 justify-start lg:w-full',
{ 'bg-muted': isCurrentOrParentUrl(item.href) }, { 'bg-muted': isCurrentOrParentUrl(item.href) },
]" ]"
as-child as-child
> >
<Link :href="item.href"> <Link
:href="item.href"
:aria-current="
isCurrentOrParentUrl(item.href)
? 'page'
: undefined
"
>
<component :is="item.icon" class="h-4 w-4" /> <component :is="item.icon" class="h-4 w-4" />
{{ item.title }} {{ item.title }}
</Link> </Link>
@@ -69,9 +79,9 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
</nav> </nav>
</aside> </aside>
<Separator class="my-6 lg:hidden" /> <Separator class="lg:hidden" />
<div class="flex-1 md:max-w-2xl"> <div class="min-w-0 flex-1 md:max-w-2xl">
<section class="max-w-xl space-y-12"> <section class="max-w-xl space-y-12">
<slot /> <slot />
</section> </section>
+42
View File
@@ -0,0 +1,42 @@
export type FormFeedbackState =
| 'idle'
| 'loading'
| 'success'
| 'validation'
| 'network'
| 'server'
| 'cancelled';
export const formRequestHeader = 'X-KanbanGit-Form-Request';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
/** Match failures to one submission, without intercepting other forms or polling. */
export function matchesFormRequest(
config: unknown,
requestId: string | null,
): boolean {
if (!requestId || !isRecord(config) || !isRecord(config.headers)) {
return false;
}
return config.headers[formRequestHeader] === requestId;
}
/** Runtime exceptions and cancellations are not network failures. */
export function isFormNetworkFailure(
error: unknown,
requestId: string | null,
): boolean {
return (
isRecord(error) &&
error.isAxiosError === true &&
!error.response &&
['ERR_NETWORK', 'ECONNABORTED', 'ETIMEDOUT'].includes(
String(error.code),
) &&
matchesFormRequest(error.config, requestId)
);
}
+159
View File
@@ -0,0 +1,159 @@
import { readonly, ref } from 'vue';
import type { App, InjectionKey } from 'vue';
import { en } from '@/locales/en';
import type {
LocalizationSettings,
TranslationCatalog,
TranslationKey,
TranslationParameters,
} from '@/types/localization';
const defaultLocale = 'en';
const catalogs: Record<string, TranslationCatalog> = { en };
function placeholders(message: string): string[] {
return [
...new Set(
[...message.matchAll(/\{(\w+)\}/g)].map((match) => match[1]),
),
].sort();
}
/** Validate every catalog, including messages that no component has rendered yet. */
export function validateCatalog(catalog: TranslationCatalog): void {
for (const key of Object.keys(en) as TranslationKey[]) {
const message = Object.hasOwn(catalog, key) ? catalog[key] : undefined;
const source = en[key];
if (message === undefined || typeof message !== typeof source) {
throw new Error(`Missing or invalid translation: ${key}`);
}
const expected = placeholders(
typeof source === 'string' ? source : source.other,
);
const forms =
typeof message === 'string'
? [message]
: Object.values(message ?? {});
if (
typeof message !== 'string' &&
(!message || typeof message.other !== 'string')
) {
throw new Error(`Missing plural fallback: ${key}`);
}
for (const form of forms) {
if (
typeof form !== 'string' ||
form.trim() === '' ||
JSON.stringify(placeholders(form)) !== JSON.stringify(expected)
) {
throw new Error(`Invalid translation placeholders: ${key}`);
}
}
}
}
export function createLocalization(
settings: Partial<LocalizationSettings> = {},
availableCatalogs: Record<string, TranslationCatalog> = catalogs,
) {
const messages: Record<string, TranslationCatalog> = {
...availableCatalogs,
en,
};
for (const catalog of Object.values(messages)) {
validateCatalog(catalog);
}
function resolveLocale(requested: string): string {
try {
const [canonical] = Intl.getCanonicalLocales(
requested.replaceAll('_', '-'),
);
if (Object.hasOwn(messages, canonical)) {
return canonical;
}
const language = canonical.split('-')[0];
return Object.hasOwn(messages, language) ? language : defaultLocale;
} catch {
return defaultLocale;
}
}
const locale = ref(resolveLocale(settings.locale ?? defaultLocale));
const timeZone = settings.timeZone ?? 'UTC';
function number(
value: number,
options: Intl.NumberFormatOptions = {},
): string {
return new Intl.NumberFormat(locale.value, options).format(value);
}
function date(
value: Date | number,
options: Intl.DateTimeFormatOptions = {},
): string {
return new Intl.DateTimeFormat(locale.value, {
timeZone,
...options,
}).format(value);
}
/** Interpolate plain text only; render with Vue text bindings, never v-html. */
function t(
key: TranslationKey,
parameters: TranslationParameters = {},
): string {
const catalog = messages[locale.value];
if (!Object.hasOwn(catalog, key)) {
throw new Error(`Missing translation: ${key}`);
}
const message = catalog[key];
let template: string;
if (typeof message === 'string') {
template = message;
} else {
const count = parameters.count;
if (typeof count !== 'number' || !Number.isFinite(count)) {
throw new Error(`Translation requires a finite count: ${key}`);
}
template =
message[new Intl.PluralRules(locale.value).select(count)] ??
message.other;
}
return template.replace(/\{(\w+)\}/g, (_, name: string) => {
if (!Object.hasOwn(parameters, name)) {
throw new Error(
`Missing translation parameter: ${key}.${name}`,
);
}
const value = parameters[name];
return typeof value === 'number' ? number(value) : value;
});
}
const localization = {
locale: readonly(locale),
t,
number,
date,
setLocale(requested: string): void {
locale.value = resolveLocale(requested);
},
install(app: App): void {
app.provide(localizationKey, localization);
},
};
return localization;
}
export type Localization = ReturnType<typeof createLocalization>;
export const localizationKey: InjectionKey<Localization> =
Symbol('localization');
+166
View File
@@ -0,0 +1,166 @@
import {
Bell,
Columns3,
Folder,
FolderGit2,
House,
KeyRound,
Layers3,
LayoutGrid,
ListTodo,
Settings2,
Shield,
Tags,
Users,
} from 'lucide-vue-next';
import { dashboard } from '@/routes';
import { index as adminUsers } from '@/routes/admin/users';
import { index as templates } from '@/routes/project-templates';
import { access, index as projects, show } from '@/routes/projects';
import { index as backlog } from '@/routes/projects/backlog';
import { index as board } from '@/routes/projects/board';
import {
board as boardSettings,
general,
metadata,
notifications,
repositories,
} from '@/routes/projects/settings';
import { index as teams } from '@/routes/teams';
import type { Localization } from '@/lib/localization';
import type { AuthPermissions, NavItem, NavigationProject } from '@/types';
export function workspaceNavigation(
permissions: AuthPermissions,
t: Localization['t'],
): NavItem[] {
const items: NavItem[] = [
{
title: t('navigation.dashboard'),
href: dashboard(),
icon: LayoutGrid,
},
{
title: t('navigation.projects'),
href: projects(),
icon: Folder,
match: 'section',
},
];
if (permissions.projectCreate) {
items.push({
title: t('navigation.templates'),
href: templates(),
icon: Layers3,
match: 'section',
});
}
if (permissions.canManageTeams) {
items.push({
title: t('navigation.teams'),
href: teams(),
icon: Users,
match: 'section',
});
}
if (permissions.globalAdmin) {
items.push({
title: t('navigation.users'),
href: adminUsers(),
icon: Shield,
match: 'section',
});
}
return items;
}
export function projectNavigation(
project: NavigationProject | null,
t: Localization['t'],
): NavItem[] {
if (!project) {
return [];
}
return [
{
title: t('navigation.overview'),
href: show(project.id),
icon: House,
},
{
title: t('navigation.board'),
href: board(project.id),
icon: Columns3,
match: 'section',
},
{
title: t('navigation.backlog'),
href: backlog(project.id),
icon: ListTodo,
match: 'section',
},
];
}
export function projectSettingsNavigation(
project: NavigationProject | null,
t: Localization['t'],
): NavItem[] {
if (!project?.canManage) {
return [];
}
return [
{
title: t('navigation.general'),
href: general(project.id),
icon: Settings2,
},
{
title: t('navigation.boardSettings'),
href: boardSettings(project.id),
icon: Columns3,
},
{
title: t('navigation.ticketSettings'),
href: metadata(project.id),
icon: Tags,
},
{
title: t('navigation.repositories'),
href: repositories(project.id),
icon: FolderGit2,
},
{
title: t('navigation.notifications'),
href: notifications(project.id),
icon: Bell,
},
{
title: t('navigation.access'),
href: access(project.id),
icon: KeyRound,
},
];
}
export function matchesNavigationUrl(
target: string,
current: string,
section = false,
): boolean {
try {
const base = 'http://localhost';
const targetPath =
new URL(target, base).pathname.replace(/\/+$/, '') || '/';
const currentPath =
new URL(current, base).pathname.replace(/\/+$/, '') || '/';
return (
currentPath === targetPath ||
(section &&
targetPath !== '/' &&
currentPath.startsWith(`${targetPath}/`))
);
} catch {
return false;
}
}
+141
View File
@@ -0,0 +1,141 @@
export const en = {
'auth.home': 'KanbanGit home',
'auth.accountAccess': 'Account access',
'auth.backToLogin': 'Return to sign in',
'auth.signOut': 'Sign out',
'auth.signingOut': 'Signing out…',
'auth.password.show': 'Show password',
'auth.password.hide': 'Hide password',
'auth.login.title': 'Welcome back',
'auth.login.description': 'Sign in to continue to your workspace.',
'auth.login.email': 'Email address',
'auth.login.emailPlaceholder': 'you@example.com',
'auth.login.password': 'Password',
'auth.login.remember': 'Remember me',
'auth.login.forgot': 'Forgot password?',
'auth.login.submit': 'Sign in',
'auth.login.pending': 'Signing in…',
'auth.login.help': 'Need an account? Contact your workspace administrator.',
'auth.login.statusTitle': 'Account update',
'auth.verify.title': 'Check your inbox',
'auth.verify.description':
'Open the verification link in your email to finish confirming your account.',
'auth.verify.emailLabel': 'Verification email address',
'auth.verify.resend': 'Resend verification email',
'auth.verify.pending': 'Sending verification email…',
'auth.verify.sentTitle': 'Verification link sent',
'auth.verify.sentDescription':
'Check your inbox for a new link. If it does not arrive, check your spam folder.',
'auth.verify.help': 'Already verified in another tab?',
'auth.verify.continue': 'Continue to workspace',
'auth.verify.wrongAccount':
'Wrong account? Sign out and use a different email address.',
'auth.twoFactor.title': 'Two-step verification',
'auth.twoFactor.description':
'Enter the six-digit code from your authenticator app to finish signing in.',
'auth.twoFactor.code': 'Authentication code',
'auth.twoFactor.codeHint':
'Enter all six digits, including any leading zero.',
'auth.twoFactor.recoveryTitle': 'Use a recovery code',
'auth.twoFactor.recoveryDescription':
'Enter one of the recovery codes you saved when you set up two-factor authentication.',
'auth.twoFactor.recoveryCode': 'Recovery code',
'auth.twoFactor.recoveryHint': 'Each recovery code can only be used once.',
'auth.twoFactor.useRecovery': 'Use a recovery code instead',
'auth.twoFactor.useCode': 'Use an authenticator code instead',
'auth.twoFactor.submit': 'Verify and sign in',
'auth.twoFactor.pending': 'Verifying your code…',
'auth.feedback.pending': 'Please wait…',
'auth.feedback.completed': 'Request completed',
'auth.feedback.validationTitle': 'Check your details',
'auth.feedback.validationDescription':
'Review the message below and try again. Your entries have been kept.',
'auth.feedback.expiredTitle': 'Your session has expired',
'auth.feedback.expiredDescription':
'Return to sign in to start a new session. You will need to enter your details again.',
'auth.feedback.limitedTitle': 'Too many attempts',
'auth.feedback.limitedDescription':
'Please wait a moment before trying again.',
'auth.feedback.networkDescription':
'We could not confirm the result. Your entries are still here. Check your connection before trying again.',
'auth.feedback.serverDescription':
'The server returned an unexpected response. Your entries are still here. Please try again in a moment.',
'auth.feedback.cancelledDescription':
'The request was interrupted. Your entries are still here if you want to try again.',
'navigation.main': 'Main navigation',
'navigation.workspace': 'Workspace',
'navigation.project': 'Project navigation',
'navigation.projectSettings': 'Project settings',
'navigation.account': 'Account',
'navigation.dashboard': 'Dashboard',
'navigation.projects': 'Projects',
'navigation.templates': 'Project templates',
'navigation.users': 'Users',
'navigation.teams': 'Teams',
'navigation.overview': 'Overview',
'navigation.board': 'Board',
'navigation.backlog': 'Backlog',
'navigation.general': 'General',
'navigation.boardSettings': 'Board settings',
'navigation.ticketSettings': 'Ticket settings',
'navigation.repositories': 'Repositories',
'navigation.notifications': 'Notifications',
'navigation.access': 'Access',
'navigation.settings': 'Settings',
'navigation.profile': 'Profile',
'navigation.security': 'Security',
'navigation.gitConnections': 'Git connections',
'navigation.applications': 'Applications',
'navigation.appearance': 'Appearance',
'navigation.settingsDescription':
'Manage your profile, security, and Git integration settings.',
'navigation.createTicket': 'New ticket',
'navigation.createProject': 'New project',
'navigation.open': 'Open navigation',
'navigation.close': 'Close navigation',
'navigation.expand': 'Expand sidebar',
'navigation.collapse': 'Collapse sidebar',
'navigation.mobileDescription':
'Navigate your workspace and current project.',
'navigation.skipToContent': 'Skip to content',
'navigation.userMenu': 'Account menu for {name}',
'navigation.logout': 'Log out',
'navigation.breadcrumbs': 'Breadcrumbs',
'appearance.label': 'Color theme',
'appearance.light': 'Light',
'appearance.dark': 'Dark',
'appearance.system': 'System',
'common.cancel': 'Cancel',
'common.close': 'Close',
'common.retry': 'Try again',
'feedback.empty': 'Nothing here yet',
'feedback.saved': 'Changes saved',
'forms.saving': 'Saving changes…',
'forms.leaveConfirm': 'You have unsaved changes. Leave without saving?',
'forms.validationTitle': 'Check your entries',
'forms.validationDescription':
'The server rejected some fields. Your entries are still here. Correct the highlighted errors and save again.',
'forms.networkTitle': 'Connection interrupted',
'forms.networkDescription':
'Your entries are still here, but we could not confirm whether the server saved them. Check your connection and verify the result before trying again.',
'forms.serverTitle': 'The request could not be completed',
'forms.serverDescription':
'Your entries are still here. The server returned an unexpected response; your session may have expired. Verify the result before trying again.',
'forms.cancelledTitle': 'Saving was interrupted',
'forms.cancelledDescription':
'Your entries are still here. Check whether the changes were saved before trying again.',
'tickets.noBranches': 'No matching branches',
'tickets.noBranchesDescription':
'No matching branches were found across the linked repositories.',
'tickets.noPullRequests': 'No matching pull requests',
'tickets.noPullRequestsDescription':
'No matching pull requests were found across the linked repositories.',
'common.save': 'Save',
'common.loading': 'Loading…',
'common.error': 'Something went wrong.',
'pagination.page': 'Page {page} of {total}',
'tickets.count': {
one: '{count} ticket',
other: '{count} tickets',
},
} as const;
+105 -81
View File
@@ -1,5 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import { LogIn } from 'lucide-vue-next';
import { onUnmounted } from 'vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue'; import PasswordInput from '@/components/PasswordInput.vue';
import TextLink from '@/components/TextLink.vue'; import TextLink from '@/components/TextLink.vue';
@@ -7,94 +11,114 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner'; import { useFormProtection } from '@/composables/useFormProtection';
import AuthBase from '@/layouts/AuthLayout.vue'; import { useLocalization } from '@/composables/useLocalization';
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { store } from '@/routes/login'; import { store } from '@/routes/login';
import { request } from '@/routes/password'; import { request } from '@/routes/password';
defineProps<{ defineProps<{ status?: string; canResetPassword: boolean }>();
status?: string; const { t } = useLocalization();
canResetPassword: boolean; // Credentials stay in memory only; no remembered form key or browser storage.
}>(); const form = useForm({ email: '', password: '', remember: false }).dontRemember(
'password',
);
const protection = useFormProtection(form, { isDirty: () => false });
const submit = (): void =>
protection.submit(store(), {
onSuccess: () => form.reset('password'),
});
onUnmounted(() => form.reset('password'));
</script> </script>
<template> <template>
<AuthBase <AuthFlowLayout
title="Log in to your account" :title="t('auth.login.title')"
description="Enter your email and password below to log in" :description="t('auth.login.description')"
:icon="LogIn"
> >
<Head title="Log in" /> <Head :title="t('auth.login.submit')" />
<FeedbackState
<div
v-if="status" v-if="status"
class="mb-4 text-center text-sm font-medium text-green-600" state="success"
:title="t('auth.login.statusTitle')"
:description="status"
/>
<form
:action="store.url()"
method="post"
class="grid gap-5"
@submit.prevent="submit"
> >
{{ status }} <AuthFormFeedback
</div> :state="protection.state.value"
:response-status="protection.responseStatus.value"
<Form :loading-title="t('auth.login.pending')"
v-bind="store.form()" @retry="submit"
:reset-on-success="['password']" />
v-slot="{ errors, processing }" <div class="gap-stack grid">
class="flex flex-col gap-6" <Label for="email">{{ t('auth.login.email') }}</Label>
> <Input
<div class="grid gap-6"> id="email"
<div class="grid gap-2"> v-model="form.email"
<Label for="email">Email address</Label> name="email"
<Input type="email"
id="email" inputmode="email"
type="email" autocomplete="username"
name="email" autocapitalize="none"
required :spellcheck="false"
autofocus required
:tabindex="1" autofocus
autocomplete="email" :disabled="form.processing"
placeholder="email@example.com" :placeholder="t('auth.login.emailPlaceholder')"
/> :invalid="Boolean(form.errors.email)"
<InputError :message="errors.email" /> aria-describedby="email-error"
</div> />
<InputError id="email-error" :message="form.errors.email" />
<div class="grid gap-2">
<div class="flex items-center justify-between">
<Label for="password">Password</Label>
<TextLink
v-if="canResetPassword"
:href="request()"
class="text-sm"
:tabindex="5"
>
Forgot password?
</TextLink>
</div>
<PasswordInput
id="password"
name="password"
required
:tabindex="2"
autocomplete="current-password"
placeholder="Password"
/>
<InputError :message="errors.password" />
</div>
<div class="flex items-center justify-between">
<Label for="remember" class="flex items-center space-x-3">
<Checkbox id="remember" name="remember" :tabindex="3" />
<span>Remember me</span>
</Label>
</div>
<Button
type="submit"
class="mt-4 w-full"
:tabindex="4"
:disabled="processing"
data-test="login-button"
>
<Spinner v-if="processing" />
Log in
</Button>
</div> </div>
</Form> <div class="gap-stack grid">
</AuthBase> <Label for="password">{{ t('auth.login.password') }}</Label>
<PasswordInput
id="password"
v-model="form.password"
name="password"
autocomplete="current-password"
required
:disabled="form.processing"
:invalid="Boolean(form.errors.password)"
aria-describedby="password-error"
/>
<InputError
id="password-error"
:message="form.errors.password"
/>
</div>
<div class="gap-card flex flex-wrap items-center justify-between">
<Label for="remember" class="min-h-control gap-stack">
<Checkbox
id="remember"
v-model="form.remember"
name="remember"
:disabled="form.processing"
/>
{{ t('auth.login.remember') }}
</Label>
<TextLink
v-if="canResetPassword"
:href="request()"
class="min-h-control text-body inline-flex items-center"
>{{ t('auth.login.forgot') }}</TextLink
>
</div>
<Button
type="submit"
class="w-full"
:loading="form.processing"
:loading-label="t('auth.login.pending')"
data-test="login-button"
>{{ t('auth.login.submit') }}</Button
>
</form>
<template #footer>{{ t('auth.login.help') }}</template>
</AuthFlowLayout>
</template> </template>
+159 -119
View File
@@ -1,133 +1,173 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import { computed, ref } from 'vue'; import { KeyRound, ShieldCheck } from 'lucide-vue-next';
import { computed, nextTick, onUnmounted, ref, useTemplateRef } from 'vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import TextLink from '@/components/TextLink.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { import { Label } from '@/components/ui/label';
InputOTP, import { useFormProtection } from '@/composables/useFormProtection';
InputOTPGroup, import { useLocalization } from '@/composables/useLocalization';
InputOTPSlot, import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
} from '@/components/ui/input-otp'; import { login } from '@/routes/index';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { store } from '@/routes/two-factor/login'; import { store } from '@/routes/two-factor/login';
import type { TwoFactorConfigContent } from '@/types';
const authConfigContent = computed<TwoFactorConfigContent>(() => { const { t } = useLocalization();
if (showRecoveryInput.value) { const recovery = ref(false);
return { const form = useForm({ code: '', recovery_code: '' }).dontRemember(
title: 'Recovery code', 'code',
description: 'recovery_code',
'Please confirm access to your account by entering one of your emergency recovery codes.', );
buttonText: 'login using an authentication code', const protection = useFormProtection(form, { isDirty: () => false });
}; const codeInput = useTemplateRef('codeInput');
const recoveryInput = useTemplateRef('recoveryInput');
const fieldError = computed(() =>
recovery.value
? (form.errors.recovery_code ?? form.errors.code)
: form.errors.code,
);
const toggleMode = async (): Promise<void> => {
if (form.processing) {
return;
} }
recovery.value = !recovery.value;
return { form.resetAndClearErrors();
title: 'Authentication code', protection.clearFeedback();
description: await nextTick();
'Enter the authentication code provided by your authenticator application.', (recovery.value ? recoveryInput.value : codeInput.value)?.$el?.focus();
buttonText: 'login using a recovery code',
};
});
const showRecoveryInput = ref<boolean>(false);
const toggleRecoveryMode = (clearErrors: () => void): void => {
showRecoveryInput.value = !showRecoveryInput.value;
clearErrors();
code.value = '';
}; };
const pasteCode = (event: ClipboardEvent): void => {
const code = ref<string>(''); const code = event.clipboardData?.getData('text').replace(/\s/g, '') ?? '';
if (/^[0-9]{6}$/.test(code)) {
event.preventDefault();
form.code = code;
}
};
const submit = (): void => {
form.transform((data) =>
recovery.value
? { recovery_code: data.recovery_code.trim() }
: { code: data.code },
);
protection.submit(store(), { onSuccess: () => form.reset() });
};
onUnmounted(() => form.reset());
</script> </script>
<template> <template>
<AuthLayout <AuthFlowLayout
:title="authConfigContent.title" :title="
:description="authConfigContent.description" t(
recovery
? 'auth.twoFactor.recoveryTitle'
: 'auth.twoFactor.title',
)
"
:description="
t(
recovery
? 'auth.twoFactor.recoveryDescription'
: 'auth.twoFactor.description',
)
"
:icon="recovery ? KeyRound : ShieldCheck"
> >
<Head title="Two-factor authentication" /> <Head :title="t('auth.twoFactor.title')" />
<form
<div class="space-y-6"> :action="store.url()"
<template v-if="!showRecoveryInput"> method="post"
<Form class="grid gap-5"
v-bind="store.form()" @submit.prevent="submit"
class="space-y-4" >
reset-on-error <AuthFormFeedback
@error="code = ''" :state="protection.state.value"
#default="{ errors, processing, clearErrors }" :response-status="protection.responseStatus.value"
:loading-title="t('auth.twoFactor.pending')"
@retry="submit"
/>
<div v-if="!recovery" class="gap-stack grid">
<Label for="code">{{ t('auth.twoFactor.code') }}</Label>
<Input
id="code"
key="code"
ref="codeInput"
v-model="form.code"
name="code"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
pattern="[0-9]{6}"
minlength="6"
maxlength="6"
required
autofocus
:disabled="form.processing"
:invalid="Boolean(fieldError)"
aria-describedby="code-hint code-error"
class="h-14 text-center font-mono text-2xl tracking-[0.35em] md:text-2xl"
@paste="pasteCode"
/>
<p id="code-hint" class="text-caption text-muted-foreground">
{{ t('auth.twoFactor.codeHint') }}
</p>
<InputError id="code-error" :message="fieldError" />
</div>
<div v-else class="gap-stack grid">
<Label for="recovery_code">{{
t('auth.twoFactor.recoveryCode')
}}</Label>
<Input
id="recovery_code"
key="recovery"
ref="recoveryInput"
v-model="form.recovery_code"
name="recovery_code"
type="text"
autocomplete="one-time-code"
autocapitalize="none"
:spellcheck="false"
required
:disabled="form.processing"
:invalid="Boolean(fieldError)"
aria-describedby="recovery-hint recovery-error"
class="font-mono"
/>
<p
id="recovery-hint"
class="text-caption text-muted-foreground"
> >
<input type="hidden" name="code" :value="code" /> {{ t('auth.twoFactor.recoveryHint') }}
<div </p>
class="flex flex-col items-center justify-center space-y-3 text-center" <InputError id="recovery-error" :message="fieldError" />
> </div>
<div class="flex w-full items-center justify-center"> <Button
<InputOTP type="submit"
id="otp" class="w-full"
v-model="code" :loading="form.processing"
:maxlength="6" :loading-label="t('auth.twoFactor.pending')"
:disabled="processing" >{{ t('auth.twoFactor.submit') }}</Button
autofocus >
> <Button
<InputOTPGroup> type="button"
<InputOTPSlot variant="outline"
v-for="index in 6" class="w-full whitespace-normal"
:key="index" :disabled="form.processing"
:index="index - 1" @click="toggleMode"
/> >{{
</InputOTPGroup> t(
</InputOTP> recovery
</div> ? 'auth.twoFactor.useCode'
<InputError :message="errors.code" /> : 'auth.twoFactor.useRecovery',
</div> )
<Button type="submit" class="w-full" :disabled="processing" }}</Button
>Continue</Button >
> </form>
<div class="text-muted-foreground text-center text-sm"> <template #footer
<span>or you can </span> ><TextLink :href="login()">{{
<button t('auth.backToLogin')
type="button" }}</TextLink></template
class="text-foreground hover:decoration-current! underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out dark:decoration-neutral-500" >
@click="() => toggleRecoveryMode(clearErrors)" </AuthFlowLayout>
>
{{ authConfigContent.buttonText }}
</button>
</div>
</Form>
</template>
<template v-else>
<Form
v-bind="store.form()"
class="space-y-4"
reset-on-error
#default="{ errors, processing, clearErrors }"
>
<Input
name="recovery_code"
type="text"
placeholder="Enter recovery code"
:autofocus="showRecoveryInput"
required
/>
<InputError :message="errors.recovery_code" />
<Button type="submit" class="w-full" :disabled="processing"
>Continue</Button
>
<div class="text-muted-foreground text-center text-sm">
<span>or you can </span>
<button
type="button"
class="text-foreground hover:decoration-current! underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out dark:decoration-neutral-500"
@click="() => toggleRecoveryMode(clearErrors)"
>
{{ authConfigContent.buttonText }}
</button>
</div>
</Form>
</template>
</div>
</AuthLayout>
</template> </template>
+110 -35
View File
@@ -1,49 +1,124 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3'; import { Head, router, useForm, usePage } from '@inertiajs/vue3';
import TextLink from '@/components/TextLink.vue'; import { Mail } from 'lucide-vue-next';
import { computed } from 'vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner'; import { useFormProtection } from '@/composables/useFormProtection';
import AuthLayout from '@/layouts/AuthLayout.vue'; import { useLocalization } from '@/composables/useLocalization';
import { logout } from '@/routes/index'; import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { dashboard, logout } from '@/routes/index';
import { send } from '@/routes/verification'; import { send } from '@/routes/verification';
defineProps<{ const props = defineProps<{ status?: string }>();
status?: string; const { t } = useLocalization();
}>(); const page = usePage();
const email = computed(() => page.props.auth.user?.email);
const resendForm = useForm({});
const logoutForm = useForm({});
const resendProtection = useFormProtection(resendForm);
const logoutProtection = useFormProtection(logoutForm);
const busy = computed(() => resendForm.processing || logoutForm.processing);
const sent = computed(
() =>
resendProtection.state.value === 'success' ||
(resendProtection.state.value === 'idle' &&
props.status === 'verification-link-sent'),
);
const resend = (): void => {
if (!busy.value) {
resendProtection.submit(send(), { preserveScroll: true });
}
};
const signOut = (): void => {
if (!busy.value) {
logoutProtection.submit(logout());
}
};
</script> </script>
<template> <template>
<AuthLayout <AuthFlowLayout
title="Verify email" :title="t('auth.verify.title')"
description="Please verify your email address by clicking on the link we just emailed to you." :description="t('auth.verify.description')"
:icon="Mail"
> >
<Head title="Email verification" /> <Head :title="t('auth.verify.title')" />
<div <div
v-if="status === 'verification-link-sent'" v-if="email"
class="mb-4 text-center text-sm font-medium text-green-600" class="gap-stack bg-muted p-card grid rounded-md border"
> >
A new verification link has been sent to the email address you <p class="text-label text-muted-foreground uppercase tracking-wide">
provided during registration. {{ t('auth.verify.emailLabel') }}
</p>
<p class="text-body break-all font-medium">{{ email }}</p>
</div> </div>
<FeedbackState
<Form v-if="sent"
v-bind="send.form()" state="success"
class="space-y-6 text-center" :title="t('auth.verify.sentTitle')"
v-slot="{ processing }" :description="t('auth.verify.sentDescription')"
/>
<form
:action="send.url()"
method="post"
class="gap-column grid"
@submit.prevent="resend"
> >
<Button :disabled="processing" variant="secondary"> <AuthFormFeedback
<Spinner v-if="processing" /> :state="
Resend verification email resendProtection.state.value === 'success'
</Button> ? 'idle'
: resendProtection.state.value
<TextLink "
:href="logout()" :response-status="resendProtection.responseStatus.value"
as="button" :loading-title="t('auth.verify.pending')"
class="mx-auto block text-sm" @retry="resend"
/>
<Button
type="submit"
class="w-full"
:disabled="busy"
:loading="resendForm.processing"
:loading-label="t('auth.verify.pending')"
>{{ t('auth.verify.resend') }}</Button
> >
Log out </form>
</TextLink> <div class="gap-stack pt-column grid border-t">
</Form> <p class="text-body text-muted-foreground">
</AuthLayout> {{ t('auth.verify.help') }}
</p>
<Button
type="button"
variant="outline"
:disabled="busy"
@click="router.visit(dashboard())"
>{{ t('auth.verify.continue') }}</Button
>
</div>
<form
:action="logout.url()"
method="post"
class="gap-card grid"
@submit.prevent="signOut"
>
<AuthFormFeedback
:state="logoutProtection.state.value"
:response-status="logoutProtection.responseStatus.value"
:loading-title="t('auth.signingOut')"
@retry="signOut"
/>
<Button
type="submit"
variant="ghost"
class="w-full"
:disabled="busy"
:loading="logoutForm.processing"
:loading-label="t('auth.signingOut')"
>{{ t('auth.signOut') }}</Button
>
</form>
<template #footer>{{ t('auth.verify.wrongAccount') }}</template>
</AuthFlowLayout>
</template> </template>
+90 -23
View File
@@ -1,7 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, useForm, Head, Link, usePage } from '@inertiajs/vue3'; import {
Form,
useForm,
useRemember,
Head,
Link,
usePage,
} from '@inertiajs/vue3';
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next'; import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
import { computed, ref, watch } from 'vue'; import { computed, ref, toRef, unref, watch } from 'vue';
import { import {
acceptanceCriteriaToEditorState, acceptanceCriteriaToEditorState,
buildAcceptanceCriteria, buildAcceptanceCriteria,
@@ -14,6 +21,10 @@ import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectT
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController'; import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import Heading from '@/components/Heading.vue'; import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import TicketTreeList from '@/components/TicketTreeList.vue'; import TicketTreeList from '@/components/TicketTreeList.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -45,6 +56,7 @@ import type { RepositoryConnectionModel } from '@/types/models/repository-connec
import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types'; import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types';
const props = defineProps<ProjectTicketPageProps>(); const props = defineProps<ProjectTicketPageProps>();
const { t } = useLocalization();
const page = usePage(); const page = usePage();
const appName = String(page.props.name); const appName = String(page.props.name);
@@ -122,8 +134,14 @@ const breadcrumbs: BreadcrumbItem[] = [
const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState( const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState(
props.ticket.acceptance_criteria, props.ticket.acceptance_criteria,
); );
const acceptanceCriteriaChecklist = ref( const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
acceptanceCriteriaEditorState.checklist, const acceptanceCriteriaDraft = useRemember(
{ checklist: acceptanceCriteriaEditorState.checklist },
`${draftKey}:checklist`,
);
const acceptanceCriteriaChecklist = toRef(
unref(acceptanceCriteriaDraft),
'checklist',
); );
const savedAcceptanceCriteria = ref( const savedAcceptanceCriteria = ref(
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria), normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
@@ -131,7 +149,7 @@ const savedAcceptanceCriteria = ref(
const isPersistingAcceptanceCriteriaToggle = ref(false); const isPersistingAcceptanceCriteriaToggle = ref(false);
const acceptanceCriteriaToggleError = ref(''); const acceptanceCriteriaToggleError = ref('');
const form = useForm({ const form = useForm(draftKey, {
acceptance_criteria: buildAcceptanceCriteria( acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value, acceptanceCriteriaChecklist.value,
), ),
@@ -170,6 +188,12 @@ const hasUnsavedAcceptanceCriteriaDraft = computed(
formatChecklistLines(savedAcceptanceCriteria.value), formatChecklistLines(savedAcceptanceCriteria.value),
); );
const protection = useFormProtection(form, {
isDirty: () =>
props.canEditTicket &&
(form.isDirty || hasUnsavedAcceptanceCriteriaDraft.value),
});
const acceptanceCriteriaError = computed(() => { const acceptanceCriteriaError = computed(() => {
return ( return (
form.errors.acceptance_criteria ?? form.errors.acceptance_criteria ??
@@ -187,10 +211,20 @@ const branchNameValidation = ref<BranchNameValidationModel | null>(null);
const branchCreationForm = useForm<{ const branchCreationForm = useForm<{
branch_name: string; branch_name: string;
repository_connection_id: string; repository_connection_id: string;
}>({ }>(`${draftKey}:branch`, {
branch_name: props.suggestedBranchName, branch_name: props.suggestedBranchName,
repository_connection_id: defaultBranchRepositoryConnectionId.value, repository_connection_id: defaultBranchRepositoryConnectionId.value,
}); });
const branchProtection = useFormProtection(branchCreationForm, {
isDirty: () => isCreateBranchDialogOpen.value && branchCreationForm.isDirty,
});
const updateBranchDialogOpen = (open: boolean): void => {
if (!open && !branchProtection.confirmLeave()) {
return;
}
isCreateBranchDialogOpen.value = open;
};
const selectedBranchRepositoryConnection = computed( const selectedBranchRepositoryConnection = computed(
(): RepositoryConnectionModel | null => (): RepositoryConnectionModel | null =>
@@ -207,6 +241,9 @@ const selectedBranchRepositoryHasPersonalAccess = computed(
); );
const initializeBranchCreationForm = (): void => { const initializeBranchCreationForm = (): void => {
if (branchCreationForm.isDirty) {
return;
}
branchCreationForm.clearErrors(); branchCreationForm.clearErrors();
branchCreationForm.branch_name = props.suggestedBranchName; branchCreationForm.branch_name = props.suggestedBranchName;
branchCreationForm.repository_connection_id = branchCreationForm.repository_connection_id =
@@ -348,7 +385,7 @@ const submitBranchCreation = (): void => {
return; return;
} }
branchCreationForm.submit( branchProtection.submit(
ProjectTicketBranchController.store({ ProjectTicketBranchController.store({
project: props.project.id, project: props.project.id,
ticket: props.ticket.id, ticket: props.ticket.id,
@@ -482,13 +519,21 @@ const submit = (): void => {
form.acceptance_criteria = buildAcceptanceCriteriaForSubmit(); form.acceptance_criteria = buildAcceptanceCriteriaForSubmit();
form.submit( protection.submit(
ProjectTicketController.update({ ProjectTicketController.update({
project: props.project.id, project: props.project.id,
ticket: props.ticket.id, ticket: props.ticket.id,
}), }),
{ {
preserveScroll: true, preserveScroll: true,
onSuccess: () => {
savedAcceptanceCriteria.value = normalizeAcceptanceCriteria(
props.ticket.acceptance_criteria,
);
acceptanceCriteriaChecklist.value = formatChecklistLines(
savedAcceptanceCriteria.value,
);
},
}, },
); );
}; };
@@ -574,16 +619,25 @@ const formatDateTime = (value: string): string => {
<CardContent> <CardContent>
<form class="grid gap-6" @submit.prevent="submit"> <form class="grid gap-6" @submit.prevent="submit">
<FormFeedback
:state="protection.state.value"
@retry="submit"
/>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="title">Title</Label> <Label for="title">Title</Label>
<Input <Input
id="title" id="title"
v-model="form.title" v-model="form.title"
:invalid="Boolean(form.errors.title)"
aria-describedby="title-error"
:disabled=" :disabled="
!canEditTicket || form.processing !canEditTicket || form.processing
" "
/> />
<InputError :message="form.errors.title" /> <InputError
id="title-error"
:message="form.errors.title"
/>
</div> </div>
<div class="grid gap-2 md:grid-cols-2 md:gap-6"> <div class="grid gap-2 md:grid-cols-2 md:gap-6">
@@ -995,7 +1049,10 @@ const formatDateTime = (value: string): string => {
</CardDescription> </CardDescription>
</div> </div>
<Dialog v-model:open="isCreateBranchDialogOpen"> <Dialog
:open="isCreateBranchDialogOpen"
@update:open="updateBranchDialogOpen"
>
<Button <Button
v-if="canCreateBranches" v-if="canCreateBranches"
type="button" type="button"
@@ -1023,6 +1080,12 @@ const formatDateTime = (value: string): string => {
class="grid gap-4" class="grid gap-4"
@submit.prevent="submitBranchCreation" @submit.prevent="submitBranchCreation"
> >
<FormFeedback
:state="
branchProtection.state.value
"
@retry="submitBranchCreation"
/>
<div class="grid gap-2"> <div class="grid gap-2">
<Label <Label
for="repository_connection_id" for="repository_connection_id"
@@ -1153,7 +1216,9 @@ const formatDateTime = (value: string): string => {
type="button" type="button"
variant="secondary" variant="secondary"
@click=" @click="
isCreateBranchDialogOpen = false updateBranchDialogOpen(
false,
)
" "
> >
Cancel Cancel
@@ -1177,13 +1242,14 @@ const formatDateTime = (value: string): string => {
</CardHeader> </CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<div <FeedbackState
v-if="visibleRepositoryBranches.length === 0" v-if="visibleRepositoryBranches.length === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm" state="empty"
> :title="t('tickets.noBranches')"
No matching branches were found across the :description="
linked repositories you can access. t('tickets.noBranchesDescription')
</div> "
/>
<div v-else class="space-y-3"> <div v-else class="space-y-3">
<div <div
@@ -1434,15 +1500,16 @@ const formatDateTime = (value: string): string => {
</CardHeader> </CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<div <FeedbackState
v-if=" v-if="
visibleRepositoryPullRequests.length === 0 visibleRepositoryPullRequests.length === 0
" "
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm" state="empty"
> :title="t('tickets.noPullRequests')"
No matching pull requests were found across the :description="
linked repositories you can access. t('tickets.noPullRequestsDescription')
</div> "
/>
<div v-else class="space-y-3"> <div v-else class="space-y-3">
<div <div
+34 -8
View File
@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3'; import { Head, Link, useForm, useRemember } from '@inertiajs/vue3';
import { computed, ref } from 'vue'; import { computed, toRef, unref } from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController'; import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import Heading from '@/components/Heading.vue'; import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import { useFormProtection } from '@/composables/useFormProtection';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -62,9 +64,17 @@ const hasTicketMetadata =
props.ticketPriorities.length > 0 && props.ticketPriorities.length > 0 &&
props.ticketStatusOptions.length > 0; props.ticketStatusOptions.length > 0;
const acceptanceCriteriaChecklist = ref(''); const draftKey = `projects:${props.project.id}:tickets:create`;
const acceptanceCriteriaDraft = useRemember(
{ checklist: '' },
`${draftKey}:checklist`,
);
const acceptanceCriteriaChecklist = toRef(
unref(acceptanceCriteriaDraft),
'checklist',
);
const form = useForm({ const form = useForm(draftKey, {
acceptance_criteria: buildAcceptanceCriteria( acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value, acceptanceCriteriaChecklist.value,
), ),
@@ -80,6 +90,10 @@ const form = useForm({
title: '', title: '',
}); });
const protection = useFormProtection(form, {
isDirty: () => form.isDirty || acceptanceCriteriaChecklist.value !== '',
});
const acceptanceCriteriaPreviewItems = computed(() => const acceptanceCriteriaPreviewItems = computed(() =>
parseChecklistLines(acceptanceCriteriaChecklist.value), parseChecklistLines(acceptanceCriteriaChecklist.value),
); );
@@ -99,9 +113,12 @@ const submit = (): void => {
acceptanceCriteriaChecklist.value, acceptanceCriteriaChecklist.value,
); );
form.submit(ProjectTicketController.store({ project: props.project.id }), { protection.submit(
preserveScroll: true, ProjectTicketController.store({ project: props.project.id }),
}); {
preserveScroll: true,
},
);
}; };
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => { const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
@@ -165,17 +182,26 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
<CardContent> <CardContent>
<form class="grid gap-6" @submit.prevent="submit"> <form class="grid gap-6" @submit.prevent="submit">
<FormFeedback
:state="protection.state.value"
@retry="submit"
/>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="title">Title</Label> <Label for="title">Title</Label>
<Input <Input
id="title" id="title"
v-model="form.title" v-model="form.title"
:invalid="Boolean(form.errors.title)"
aria-describedby="title-error"
required required
autofocus autofocus
:disabled="form.processing" :disabled="form.processing"
placeholder="Implement repository sync" placeholder="Implement repository sync"
/> />
<InputError :message="form.errors.title" /> <InputError
id="title-error"
:message="form.errors.title"
/>
</div> </div>
<div <div
+21 -1
View File
@@ -4,6 +4,12 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import type { DefineComponent } from 'vue'; import type { DefineComponent } from 'vue';
import { createSSRApp, h } from 'vue'; import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer'; import { renderToString } from 'vue/server-renderer';
import { createLocalization } from '@/lib/localization';
import type { LocalizationSettings } from '@/types/localization';
import {
createAppearance,
normalizeAppearance,
} from '@/composables/useAppearance';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
@@ -19,7 +25,21 @@ createServer(
import.meta.glob<DefineComponent>('./pages/**/*.vue'), import.meta.glob<DefineComponent>('./pages/**/*.vue'),
), ),
setup: ({ App, props, plugin }) => setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) }).use(plugin), createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(
createAppearance(
normalizeAppearance(
props.initialPage.props.appearance,
),
),
)
.use(
createLocalization(
props.initialPage.props
.localization as LocalizationSettings,
),
),
}), }),
{ cluster: true }, { cluster: true },
); );
+6
View File
@@ -1,4 +1,7 @@
import type { Auth } from '@/types/auth'; import type { Auth } from '@/types/auth';
import type { AppNavigation } from '@/types/navigation';
import type { LocalizationSettings } from '@/types/localization';
import type { Appearance } from '@/types/ui';
import type { Page, Router } from '@inertiajs/core'; import type { Page, Router } from '@inertiajs/core';
import { createHeadManager } from '@inertiajs/vue3'; import { createHeadManager } from '@inertiajs/vue3';
@@ -19,8 +22,11 @@ declare module '@inertiajs/core' {
export interface InertiaConfig { export interface InertiaConfig {
sharedPageProps: { sharedPageProps: {
name: string; name: string;
localization: LocalizationSettings;
appearance: Appearance;
auth: Auth; auth: Auth;
sidebarOpen: boolean; sidebarOpen: boolean;
navigation: AppNavigation;
}; };
} }
} }
+13
View File
@@ -0,0 +1,13 @@
import type { en } from '@/locales/en';
export type TranslationKey = keyof typeof en;
export type PluralMessage = Partial<Record<Intl.LDMLPluralRule, string>> & {
other: string;
};
export type TranslationCatalog = Record<TranslationKey, string | PluralMessage>;
export type TranslationParameters = Record<string, string | number>;
export type LocalizationSettings = {
locale: string;
timeZone: string;
};
+13
View File
@@ -11,4 +11,17 @@ export type NavItem = {
href: NonNullable<InertiaLinkProps['href']>; href: NonNullable<InertiaLinkProps['href']>;
icon?: LucideIcon; icon?: LucideIcon;
isActive?: boolean; isActive?: boolean;
match?: 'exact' | 'section';
};
export type NavigationProject = {
id: number;
name: string;
key: string;
canManage: boolean;
canCreateTickets: boolean;
};
export type AppNavigation = {
project: NavigationProject | null;
}; };
+18 -19
View File
@@ -1,33 +1,33 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['dark' => ($appearance ?? 'system') == 'dark'])> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}" data-appearance="{{ $appearance ?? 'system' }}" @class(['dark' => ($appearance ?? 'system') === 'dark'])>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
{{-- Inline script to detect system dark mode preference and apply it immediately --}} {{-- Resolve before first paint; the Vue plugin reads the same data attribute. --}}
<script> <script>
(function() { (function() {
const appearance = '{{ $appearance ?? "system" }}'; const root = document.documentElement;
let appearance = root.dataset.appearance;
if (appearance === 'system') { try {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; const stored = window.localStorage.getItem('appearance');
if (stored !== null) {
if (prefersDark) { appearance = stored;
document.documentElement.classList.add('dark');
} }
} catch {
// Fall back to the server cookie when browser storage is unavailable.
} }
appearance = ['light', 'dark', 'system'].includes(appearance) ? appearance : 'system';
const dark = appearance === 'dark' || (appearance === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
root.dataset.appearance = appearance;
root.classList.toggle('dark', dark);
root.style.colorScheme = dark ? 'dark' : 'light';
})(); })();
</script> </script>
{{-- Inline style to set the HTML background color based on our theme in app.css --}} {{-- The same tokens are bundled by Vite, so first paint cannot drift. --}}
<style> <style>
html { {!! file_get_contents(resource_path('css/theme.css')) !!}
background-color: oklch(1 0 0);
}
html.dark {
background-color: oklch(0.145 0 0);
}
</style> </style>
<title inertia>{{ config('app.name', 'Laravel') }}</title> <title inertia>{{ config('app.name', 'Laravel') }}</title>
@@ -36,8 +36,7 @@
<link rel="icon" href="/favicon.svg" type="image/svg+xml"> <link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="preconnect" href="https://fonts.bunny.net"> <link rel="preload" href="{{ asset('fonts/inter/InterVariable.woff2') }}" as="font" type="font/woff2" crossorigin>
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
@vite(['resources/js/app.ts', "resources/js/pages/{$page['component']}.vue"]) @vite(['resources/js/app.ts', "resources/js/pages/{$page['component']}.vue"])
@inertiaHead @inertiaHead
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace Tests\Feature;
use Inertia\Testing\AssertableInertia as Assert;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class AppearanceTest extends TestCase
{
#[DataProvider('appearanceCookies')]
public function test_initial_render_uses_a_validated_theme(?string $cookie, string $expected): void
{
if ($cookie !== null) {
$this->withUnencryptedCookie('appearance', $cookie);
}
$response = $this->get(route('home'));
$response->assertOk()
->assertSee('data-appearance="'.$expected.'"', false)
->assertInertia(fn (Assert $page) => $page->where('appearance', $expected))
->assertDontSee('fonts.bunny.net', false)
->assertDontSee('Instrument Sans', false)
->assertSee('fonts/inter/InterVariable.woff2', false);
preg_match('/<html[^>]+>/', $response->getContent(), $matches);
$this->assertSame($expected === 'dark', str_contains($matches[0], 'class="dark"'));
$this->assertStringContainsString(file_get_contents(resource_path('css/theme.css')), $response->getContent());
}
/** @return array<string, array{?string, string}> */
public static function appearanceCookies(): array
{
return [
'default' => [null, 'system'],
'light' => ['light', 'light'],
'dark' => ['dark', 'dark'],
'system' => ['system', 'system'],
'invalid' => ['invalid', 'system'],
'empty' => ['', 'system'],
'malformed' => ['</script><script>alert(1)</script>', 'system'],
];
}
public function test_theme_behaviour_and_contrast(): void
{
$process = new Process(['node', 'tests/Fixtures/appearance.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Appearance behavior checks passed.', $process->getOutput());
}
public function test_inter_is_bundled_with_its_license(): void
{
foreach (['InterVariable.woff2', 'InterVariable-Italic.woff2'] as $font) {
$this->assertSame('wOF2', file_get_contents(public_path('fonts/inter/'.$font), false, null, 0, 4));
}
$this->assertStringContainsString('SIL OPEN FONT LICENSE', file_get_contents(public_path('fonts/inter/LICENSE.txt')));
}
}
+18 -1
View File
@@ -4,6 +4,7 @@ namespace Tests\Feature\Auth;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Inertia\Testing\AssertableInertia as Assert; use Inertia\Testing\AssertableInertia as Assert;
use Laravel\Fortify\Features; use Laravel\Fortify\Features;
@@ -85,7 +86,7 @@ class AuthenticationTest extends TestCase
$this->post(route('login.store'), [ $this->post(route('login.store'), [
'email' => $user->email, 'email' => $user->email,
'password' => 'wrong-password', 'password' => 'wrong-password',
]); ])->assertSessionHasErrors('email');
$this->assertGuest(); $this->assertGuest();
} }
@@ -100,6 +101,22 @@ class AuthenticationTest extends TestCase
$response->assertRedirect(route('home')); $response->assertRedirect(route('home'));
} }
public function test_login_preserves_the_intended_destination_and_remember_choice(): void
{
$user = User::factory()->create();
$this->withSession(['url.intended' => route('dashboard')])
->post(route('login.store'), [
'email' => $user->email,
'password' => 'password',
'remember' => true,
])
->assertRedirect(route('dashboard'))
->assertCookie(Auth::guard('web')->getRecallerName());
$this->assertAuthenticatedAs($user);
}
public function test_users_are_rate_limited() public function test_users_are_rate_limited()
{ {
$user = User::factory()->create(); $user = User::factory()->create();
+30 -2
View File
@@ -61,7 +61,7 @@ class EmailVerificationTest extends TestCase
['id' => $user->id, 'hash' => sha1('wrong-email')], ['id' => $user->id, 'hash' => sha1('wrong-email')],
); );
$this->actingAs($user)->get($verificationUrl); $this->actingAs($user)->get($verificationUrl)->assertForbidden();
Event::assertNotDispatched(Verified::class); Event::assertNotDispatched(Verified::class);
$this->assertFalse($user->fresh()->hasVerifiedEmail()); $this->assertFalse($user->fresh()->hasVerifiedEmail());
@@ -79,7 +79,7 @@ class EmailVerificationTest extends TestCase
['id' => 123, 'hash' => sha1($user->email)], ['id' => 123, 'hash' => sha1($user->email)],
); );
$this->actingAs($user)->get($verificationUrl); $this->actingAs($user)->get($verificationUrl)->assertForbidden();
Event::assertNotDispatched(Verified::class); Event::assertNotDispatched(Verified::class);
$this->assertFalse($user->fresh()->hasVerifiedEmail()); $this->assertFalse($user->fresh()->hasVerifiedEmail());
@@ -97,6 +97,34 @@ class EmailVerificationTest extends TestCase
$response->assertRedirect(route('dashboard', absolute: false)); $response->assertRedirect(route('dashboard', absolute: false));
} }
public function test_the_ui_rework_preserves_the_existing_optional_verification_policy(): void
{
$user = User::factory()->unverified()->create();
$this->actingAs($user)->get(route('dashboard'))->assertOk();
$this->assertFalse($user->fresh()->hasVerifiedEmail());
}
public function test_guests_cannot_view_the_verification_prompt(): void
{
$this->get(route('verification.notice'))->assertRedirect(route('login'));
}
public function test_expired_verification_links_cannot_verify_an_email(): void
{
$user = User::factory()->unverified()->create();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->subMinute(),
['id' => $user->id, 'hash' => sha1($user->email)],
);
$this->actingAs($user)->get($verificationUrl)->assertForbidden();
$this->assertFalse($user->fresh()->hasVerifiedEmail());
}
public function test_already_verified_user_visiting_verification_link_is_redirected_without_firing_event_again(): void public function test_already_verified_user_visiting_verification_link_is_redirected_without_firing_event_again(): void
{ {
$user = User::factory()->create(); $user = User::factory()->create();
@@ -4,7 +4,9 @@ namespace Tests\Feature\Auth;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
use Inertia\Testing\AssertableInertia as Assert; use Inertia\Testing\AssertableInertia as Assert;
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
use Laravel\Fortify\Features; use Laravel\Fortify\Features;
use Tests\TestCase; use Tests\TestCase;
@@ -52,4 +54,77 @@ class TwoFactorChallengeTest extends TestCase
->component('auth/TwoFactorChallenge'), ->component('auth/TwoFactorChallenge'),
); );
} }
public function test_valid_authenticator_code_completes_the_pending_login(): void
{
$user = User::factory()->withTwoFactor()->create();
$this->mock(TwoFactorAuthenticationProvider::class)
->shouldReceive('verify')->once()->with('secret', '012345')->andReturnTrue();
$this->withSession(['url.intended' => route('dashboard')])
->post(route('login.store'), [
'email' => $user->email,
'password' => 'password',
'remember' => true,
])->assertRedirect(route('two-factor.login'));
$this->assertGuest();
$this->post(route('two-factor.login.store'), ['code' => '012345'])
->assertRedirect(route('dashboard'))
->assertSessionMissing('login.id')
->assertSessionMissing('login.remember')
->assertCookie(Auth::guard('web')->getRecallerName());
$this->assertAuthenticatedAs($user);
}
public function test_invalid_authenticator_code_keeps_the_pending_login_for_retry(): void
{
$user = User::factory()->withTwoFactor()->create();
$this->mock(TwoFactorAuthenticationProvider::class)
->shouldReceive('verify')->once()->with('secret', '000000')->andReturnFalse();
$this->withSession(['login.id' => $user->id])
->from(route('two-factor.login'))
->post(route('two-factor.login.store'), ['code' => '000000'])
->assertRedirect(route('two-factor.login'))
->assertSessionHasErrors('code')
->assertSessionHas('login.id', $user->id);
$this->assertGuest();
}
public function test_recovery_code_signs_in_once_and_is_replaced(): void
{
$user = User::factory()->withTwoFactor()->create();
$this->withSession(['login.id' => $user->id, 'login.remember' => true])
->post(route('two-factor.login.store'), ['recovery_code' => 'recovery-code-1'])
->assertRedirect(route('dashboard', absolute: false))
->assertSessionMissing('login.id')
->assertCookie(Auth::guard('web')->getRecallerName());
$this->assertAuthenticatedAs($user);
$this->assertNotContains('recovery-code-1', $user->fresh()->recoveryCodes());
$this->assertCount(1, $user->fresh()->recoveryCodes());
$this->post(route('logout'));
$this->withSession(['login.id' => $user->id])
->post(route('two-factor.login.store'), ['recovery_code' => 'recovery-code-1'])
->assertSessionHasErrors('recovery_code')
->assertSessionHas('login.id', $user->id);
$this->assertGuest();
}
public function test_challenge_submission_cannot_sign_in_without_a_pending_login(): void
{
$this->from(route('two-factor.login'))
->post(route('two-factor.login.store'), ['code' => '012345'])
->assertRedirect(route('two-factor.login'))
->assertSessionHasErrors('code');
$this->assertGuest();
$this->get(route('two-factor.login'))->assertRedirect(route('login'));
}
} }
@@ -28,7 +28,8 @@ class VerificationNotificationTest extends TestCase
$this->actingAs($user) $this->actingAs($user)
->post(route('verification.send')) ->post(route('verification.send'))
->assertRedirect(route('home')); ->assertRedirect(route('home'))
->assertSessionHas('status', 'verification-link-sent');
Notification::assertSentTo($user, VerifyEmail::class); Notification::assertSentTo($user, VerifyEmail::class);
} }
@@ -45,4 +46,27 @@ class VerificationNotificationTest extends TestCase
Notification::assertNothingSent(); Notification::assertNothingSent();
} }
public function test_guests_cannot_request_a_verification_email(): void
{
Notification::fake();
$this->post(route('verification.send'))->assertRedirect(route('login'));
Notification::assertNothingSent();
}
public function test_resending_verification_emails_is_throttled(): void
{
Notification::fake();
$user = User::factory()->unverified()->create();
$this->actingAs($user);
for ($attempt = 0; $attempt < 6; $attempt++) {
$this->post(route('verification.send'))->assertRedirect();
}
$this->post(route('verification.send'))->assertTooManyRequests();
Notification::assertSentToTimes($user, VerifyEmail::class, 6);
}
} }
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Tests\Feature;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class FormFeedbackTest extends TestCase
{
public function test_feedback_states_and_request_failure_classification(): void
{
$process = new Process(['node', 'tests/Fixtures/form-feedback.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Form feedback behavior checks passed.', $process->getOutput());
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace Tests\Feature;
use Illuminate\Support\Facades\Validator;
use Inertia\Testing\AssertableInertia as Assert;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class LocalizationTest extends TestCase
{
public function test_frontend_localization_behaviour(): void
{
$process = new Process(['node', 'tests/Fixtures/localization.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Localization behavior checks passed.', $process->getOutput());
}
public function test_initial_page_shares_the_application_locale_and_time_zone(): void
{
config(['app.timezone' => 'Europe/Berlin']);
$this->withHeader('Accept-Language', 'de-DE')->get(route('home'))
->assertOk()
->assertSee('lang="en"', false)
->assertInertia(fn (Assert $page) => $page
->where('localization.locale', 'en')
->where('localization.timeZone', 'Europe/Berlin'));
}
public function test_server_validation_remains_english(): void
{
$this->assertSame('en', config('app.locale'));
$this->assertSame('en', config('app.fallback_locale'));
$validator = Validator::make(['email' => 'invalid'], [
'title' => ['required'],
'email' => ['email'],
]);
$this->assertSame('The title field is required.', $validator->errors()->first('title'));
$this->assertSame('The email field must be a valid email address.', $validator->errors()->first('email'));
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace Tests\Feature;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class NavigationTest extends TestCase
{
use RefreshDatabase;
#[DataProvider('projectRoles')]
public function test_navigation_uses_effective_project_permissions(string $role, bool $teamAccess, bool $canManage, bool $canCreateTickets): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$assignee = $user;
if ($teamAccess) {
$assignee = Team::factory()->create();
$assignee->users()->attach($user);
}
$this->syncProjectPermissions($project, $assignee, [$role]);
$this->actingAs($user)->get(route('projects.board.index', $project))
->assertOk()->assertInertia(fn (Assert $page) => $page
->where('navigation.project', [
'id' => $project->id,
'name' => $project->name,
'key' => $project->key,
'canManage' => $canManage,
'canCreateTickets' => $canCreateTickets,
]));
}
/** @return array<string, array{string, bool, bool, bool}> */
public static function projectRoles(): array
{
return [
'reader' => ['read', false, false, false],
'writer' => ['write', false, false, true],
'project admin' => ['admin', false, true, true],
'team reader' => ['read', true, false, false],
'team writer' => ['write', true, false, true],
'team admin' => ['admin', true, true, true],
];
}
public function test_global_admin_has_project_actions_without_direct_membership(): void
{
$user = User::factory()->admin()->create();
$project = Project::factory()->create();
$this->actingAs($user)->get(route('projects.show', $project))
->assertOk()->assertInertia(fn (Assert $page) => $page
->where('navigation.project.canManage', true)
->where('navigation.project.canCreateTickets', true)
->where('auth.permissions.globalAdmin', true));
}
public function test_every_project_admin_navigation_destination_is_authorized(): void
{
$user = User::factory()->create();
$project = Project::factory()->create();
$this->syncProjectPermissions($project, $user, ['admin']);
foreach (['projects.show', 'projects.board.index', 'projects.backlog.index', 'projects.settings.general', 'projects.settings.board', 'projects.settings.metadata', 'projects.settings.repositories', 'projects.settings.notifications', 'projects.access'] as $route) {
$this->actingAs($user)->get(route($route, $project))
->assertOk()->assertInertia(fn (Assert $page) => $page
->where('navigation.project.id', $project->id)
->where('navigation.project.canManage', true)
->where('auth.permissions.globalAdmin', false));
}
}
public function test_project_context_is_cleared_when_leaving_a_project(): void
{
$user = User::factory()->admin()->create();
$project = Project::factory()->create();
$this->actingAs($user)->get(route('projects.show', $project))
->assertInertia(fn (Assert $page) => $page->where('navigation.project.id', $project->id));
$this->get(route('dashboard'))->assertOk()
->assertInertia(fn (Assert $page) => $page->where('navigation.project', null));
$this->get(route('projects.create'))->assertOk()
->assertInertia(fn (Assert $page) => $page->where('navigation.project', null));
}
public function test_unrelated_projects_remain_inaccessible(): void
{
$user = User::factory()->create();
$project = Project::factory()->create(['name' => 'Private project']);
$this->actingAs($user)->get(route('projects.show', $project))
->assertForbidden()->assertDontSee('Private project');
}
public function test_navigation_rendering_and_url_matching(): void
{
$process = new Process(['node', 'tests/Fixtures/navigation.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Navigation behavior checks passed.', $process->getOutput());
}
}
@@ -248,6 +248,8 @@ class ProjectTicketControllerTest extends TestCase
*/ */
public function test_ticket_details_include_matching_branches_pull_requests_and_inaccessible_repository_links(): void public function test_ticket_details_include_matching_branches_pull_requests_and_inaccessible_repository_links(): void
{ {
config()->set('inertia.ssr.enabled', false);
Http::preventStrayRequests(); Http::preventStrayRequests();
Http::fake([ Http::fake([
'https://git.example.test/api/v1/repos/core-team/kanbangit/branches*' => Http::response([ 'https://git.example.test/api/v1/repos/core-team/kanbangit/branches*' => Http::response([
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Tests\Feature;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class UiComponentsTest extends TestCase
{
public function test_foundational_ui_component_behaviour(): void
{
$process = new Process(['node', 'tests/Fixtures/ui-components.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('UI component behavior checks passed.', $process->getOutput());
}
}
+154
View File
@@ -0,0 +1,154 @@
<script setup>
import { Link, useForm } from '@inertiajs/vue3';
import { ref } from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import AppearanceTabs from '@/components/AppearanceTabs.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogClose,
DialogScrollContent,
} from '@/components/ui/dialog';
import {
Sheet,
SheetContent,
SheetTitle,
SheetDescription,
SheetTrigger,
} from '@/components/ui/sheet';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from '@/components/ui/dropdown-menu';
import { useFormProtection } from '@/composables/useFormProtection';
import { index as projectsIndex } from '@/routes/projects/index';
const form = useForm('feedback-preview', { title: '' });
const protection = useFormProtection(form);
const dialogOpen = ref(false);
const menuSelection = ref('');
const submit = () =>
protection.submit(ProjectTicketController.store({ project: 7 }));
</script>
<template>
<main class="gap-column p-page mx-auto grid max-w-3xl">
<div class="gap-stack flex flex-wrap items-center justify-between">
<h1 class="text-headline">Dialogs and feedback</h1>
<AppearanceTabs />
</div>
<FeedbackState
state="empty"
description="New tickets will appear here."
/>
<FeedbackState state="loading" />
<form
class="gap-card bg-card p-card grid rounded-md border"
@submit.prevent="submit"
>
<label for="title">Title</label>
<Input
id="title"
v-model="form.title"
:invalid="Boolean(form.errors.title)"
aria-describedby="title-error"
/>
<InputError id="title-error" :message="form.errors.title" />
<FormFeedback :state="protection.state.value" @retry="submit" />
<Button type="submit" :loading="form.processing">Save draft</Button>
<Button type="button" variant="outline" @click="form.cancel()"
>Cancel request</Button
>
</form>
<Link :href="projectsIndex()">Leave page</Link>
<div class="gap-stack flex flex-wrap">
<Dialog v-model:open="dialogOpen">
<Button id="programmatic-trigger" @click="dialogOpen = true"
>Open dialog</Button
>
<DialogContent>
<DialogHeader
><DialogTitle>Edit details</DialogTitle
><DialogDescription
>Keyboard and focus preview.</DialogDescription
></DialogHeader
>
<Input aria-label="Dialog title" />
<Dialog>
<DialogTrigger as-child
><Button>Open nested dialog</Button></DialogTrigger
>
<DialogContent>
<DialogHeader
><DialogTitle>Nested details</DialogTitle
><DialogDescription
>Returns focus to the parent
dialog.</DialogDescription
></DialogHeader
>
<DialogClose as-child
><Button>Done</Button></DialogClose
>
</DialogContent>
</Dialog>
<DialogClose as-child
><Button variant="outline">Cancel</Button></DialogClose
>
</DialogContent>
</Dialog>
<Dialog>
<DialogTrigger as-child
><Button>Open long dialog</Button></DialogTrigger
>
<DialogScrollContent>
<DialogHeader
><DialogTitle>Long details</DialogTitle
><DialogDescription
>Scrollable content on small
screens.</DialogDescription
></DialogHeader
>
<p v-for="item in 30" :key="item">Detail {{ item }}</p>
<DialogClose as-child
><Button>Finish reading</Button></DialogClose
>
</DialogScrollContent>
</Dialog>
<Sheet>
<SheetTrigger as-child
><Button>Open sheet</Button></SheetTrigger
>
<SheetContent class="p-page">
<SheetTitle>Sheet details</SheetTitle
><SheetDescription
>Focus returns to the trigger.</SheetDescription
>
<Input aria-label="Sheet title" />
</SheetContent>
</Sheet>
<DropdownMenu>
<DropdownMenuTrigger as-child
><Button>Open actions</Button></DropdownMenuTrigger
>
<DropdownMenuContent>
<DropdownMenuItem disabled>Unavailable</DropdownMenuItem>
<DropdownMenuItem @select="menuSelection = 'selected'"
>Select action</DropdownMenuItem
>
</DropdownMenuContent>
</DropdownMenu>
<span role="status">{{ menuSelection }}</span>
</div>
</main>
</template>
+47
View File
@@ -0,0 +1,47 @@
import { createApp, h } from 'vue';
import '../../resources/css/app.css';
import AppearanceTabs from '../../resources/js/components/AppearanceTabs.vue';
import AlertError from '../../resources/js/components/AlertError.vue';
import { Button } from '../../resources/js/components/ui/button';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
const localization = createLocalization();
createApp({
render: () =>
h('main', { class: 'mx-auto grid max-w-xl gap-column p-page' }, [
h('h1', { class: 'text-headline' }, 'Emerald design system'),
h(AppearanceTabs),
h(
'section',
{
class: 'grid gap-column rounded-md border bg-card p-card text-card-foreground',
},
[
h('h2', { class: 'text-title' }, 'Readable surfaces'),
h(
'p',
{ class: 'text-muted-foreground' },
'Inter, emerald actions and a visible keyboard focus.',
),
h('label', { for: 'sample-input' }, 'Sample input'),
h('input', {
id: 'sample-input',
class: 'min-h-control rounded-md border border-input bg-background px-3',
placeholder: 'Type here',
}),
h('div', { class: 'flex flex-wrap gap-stack' }, [
h(Button, {}, () => localization.t('common.save')),
h(Button, { variant: 'secondary' }, () =>
localization.t('common.cancel'),
),
h(Button, { variant: 'destructive' }, () => 'Delete'),
]),
h(AlertError, { errors: ['A readable validation error.'] }),
],
),
]),
})
.use(localization)
.use(createAppearance())
.mount('#app');
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" data-appearance="system">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Emerald appearance fixture</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./appearance-preview.mjs"></script>
</body>
</html>
+219
View File
@@ -0,0 +1,219 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import vm from 'node:vm';
import vue from '@vitejs/plugin-vue';
import { createServer } from 'vite';
import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
const server = await createServer({
configFile: false,
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
function browserState(cookie, stored, dark, storageDenied = false) {
const root = { dataset: { appearance: cookie }, style: {}, dark: false };
root.classList = {
toggle: (_, value) => {
root.dark = value;
},
};
const listeners = new Map();
const storage = new Map(stored === null ? [] : [['appearance', stored]]);
const media = {
matches: dark,
addEventListener: (_, listener) => listeners.set('media', listener),
removeEventListener: () => listeners.delete('media'),
};
return {
document: { documentElement: root, cookie: '' },
window: {
matchMedia: () => media,
localStorage: {
getItem: (key) => {
if (storageDenied) {
throw new Error('Storage denied');
}
return storage.get(key) ?? null;
},
setItem: (key, value) => {
if (storageDenied) {
throw new Error('Storage denied');
}
storage.set(key, value);
},
},
addEventListener: (_, listener) =>
listeners.set('storage', listener),
removeEventListener: () => listeners.delete('storage'),
},
root,
media,
listeners,
storage,
};
}
try {
const { createAppearance, normalizeAppearance } =
await server.ssrLoadModule(
'/resources/js/composables/useAppearance.ts',
);
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const { default: AppearanceTabs } = await server.ssrLoadModule(
'/resources/js/components/AppearanceTabs.vue',
);
const renderTheme = (value) =>
renderToString(
createSSRApp({ render: () => h(AppearanceTabs) })
.use(createLocalization())
.use(createAppearance(value)),
);
const [light, dark] = await Promise.all([
renderTheme('light'),
renderTheme('dark'),
]);
const selectedButton = (html) =>
html.match(
/<button[^>]*aria-pressed="true"[^>]*>([\s\S]*?)<\/button>/,
)[1];
assert.match(selectedButton(light), />Light</);
assert.match(selectedButton(dark), />Dark</);
assert.match(light, /role="group" aria-label="Color theme"/);
assert.equal((light.match(/type="button"/g) ?? []).length, 3);
assert.equal((dark.match(/aria-pressed="true"/g) ?? []).length, 1);
assert.equal(normalizeAppearance('unexpected'), 'system');
const blade = await readFile('resources/views/app.blade.php', 'utf8');
const bootstrap = blade.match(/<script>([\s\S]*?)<\/script>/)[1];
for (const [cookie, stored, osDark, denied, expected, preference] of [
['system', null, false, false, false, 'system'],
['system', null, true, false, true, 'system'],
['dark', null, false, false, true, 'dark'],
['light', null, true, false, false, 'light'],
['light', 'dark', false, false, true, 'dark'],
['dark', null, false, true, true, 'dark'],
['light', 'invalid', true, false, true, 'system'],
]) {
const state = browserState(cookie, stored, osDark, denied);
vm.runInNewContext(bootstrap, state);
assert.equal(state.root.dark, expected);
assert.equal(state.root.dataset.appearance, preference);
assert.equal(state.root.style.colorScheme, expected ? 'dark' : 'light');
}
for (const denied of [false, true]) {
const state = browserState('system', null, false, denied);
globalThis.window = state.window;
globalThis.document = state.document;
const theme = createAppearance();
let cleanup;
theme.install({
provide() {},
onUnmount(callback) {
cleanup = callback;
},
});
assert.equal(theme.resolvedAppearance.value, 'light');
state.listeners.get('media')({ matches: true });
assert.equal(theme.resolvedAppearance.value, 'dark');
assert.equal(state.root.dark, true);
theme.updateAppearance('light');
assert.equal(state.root.dark, false);
assert.match(state.document.cookie, /appearance=light;.*SameSite=Lax/);
state.listeners.get('media')({ matches: false });
state.listeners.get('media')({ matches: true });
assert.equal(theme.resolvedAppearance.value, 'light');
theme.updateAppearance('system');
assert.equal(theme.resolvedAppearance.value, 'dark');
state.listeners.get('storage')({
key: 'appearance',
newValue: 'light',
});
assert.equal(theme.appearance.value, 'light');
state.listeners.get('storage')({ key: null, newValue: null });
assert.equal(theme.appearance.value, 'system');
if (!denied) {
assert.equal(state.storage.get('appearance'), 'system');
}
cleanup();
assert.equal(state.listeners.size, 0);
delete globalThis.window;
delete globalThis.document;
}
const css = await readFile('resources/css/theme.css', 'utf8');
function luminance(hex) {
const values = hex
.match(/[a-f\d]{2}/gi)
.map((part) => parseInt(part, 16) / 255)
.map((value) =>
value <= 0.04045
? value / 12.92
: ((value + 0.055) / 1.055) ** 2.4,
);
return values[0] * 0.2126 + values[1] * 0.7152 + values[2] * 0.0722;
}
function contrast(first, second) {
const values = [luminance(first), luminance(second)].sort(
(a, b) => b - a,
);
return (values[0] + 0.05) / (values[1] + 0.05);
}
for (const selector of [':root', '.dark']) {
const declarations = css
.slice(css.indexOf(selector))
.match(/\{([^}]+)\}/)[1];
const tokens = Object.fromEntries(
[...declarations.matchAll(/--([\w-]+):\s*(#[a-f\d]{6})/gi)].map(
(match) => [match[1], match[2]],
),
);
for (const [background, foreground] of [
['background', 'foreground'],
['card', 'card-foreground'],
['popover', 'popover-foreground'],
['primary', 'primary-foreground'],
['secondary', 'secondary-foreground'],
['muted', 'muted-foreground'],
['accent', 'accent-foreground'],
['destructive', 'destructive-foreground'],
['card', 'destructive'],
['brand', 'brand-foreground'],
['success', 'success-foreground'],
['warning', 'warning-foreground'],
['info', 'info-foreground'],
['sidebar-background', 'sidebar-foreground'],
['sidebar-accent', 'sidebar-accent-foreground'],
['sidebar-primary', 'sidebar-primary-foreground'],
]) {
assert.ok(
contrast(tokens[background], tokens[foreground]) >= 4.5,
selector + ': ' + background + '/' + foreground,
);
}
assert.ok(contrast(tokens.background, tokens.ring) >= 3);
assert.ok(contrast(tokens.card, tokens.input) >= 3);
}
const appCss = await readFile('resources/css/app.css', 'utf8');
assert.match(appCss, /@media \(prefers-reduced-motion: reduce\)/);
assert.match(appCss, /animation-duration: 0.01ms !important/);
assert.match(appCss, /transition-duration: 0.01ms !important/);
assert.match(appCss, /scroll-behavior: auto !important/);
console.log('Appearance behavior checks passed.');
} finally {
delete globalThis.window;
delete globalThis.document;
await server.close();
}
+342
View File
@@ -0,0 +1,342 @@
import assert from 'node:assert/strict';
import process from 'node:process';
const { chromium } = await import(
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
);
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const base = 'http://127.0.0.1:4177';
const context = await browser.newContext();
const page = await context.newPage();
page.setDefaultTimeout(10000);
const errors = [];
const submissions = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('request', (request) => {
if (request.method() === 'POST') {
submissions.push({
path: new URL(request.url()).pathname,
data: request.postDataJSON(),
});
}
});
const outcome = (value) =>
context.addCookies([{ name: 'auth_test_result', value, url: base }]);
const open = async (path) => {
await page.goto(base + path);
await page.locator('main h1').waitFor();
};
const state = (value) =>
page.locator(`[data-form-state="${value}"]`).waitFor({ state: 'visible' });
const credentials = async () => {
await page
.getByLabel('Email address', { exact: true })
.fill('alex@example.test');
await page
.getByLabel('Password', { exact: true })
.fill('fixture-private-password');
};
const noStoredSecrets = async () => {
const persisted = await page.evaluate(() =>
JSON.stringify({
local: { ...localStorage },
session: { ...sessionStorage },
history: history.state,
}),
);
for (const secret of [
'fixture-private-password',
'012345',
'fixture-recovery-code',
]) {
assert.ok(
!persisted.includes(secret),
'Credentials must not be remembered in browser storage',
);
}
};
try {
for (const width of [320, 390, 1440]) {
await page.setViewportSize({ width, height: 900 });
for (const theme of ['light', 'dark']) {
await page.emulateMedia({ colorScheme: theme });
for (const path of [
'/login',
'/email/verify',
'/two-factor-challenge',
]) {
await open(path);
await page
.getByRole('button', {
name: theme === 'light' ? 'Light' : 'Dark',
exact: true,
})
.click();
await page
.getByRole('button', {
name: theme === 'light' ? 'Light' : 'Dark',
exact: true,
pressed: true,
})
.waitFor();
assert.equal(
await page
.locator('html')
.evaluate((el) => el.classList.contains('dark')),
theme === 'dark',
);
assert.equal(
await page.evaluate(
() =>
document.documentElement.scrollWidth <= innerWidth,
),
true,
`${path} must fit at ${width}`,
);
const controls = await page
.locator(
'main button:not([role="checkbox"]), main label[for="remember"], main input:not([type="checkbox"]):not([aria-hidden="true"])',
)
.evaluateAll((els) =>
els
.filter(
(el) => el.getBoundingClientRect().width > 0,
)
.map((el) => el.getBoundingClientRect().height),
);
assert.ok(
controls.every((height) => height >= 44),
`${path} has touch-sized controls`,
);
await page.screenshot({
path: `/private/tmp/kan21-${path.split('/').at(-1)}-${width}-${theme}.png`,
fullPage: true,
});
}
}
}
await page.setViewportSize({ width: 390, height: 480 });
await open('/login');
await page.getByRole('button', { name: 'System', exact: true }).click();
await page.emulateMedia({ colorScheme: 'light' });
await page.waitForFunction(
() => !document.documentElement.classList.contains('dark'),
);
await page.emulateMedia({ colorScheme: 'dark' });
await page.waitForFunction(() =>
document.documentElement.classList.contains('dark'),
);
await credentials();
const password = page.locator('#password');
assert.equal(
await password.getAttribute('autocomplete'),
'current-password',
);
assert.equal(
await page.locator('#email').getAttribute('autocomplete'),
'username',
);
await password.focus();
await page.keyboard.press('Tab');
assert.equal(
await page
.getByRole('button', { name: 'Show password' })
.evaluate((el) => el === document.activeElement),
true,
);
await page.keyboard.press('Enter');
assert.equal(await password.getAttribute('type'), 'text');
await page.getByRole('button', { name: 'Hide password' }).click();
await page.getByRole('checkbox', { name: 'Remember me' }).check();
await outcome('validation');
await password.press('Enter');
await state('validation');
assert.equal(await password.inputValue(), 'fixture-private-password');
assert.equal(
await page.locator('#email').inputValue(),
'alex@example.test',
);
assert.equal(
await page.locator('#email').getAttribute('aria-invalid'),
'true',
);
assert.equal(submissions.at(-1).data.remember, true);
assert.equal(
await page
.locator('[data-form-state="validation"]')
.evaluate((el) => el === document.activeElement),
true,
);
await noStoredSecrets();
await page.route('**/login', (route) =>
route.request().method() === 'POST'
? route.abort('failed')
: route.continue(),
);
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await state('network');
assert.equal(await password.inputValue(), 'fixture-private-password');
await page.unroute('**/login');
await outcome('limited');
await page.getByRole('button', { name: 'Try again' }).click();
await page.getByText('Too many attempts', { exact: true }).waitFor();
await outcome('expired');
await page.getByRole('button', { name: 'Try again' }).click();
await page.getByText('Your session has expired', { exact: true }).waitFor();
assert.equal(
await page.getByRole('button', { name: 'Try again' }).count(),
0,
);
await page.getByRole('link', { name: 'Return to sign in' }).click();
await page.waitForFunction(
() => document.querySelector('#password')?.value === '',
);
await outcome('success');
await credentials();
let release;
const held = new Promise((resolve) => {
release = resolve;
});
await page.route('**/login', async (route) => {
await held;
await route.continue();
});
const before = submissions.length;
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await state('loading');
assert.equal(await password.isDisabled(), true);
assert.equal(
await page.getByRole('button', { name: 'Show password' }).isDisabled(),
true,
);
await page
.locator('form')
.evaluate((el) =>
el.dispatchEvent(
new Event('submit', { bubbles: true, cancelable: true }),
),
);
assert.equal(submissions.length, before + 1);
release();
await page
.getByRole('heading', { name: 'Signed in destination' })
.waitFor();
await page.unroute('**/login');
await noStoredSecrets();
await page.goBack();
await page.locator('#password').waitFor();
assert.equal(await password.inputValue(), '');
await open('/login?no-reset=1');
assert.equal(
await page.getByRole('link', { name: 'Forgot password?' }).count(),
0,
);
await credentials();
await outcome('challenge');
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await page.locator('#code').waitFor();
assert.equal(
await page.locator('#code').getAttribute('inputmode'),
'numeric',
);
assert.equal(
await page.locator('#code').getAttribute('autocomplete'),
'one-time-code',
);
await page.locator('#code').evaluate((el) => {
const clipboardData = new DataTransfer();
clipboardData.setData('text', '012 345');
el.dispatchEvent(
new ClipboardEvent('paste', {
clipboardData,
bubbles: true,
cancelable: true,
}),
);
});
assert.equal(await page.locator('#code').inputValue(), '012345');
await outcome('validation');
await page.locator('#code').press('Enter');
await state('validation');
assert.deepEqual(submissions.at(-1).data, { code: '012345' });
assert.equal(await page.locator('#code').inputValue(), '012345');
await page
.getByRole('button', { name: 'Use a recovery code instead' })
.click();
assert.equal(
await page
.locator('#recovery_code')
.evaluate((el) => el === document.activeElement),
true,
);
assert.equal(await page.getByRole('alert').count(), 0);
await page.locator('#recovery_code').fill('fixture-recovery-code');
await page.locator('#recovery_code').press('Enter');
await state('validation');
assert.deepEqual(submissions.at(-1).data, {
recovery_code: 'fixture-recovery-code',
});
assert.equal(
await page.locator('#recovery_code').getAttribute('aria-invalid'),
'true',
);
assert.equal(
await page.locator('#recovery_code').inputValue(),
'fixture-recovery-code',
);
await noStoredSecrets();
await page
.getByRole('button', { name: 'Use an authenticator code instead' })
.click();
assert.equal(await page.locator('#code').inputValue(), '');
await page
.getByRole('button', { name: 'Use a recovery code instead' })
.click();
assert.equal(await page.locator('#recovery_code').inputValue(), '');
await page.locator('#recovery_code').fill('fixture-recovery-code');
await outcome('server');
await page.getByRole('button', { name: 'Verify and sign in' }).click();
await state('server');
assert.equal(
await page.locator('#recovery_code').inputValue(),
'fixture-recovery-code',
);
await outcome('success');
await page.getByRole('button', { name: 'Try again' }).click();
await page
.getByRole('heading', { name: 'Signed in destination' })
.waitFor();
await noStoredSecrets();
await open('/email/verify');
await page.getByText('alex.morgan@example.test', { exact: true }).waitFor();
await page
.getByRole('button', { name: 'Resend verification email', exact: true })
.click();
await page.getByText('Verification link sent', { exact: true }).waitFor();
assert.equal(submissions.at(-1).path, '/email/verification-notification');
await outcome('limited');
await page
.getByRole('button', { name: 'Resend verification email', exact: true })
.click();
await page.getByText('Too many attempts', { exact: true }).waitFor();
await outcome('success');
await page.getByRole('button', { name: 'Continue to workspace' }).click();
await page
.getByRole('heading', { name: 'Signed in destination' })
.waitFor();
await open('/email/verify');
await page.getByRole('button', { name: 'Sign out', exact: true }).click();
await page.locator('#email').waitFor();
assert.equal(submissions.at(-1).path, '/logout');
assert.deepEqual(errors, []);
console.log(
'Auth browser checks passed: themes, responsive layout, keyboard, autofill, retention, retry, expiry, throttling, 2FA, recovery, verification and logout.',
);
} finally {
await browser.close();
}
+20
View File
@@ -0,0 +1,20 @@
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import Login from '../../resources/js/pages/auth/Login.vue';
import VerifyEmail from '../../resources/js/pages/auth/VerifyEmail.vue';
import TwoFactorChallenge from '../../resources/js/pages/auth/TwoFactorChallenge.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
const Destination = { render: () => h('h1', 'Signed in destination') };
createInertiaApp({
resolve: (name) =>
({ Login, VerifyEmail, TwoFactorChallenge, Destination })[name],
setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance())
.mount(el),
});
+119
View File
@@ -0,0 +1,119 @@
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import tailwind from '@tailwindcss/vite';
import { createServer } from 'vite';
// Isolated Inertia transport fixture; real Fortify sessions are covered by PHPUnit.
const server = await createServer({
configFile: false,
cacheDir: 'node_modules/.vite/auth-fixture',
plugins: [vue(), tailwind()],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: { host: '127.0.0.1', port: 4177, strictPort: true, hmr: false },
appType: 'custom',
});
server.middlewares.use(async (request, response, next) => {
const url = new URL(request.url, 'http://localhost');
const pages = {
'/login': 'Login',
'/two-factor-challenge': 'TwoFactorChallenge',
'/email/verify': 'VerifyEmail',
'/email/verification-notification': 'VerifyEmail',
'/dashboard': 'Destination',
'/logout': 'Login',
'/forgot-password': 'Destination',
'/': 'Destination',
};
if (!(url.pathname in pages)) {
return next();
}
const mutation = request.method === 'POST';
const result =
request.headers.cookie?.match(/auth_test_result=(\w+)/)?.[1] ??
'success';
if (mutation && ['server', 'expired', 'limited'].includes(result)) {
response.statusCode = { server: 503, expired: 419, limited: 429 }[
result
];
response.setHeader('Content-Type', 'text/html');
response.end('<h1>Simulated failed response</h1>');
return;
}
let body = '';
if (mutation) {
for await (const chunk of request) {
body += chunk;
}
}
const submitted = body ? JSON.parse(body) : {};
let path = url.pathname;
let errors = {};
let status;
if (mutation && result === 'validation') {
path = new URL(request.headers.referer).pathname;
const field =
path === '/login'
? 'email'
: 'recovery_code' in submitted
? 'recovery_code'
: 'code';
errors = { [field]: 'These details could not be verified.' };
} else if (mutation) {
if (path === '/email/verification-notification') {
path = '/email/verify';
status = 'verification-link-sent';
} else if (path === '/logout') {
path = '/login';
} else {
path =
result === 'challenge' ? '/two-factor-challenge' : '/dashboard';
}
}
const page = {
component: pages[path],
url: path,
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
errors,
status,
canResetPassword: !url.searchParams.has('no-reset'),
auth: {
user:
path === '/email/verify'
? {
id: 1,
name: 'Alex Morgan',
email: 'alex.morgan@example.test',
}
: null,
},
},
};
response.setHeader('Vary', 'X-Inertia');
if (request.headers['x-inertia']) {
response.setHeader('X-Inertia', 'true');
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify(page));
return;
}
const escaped = JSON.stringify(page)
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
const html =
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
escaped +
'"></div><script type="module" src="/tests/Fixtures/auth-preview.mjs"></script></body></html>';
response.setHeader('Content-Type', 'text/html');
response.end(await server.transformIndexHtml(request.url, html));
});
await server.listen();
console.log('Auth fixture ready at http://127.0.0.1:4177/login');
+404
View File
@@ -0,0 +1,404 @@
import assert from 'node:assert/strict';
import process from 'node:process';
const { chromium } = await import(
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
);
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const base = 'http://127.0.0.1:4176';
const context = await browser.newContext({
viewport: { width: 1280, height: 900 },
});
const page = await context.newPage();
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const state = (value) =>
page
.locator(`[data-form-state="${value}"]`)
.first()
.waitFor({ state: 'visible' });
const setResult = (value) =>
context.addCookies([{ name: 'feedback_test_result', value, url: base }]);
const verify = async (condition) => {
assert.equal(await condition(), true);
};
const confirmNavigation = (accepted) =>
page.once('dialog', (dialog) =>
accepted ? dialog.accept() : dialog.dismiss(),
);
try {
await page.goto(`${base}/feedback`);
await page.getByLabel('Title', { exact: true }).fill('Unsaved draft');
await verify(() =>
page.evaluate(() => {
const event = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(event);
return event.defaultPrevented;
}),
);
confirmNavigation(false);
await page.getByRole('link', { name: 'Leave page' }).click();
assert.equal(new URL(page.url()).pathname, '/feedback');
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'Unsaved draft',
);
await setResult('validation');
await page.getByRole('button', { name: 'Save draft' }).click();
await state('validation');
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'Unsaved draft',
);
assert.equal(
await page
.getByLabel('Title', { exact: true })
.getAttribute('aria-invalid'),
'true',
);
assert.equal(
await page.getByRole('button', { name: 'Try again' }).count(),
0,
);
let failedRequests = 0;
await page.route('**/projects/7/tickets', (route) => {
failedRequests++;
return route.abort('failed');
});
await page.getByRole('button', { name: 'Save draft' }).click();
await state('network');
await page.waitForFunction(
() =>
document.activeElement?.getAttribute('data-form-state') ===
'network',
);
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'Unsaved draft',
);
assert.equal(failedRequests, 1);
await page.unroute('**/projects/7/tickets');
await setResult('server');
await page.getByRole('button', { name: 'Try again' }).click();
await state('server');
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'Unsaved draft',
);
await setResult('success');
let pending;
let submissions = 0;
await page.route('**/projects/7/tickets', (route) => {
submissions++;
pending = route;
});
await page.getByRole('button', { name: 'Try again' }).click();
await state('loading');
await verify(() =>
page.evaluate(() => {
const event = new CustomEvent('inertia:exception', {
cancelable: true,
detail: { exception: new Error('Unrelated runtime error') },
});
document.dispatchEvent(event);
return !event.defaultPrevented;
}),
);
await verify(() =>
page.getByRole('button', { name: 'Save draft' }).isDisabled(),
);
await page.locator('form').evaluate((form) => form.requestSubmit());
assert.equal(submissions, 1);
await page.getByRole('button', { name: 'Cancel request' }).click();
await state('cancelled');
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'Unsaved draft',
);
await pending.abort();
await page.unroute('**/projects/7/tickets');
await page.getByRole('button', { name: 'Try again' }).click();
await state('success');
await verify(() =>
page.evaluate(() => {
const event = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(event);
return !event.defaultPrevented;
}),
);
await page.getByLabel('Title', { exact: true }).fill('History draft');
confirmNavigation(true);
await page.getByRole('link', { name: 'Leave page' }).click();
await page.getByRole('heading', { name: 'Destination' }).waitFor();
await page.goBack();
await page.getByLabel('Title', { exact: true }).waitFor();
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'History draft',
);
await page.goForward();
await page.getByRole('heading', { name: 'Destination' }).waitFor();
await page.goBack();
await page.getByLabel('Title', { exact: true }).waitFor();
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'History draft',
);
// Native history cannot be cancelled; remembered state preserves the draft.
await page.getByRole('button', { name: 'Save draft' }).click();
await state('success');
for (const width of [1280, 390]) {
await page.setViewportSize({ width, height: 844 });
for (const theme of ['Light', 'Dark']) {
console.log(`Checking dialogs: ${width}px, ${theme}`);
await page
.getByRole('button', { name: theme, exact: true })
.click();
await page.waitForFunction(
(dark) =>
document.documentElement.classList.contains('dark') ===
dark,
theme === 'Dark',
);
const opener = page.getByRole('button', {
name: 'Open dialog',
exact: true,
});
await opener.focus();
await page.keyboard.press('Enter');
await page.getByRole('dialog', { name: 'Edit details' }).waitFor();
await page
.getByRole('dialog', { name: 'Edit details' })
.evaluate((element) =>
Promise.all(
[
element,
...document.querySelectorAll(
'[data-slot="dialog-overlay"]',
),
]
.flatMap((surface) => surface.getAnimations())
.map((animation) => animation.finished),
),
);
assert.equal(
await page
.getByRole('dialog', { name: 'Edit details' })
.evaluate((element) => getComputedStyle(element).opacity),
'1',
);
await page.waitForFunction(() =>
document.activeElement?.closest('[role="dialog"]'),
);
for (let step = 0; step < 7; step++) {
await page.keyboard.press('Tab');
await verify(() =>
page.evaluate(() =>
Boolean(
document.activeElement?.closest('[role="dialog"]'),
),
),
);
}
await page.keyboard.press('Shift+Tab');
await verify(() =>
page.evaluate(() =>
Boolean(document.activeElement?.closest('[role="dialog"]')),
),
);
await page.screenshot({
path: `/private/tmp/kan20-dialog-${width}-${theme.toLowerCase()}.png`,
});
await page
.getByRole('button', { name: 'Open nested dialog' })
.click();
await page
.getByRole('dialog', { name: 'Nested details' })
.waitFor();
await page.keyboard.press('Escape');
await page
.getByRole('dialog', { name: 'Nested details' })
.waitFor({ state: 'hidden' });
await page.waitForFunction(() =>
document.activeElement?.textContent.includes(
'Open nested dialog',
),
);
await page.keyboard.press('Escape');
await page
.getByRole('dialog', { name: 'Edit details' })
.waitFor({ state: 'hidden' });
await page.waitForFunction(
() => document.activeElement?.id === 'programmatic-trigger',
);
await opener.click();
await page
.getByRole('dialog', { name: 'Edit details' })
.getByRole('button', { name: 'Cancel', exact: true })
.click();
await page.waitForFunction(
() => document.activeElement?.id === 'programmatic-trigger',
);
const longOpener = page.getByRole('button', {
name: 'Open long dialog',
});
await longOpener.click();
const longDialog = page.getByRole('dialog', {
name: 'Long details',
});
await longDialog.waitFor();
const box = await longDialog.boundingBox();
assert.ok(box.x >= 0 && box.x + box.width <= width);
await longDialog
.getByRole('button', { name: 'Finish reading' })
.click();
await longDialog.waitFor({ state: 'hidden' });
await page.waitForFunction(() =>
document.activeElement?.textContent.includes(
'Open long dialog',
),
);
await page.getByRole('button', { name: 'Open sheet' }).click();
await page.getByRole('dialog', { name: 'Sheet details' }).waitFor();
await page.keyboard.press('Escape');
await page
.getByRole('dialog', { name: 'Sheet details' })
.waitFor({ state: 'hidden' });
await page.waitForFunction(() =>
document.activeElement?.textContent.includes('Open sheet'),
);
const actions = page.getByRole('button', { name: 'Open actions' });
await actions.focus();
await page.keyboard.press('Enter');
await page.getByRole('menu').waitFor();
await page
.getByRole('menu')
.evaluate((element) =>
Promise.all(
element
.getAnimations()
.map((animation) => animation.finished),
),
);
const menuItem = await page
.getByRole('menuitem', { name: 'Select action' })
.boundingBox();
assert.ok(menuItem.height >= 44);
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
await page.getByRole('menu').waitFor({ state: 'hidden' });
await page.waitForFunction(() =>
document.activeElement?.textContent.includes('Open actions'),
);
assert.equal(
await page.getByText('selected', { exact: true }).count(),
1,
);
await verify(() =>
page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
);
await page.screenshot({
path: `/private/tmp/kan20-feedback-${width}-${theme.toLowerCase()}.png`,
fullPage: true,
});
}
}
// Exercise the real ticket pages as well as the isolated components.
await page.goto(`${base}/projects/7/tickets/create`);
await page.locator('#title').fill('New ticket draft');
await page.locator('#description').fill('Keep this description');
await page
.locator('#acceptance_criteria_checklist')
.fill('[ ] Keep this checklist');
await setResult('validation');
await page.locator('form button[type="submit"]').first().click();
await state('validation');
assert.equal(
await page.locator('#acceptance_criteria_checklist').inputValue(),
'[ ] Keep this checklist',
);
await page.route('**/projects/7/tickets', (route) => route.abort('failed'));
await page.locator('form button[type="submit"]').first().click();
await state('network');
assert.equal(await page.locator('#title').inputValue(), 'New ticket draft');
assert.equal(
await page.locator('#description').inputValue(),
'Keep this description',
);
await page.unroute('**/projects/7/tickets');
confirmNavigation(true);
await page
.getByRole('link', { name: 'Back to backlog', exact: true })
.first()
.click();
await page.getByRole('heading', { name: 'Destination' }).waitFor();
await page.goBack();
await page.locator('#title').waitFor();
assert.equal(await page.locator('#title').inputValue(), 'New ticket draft');
assert.equal(
await page.locator('#acceptance_criteria_checklist').inputValue(),
'[ ] Keep this checklist',
);
await setResult('success');
await page.locator('form button[type="submit"]').first().click();
await page.getByRole('heading', { name: 'Destination' }).waitFor();
await page.goto(`${base}/projects/7/tickets/31`);
await page.locator('#title').fill('Updated ticket draft');
await page
.locator('#acceptance_criteria_checklist')
.fill('[ ] Edited checklist');
await setResult('server');
await page.locator('form button[type="submit"]').first().click();
await state('server');
assert.equal(
await page.locator('#title').inputValue(),
'Updated ticket draft',
);
assert.equal(
await page.locator('#acceptance_criteria_checklist').inputValue(),
'[ ] Edited checklist',
);
await setResult('success');
await page.getByRole('button', { name: 'Try again' }).click();
await state('success');
await verify(() =>
page.evaluate(() => {
const event = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(event);
return !event.defaultPrevented;
}),
);
assert.deepEqual(errors, []);
console.log(
'Feedback browser behavior checks passed (desktop/mobile, light/dark, real ticket forms).',
);
} catch (error) {
console.error(
'Active element:',
await page.evaluate(() =>
document.activeElement?.outerHTML.slice(0, 500),
),
);
console.error('Browser errors:', errors);
await page.screenshot({
path: '/private/tmp/kan20-feedback-failure.png',
fullPage: true,
});
throw error;
} finally {
await browser.close();
}
+26
View File
@@ -0,0 +1,26 @@
import { createApp, h } from 'vue';
import { createInertiaApp, Link } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import FeedbackPreview from './FeedbackPreview.vue';
import TicketCreate from '../../resources/js/pages/projects/TicketCreate.vue';
import Ticket from '../../resources/js/pages/projects/Ticket.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
const Destination = {
render: () =>
h('main', [
h('h1', 'Destination'),
h(Link, { href: '/feedback' }, () => 'Feedback preview'),
]),
};
createInertiaApp({
resolve: (name) =>
({ FeedbackPreview, TicketCreate, Ticket, Destination })[name],
setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance())
.mount(el),
});
+159
View File
@@ -0,0 +1,159 @@
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import tailwind from '@tailwindcss/vite';
import { createServer } from 'vite';
const server = await createServer({
configFile: false,
plugins: [vue(), tailwind()],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: { host: '127.0.0.1', port: 4176, strictPort: true, hmr: false },
appType: 'custom',
});
const project = {
id: 7,
name: 'Engineering',
key: 'ENG',
show_author: true,
show_story_points: true,
};
const ticketType = { id: 1, name: 'Task', is_parent: false };
const priority = { id: 1, name: 'Medium' };
const status = { id: 1, name: 'To do', board_column: null };
let ticket = {
id: 31,
ticket_key: 'ENG-31',
title: 'Existing ticket',
description: '',
ticket_type: ticketType,
ticket_type_id: 1,
ticket_priority: priority,
ticket_priority_id: 1,
ticket_status: status,
is_blocked: false,
story_points: 0,
acceptance_criteria: [],
field_values: [],
worklog_entries: [],
};
server.middlewares.use(async (request, response, next) => {
const url = new URL(request.url, 'http://localhost');
if (!/^\/(feedback|projects|dashboard)(\/|$)/.test(url.pathname)) {
return next();
}
let path = url.pathname;
const mutation = request.method !== 'GET';
const result =
request.headers.cookie?.match(/feedback_test_result=(\w+)/)?.[1] ??
'success';
if (path.endsWith('/branches/validate')) {
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify({ is_valid: true, message: 'Available' }));
return;
}
if (mutation) {
if (result === 'server') {
response.statusCode = 503;
response.setHeader('Content-Type', 'text/html');
response.end('<h1>Temporarily unavailable</h1>');
return;
}
path = new URL(request.headers.referer).pathname;
let body = '';
for await (const chunk of request) {
body += chunk;
}
if (path === '/projects/7/tickets/31' && result === 'success') {
ticket = { ...ticket, ...JSON.parse(body) };
}
if (path === '/projects/7/tickets/create' && result === 'success') {
path = '/projects/7/backlog';
}
}
const page = {
component:
path === '/feedback'
? 'FeedbackPreview'
: path.endsWith('/tickets/create')
? 'TicketCreate'
: path === '/projects/7/tickets/31'
? 'Ticket'
: 'Destination',
url: path,
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
sidebarOpen: true,
auth: {
permissions: {
globalAdmin: true,
projectCreate: true,
canManageTeams: true,
},
user: {
id: 1,
name: 'Alex Morgan',
email: 'alex@example.test',
avatar: null,
},
},
navigation: {
project: {
...project,
canManage: true,
canCreateTickets: true,
},
},
errors:
mutation && result === 'validation'
? { title: 'Use a different title.' }
: {},
project,
source: 'backlog',
defaultTicketStatusId: 1,
ticketTypes: [ticketType],
ticketPriorities: [priority],
ticketStatusOptions: [status],
users: [],
parentTicketOptions: [],
canEditTicket: true,
canManageProject: true,
childTicketTree: [],
linkedTickets: [],
repositoryBranches: [],
repositoryPullRequests: [],
suggestedBranchName: 'feature/ENG-31-existing-ticket',
ticket,
ticketFieldDefinitions: [],
ticketLinkOptions: [],
ticketLinkTypes: [],
},
};
response.setHeader('Vary', 'X-Inertia');
if (request.headers['x-inertia']) {
response.setHeader('X-Inertia', 'true');
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify(page));
return;
}
const escapedPage = JSON.stringify(page)
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
const html =
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
escapedPage +
'"></div><script type="module" src="/tests/Fixtures/feedback-preview.mjs"></script></body></html>';
response.setHeader('Content-Type', 'text/html');
response.end(await server.transformIndexHtml(request.url, html));
});
await server.listen();
console.log('Feedback fixture ready at http://127.0.0.1:4176/feedback');
+171
View File
@@ -0,0 +1,171 @@
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import { createServer } from 'vite';
import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
const server = await createServer({
configFile: false,
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
try {
const { isFormNetworkFailure, matchesFormRequest, formRequestHeader } =
await server.ssrLoadModule('/resources/js/lib/form-feedback.ts');
const config = { headers: { [formRequestHeader]: 'submission-a' } };
for (const code of ['ERR_NETWORK', 'ECONNABORTED', 'ETIMEDOUT']) {
assert.equal(
isFormNetworkFailure(
{ isAxiosError: true, code, config },
'submission-a',
),
true,
);
}
for (const error of [
null,
new Error('Render failed'),
{ code: 'ERR_NETWORK', config },
{ isAxiosError: true, code: 'ERR_CANCELED', config },
{
isAxiosError: true,
code: 'ERR_NETWORK',
config,
response: { status: 500 },
},
{ isAxiosError: true, code: 'ERR_NETWORK', config: {} },
]) {
assert.equal(isFormNetworkFailure(error, 'submission-a'), false);
}
assert.equal(
isFormNetworkFailure(
{ isAxiosError: true, code: 'ERR_NETWORK', config },
'submission-b',
),
false,
);
assert.equal(matchesFormRequest(config, null), false);
assert.equal(matchesFormRequest(config, 'submission-a'), true);
assert.equal(matchesFormRequest(undefined, 'submission-a'), false);
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const { en } = await server.ssrLoadModule('/resources/js/locales/en.ts');
const { default: FeedbackState } = await server.ssrLoadModule(
'/resources/js/components/FeedbackState.vue',
);
const { default: FormFeedback } = await server.ssrLoadModule(
'/resources/js/components/FormFeedback.vue',
);
const { default: InputError } = await server.ssrLoadModule(
'/resources/js/components/InputError.vue',
);
const { default: AuthFormFeedback } = await server.ssrLoadModule(
'/resources/js/components/AuthFormFeedback.vue',
);
const { default: PasswordInput } = await server.ssrLoadModule(
'/resources/js/components/PasswordInput.vue',
);
const localization = createLocalization(
{ locale: 'de' },
{
de: {
...en,
'common.retry': 'Erneut versuchen',
'feedback.empty': 'Noch keine Einträge',
'auth.feedback.expiredTitle': 'Sitzung abgelaufen',
'auth.password.show': 'Passwort anzeigen',
},
},
);
const render = (component, props) =>
renderToString(
createSSRApp({ render: () => h(component, props) }).use(
localization,
),
);
assert.match(
await render(FeedbackState, { state: 'empty' }),
/Noch keine Einträge/,
);
const loading = await render(FormFeedback, { state: 'loading' });
assert.match(loading, /role="status"/);
assert.match(loading, /aria-busy="true"/);
assert.match(loading, /motion-reduce:animate-none/);
assert.doesNotMatch(loading, /<button/);
const validation = await render(FormFeedback, { state: 'validation' });
assert.match(validation, /role="alert"/);
assert.match(validation, /Check your entries/);
assert.doesNotMatch(validation, /<button/);
for (const state of ['network', 'server', 'cancelled']) {
const html = await render(FormFeedback, { state });
assert.match(html, /Erneut versuchen/);
assert.match(html, /type="button"/);
assert.match(html, /role="alert"/);
}
assert.match(
await render(FormFeedback, { state: 'network' }),
/Connection interrupted/,
);
assert.match(
await render(FormFeedback, { state: 'server' }),
/unexpected response/,
);
assert.match(
await render(FormFeedback, { state: 'success' }),
/Changes saved/,
);
assert.doesNotMatch(await render(FormFeedback, { state: 'idle' }), /role=/);
const fieldError = await render(InputError, {
message: '<script>alert(1)</script>',
});
assert.match(fieldError, /role="alert"/);
assert.match(fieldError, /text-destructive/);
assert.doesNotMatch(fieldError, /<script>/);
const expired = await render(AuthFormFeedback, {
state: 'server',
responseStatus: 419,
});
assert.match(expired, /Sitzung abgelaufen/);
assert.match(expired, /href="\/login"/);
assert.doesNotMatch(expired, /Erneut versuchen/);
assert.match(
await render(AuthFormFeedback, {
state: 'server',
responseStatus: 429,
}),
/Too many attempts/,
);
assert.match(
await render(AuthFormFeedback, {
state: 'loading',
loadingTitle: 'Signing in',
}),
/Signing in/,
);
assert.match(
await render(AuthFormFeedback, { state: 'validation' }),
/Check your details/,
);
const password = await render(PasswordInput, {
disabled: true,
autocomplete: 'current-password',
});
assert.match(password, /Passwort anzeigen/);
assert.match(password, /autocomplete="current-password"/);
assert.match(password, /aria-pressed="false"/);
assert.doesNotMatch(password, /tabindex="-1"/);
assert.match(password, /<button[^>]*disabled/);
console.log('Form feedback behavior checks passed.');
} finally {
await server.close();
}
+169
View File
@@ -0,0 +1,169 @@
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import { createServer } from 'vite';
import { computed, createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
const server = await createServer({
configFile: false,
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
try {
const { createLocalization, validateCatalog } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const { useLocalization } = await server.ssrLoadModule(
'/resources/js/composables/useLocalization.ts',
);
const { en } = await server.ssrLoadModule('/resources/js/locales/en.ts');
const { default: AlertError } = await server.ssrLoadModule(
'/resources/js/components/AlertError.vue',
);
const english = createLocalization();
assert.equal(english.t('common.save'), 'Save');
assert.equal(
english.t('pagination.page', { page: 0, total: 2000 }),
'Page 0 of 2,000',
);
for (const [count, expected] of [
[0, '0 tickets'],
[1, '1 ticket'],
[2, '2 tickets'],
[1.5, '1.5 tickets'],
]) {
assert.equal(english.t('tickets.count', { count }), expected);
}
assert.equal(
english.number(1234.5, { minimumFractionDigits: 2 }),
'1,234.50',
);
assert.equal(
english.number(12, { style: 'currency', currency: 'USD' }),
'$12.00',
);
assert.equal(english.date(new Date('2026-09-09T23:30:00Z')), '9/9/2026');
assert.equal(
createLocalization({ timeZone: 'Europe/Berlin' }).date(
new Date('2026-09-09T23:30:00Z'),
),
'9/10/2026',
);
assert.throws(() => english.t('missing.key'), /Missing translation/);
assert.throws(() => english.t('toString'), /Missing translation/);
assert.throws(
() => english.t('pagination.page', { page: 1 }),
/Missing translation parameter/,
);
for (const count of [undefined, '1', Infinity, NaN]) {
assert.throws(
() => english.t('tickets.count', { count }),
/finite count/,
);
}
const incomplete = { ...en };
delete incomplete['common.save'];
assert.throws(
() => validateCatalog(incomplete),
/Missing or invalid translation/,
);
assert.throws(
() =>
validateCatalog({
...en,
'tickets.count': { one: '{count} ticket' },
}),
/plural fallback/,
);
assert.throws(
() => validateCatalog({ ...en, 'pagination.page': 'Page {unknown}' }),
/placeholders/,
);
assert.throws(
() => validateCatalog({ ...en, 'common.save': '' }),
/placeholders/,
);
assert.throws(
() => createLocalization({}, { de: incomplete }),
/Missing or invalid translation/,
);
for (const locale of ['de-DE', 'en_US', 'not a locale', '']) {
assert.equal(createLocalization({ locale }).locale.value, 'en');
}
const de = {
...en,
'common.error': 'Etwas ist schiefgelaufen.',
'common.save': 'Speichern',
};
const translated = createLocalization({ locale: 'de_DE' }, { de });
assert.equal(translated.locale.value, 'de');
assert.equal(translated.number(1234.5), '1.234,5');
const label = computed(() => translated.t('common.save'));
assert.equal(label.value, 'Speichern');
translated.setLocale('en');
assert.equal(label.value, 'Save');
translated.setLocale('de');
assert.equal(label.value, 'Speichern');
assert.equal(english.locale.value, 'en');
const renderAlert = (localization, props = {}) =>
renderToString(
createSSRApp({
render: () =>
h(AlertError, {
errors: ['Invalid input', 'Invalid input'],
...props,
}),
}).use(localization),
);
const [englishHtml, germanHtml] = await Promise.all([
renderAlert(english),
renderAlert(translated),
]);
assert.match(englishHtml, /Something went wrong\./);
assert.match(germanHtml, /Etwas ist schiefgelaufen\./);
assert.equal((englishHtml.match(/Invalid input/g) ?? []).length, 1);
assert.match(
await renderAlert(translated, { title: 'Custom title' }),
/Custom title/,
);
assert.doesNotMatch(
await renderAlert(translated, { title: '' }),
/Etwas ist schiefgelaufen/,
);
const InterpolatedText = {
setup() {
const { t } = useLocalization();
return () =>
h(
'p',
t('pagination.page', {
page: '<script>alert(1)</script>',
total: 1,
}),
);
},
};
const escaped = await renderToString(
createSSRApp(InterpolatedText).use(createLocalization()),
);
assert.match(escaped, /&lt;script&gt;/);
assert.doesNotMatch(escaped, /<script>/);
console.log('Localization behavior checks passed.');
} finally {
await server.close();
}

Some files were not shown because too many files have changed in this diff Show More