Compare commits

...
11 Commits
Author SHA1 Message Date
Twyco d0e651809e feat(KAN-25): build personal dashboard
CI/CD / test_and_check_app (push) Successful in 5m41s
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-11 11:00:47 +02:00
Twyco 3f00e9f2b5 feat(KAN-26): add reusable ticket presentations
CI/CD / test_and_check_app (push) Successful in 5m25s
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-11 09:33:50 +02:00
Twyco 990e18e149 feat(KAN-24): rebuild project creation flow
CI/CD / test_and_check_app (push) Successful in 5m9s
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 19:47:34 +02:00
Twyco d34216f1b8 feat(KAN-23): rebuild project overview and entry pages
CI/CD / test_and_check_app (push) Successful in 5m6s
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 16:12:32 +02:00
Twyco be89a4900d feat(KAN-22): rebuild password confirmation flows
CI/CD / test_and_check_app (push) Successful in 4m50s
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 0s
2026-09-10 12:28:55 +02:00
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
148 changed files with 10863 additions and 2651 deletions
@@ -47,6 +47,7 @@ class BuildProjectBacklogPayload
'tickets' => TicketResource::collection( 'tickets' => TicketResource::collection(
Ticket::query() Ticket::query()
->with([ ->with([
'assignee',
'ticketPriority', 'ticketPriority',
'ticketStatus.allowedTargetStatuses.boardColumn', 'ticketStatus.allowedTargetStatuses.boardColumn',
'ticketStatus.boardColumn', 'ticketStatus.boardColumn',
@@ -29,6 +29,7 @@ class BuildProjectBoardPayload
'ticketStatuses', 'ticketStatuses',
'tickets' => fn ($query) => $query 'tickets' => fn ($query) => $query
->with([ ->with([
'assignee',
'ticketPriority', 'ticketPriority',
'ticketStatus.allowedTargetStatuses.boardColumn', 'ticketStatus.allowedTargetStatuses.boardColumn',
'ticketStatus.boardColumn', 'ticketStatus.boardColumn',
@@ -41,8 +41,8 @@ class ListAssignedTicketsByVisibleProject
->where('assignee_user_id', $assignee->id) ->where('assignee_user_id', $assignee->id)
->whereIn('project_id', $visibleProjectIds) ->whereIn('project_id', $visibleProjectIds)
->with([ ->with([
'acceptanceCriteria',
'assignee', 'assignee',
'project',
'ticketPriority', 'ticketPriority',
'ticketStatus.boardColumn', 'ticketStatus.boardColumn',
'ticketType', 'ticketType',
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Tickets\ListAssignedTicketsByVisibleProject;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
public function __construct(
private readonly ListAssignedTicketsByVisibleProject $listAssignedTicketsByVisibleProject,
) {}
public function __invoke(Request $request): Response
{
$user = $request->user();
return Inertia::render('Dashboard', [
'assignedTicketsByProject' => $this->listAssignedTicketsByVisibleProject->handle($user, $user),
]);
}
}
+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>
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ArrowUpRight } from 'lucide-vue-next';
import TicketMetadata from '@/components/tickets/TicketMetadata.vue';
import { Badge } from '@/components/ui/badge';
import { useLocalization } from '@/composables/useLocalization';
import { show as showTicket } from '@/routes/projects/tickets';
import type {
TicketPresentationEmits,
TicketPresentationProps,
} from '@/types/ticket-presentation';
withDefaults(defineProps<TicketPresentationProps>(), {
showDescription: true,
showStatus: true,
});
const emit = defineEmits<TicketPresentationEmits>();
const { t } = useLocalization();
</script>
<template>
<article
:class="[
'bg-card text-card-foreground min-w-0 rounded-md border shadow-sm',
ticket.is_blocked ? 'border-warning' : 'border-border',
]"
>
<Link
:href="showTicket({ project: projectId, ticket: ticket.id })"
:aria-label="
t('tickets.open', {
key: ticket.ticket_key,
title: ticket.title,
})
"
class="focus-visible:ring-ring/35 gap-card min-h-control hover:bg-accent/35 group flex min-w-0 flex-col rounded-t-md p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2"
@click="emit('open', ticket.id)"
>
<div class="flex min-w-0 items-start justify-between gap-3">
<Badge
variant="outline"
class="wrap-anywhere max-w-full whitespace-normal"
>
{{ ticket.ticket_key }}
</Badge>
<ArrowUpRight
class="text-muted-foreground group-hover:text-primary size-4 shrink-0"
aria-hidden="true"
/>
</div>
<h3
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
>
{{ ticket.title }}
</h3>
<TicketMetadata :ticket="ticket" :show-status="showStatus" />
<p
v-if="showDescription && ticket.description"
class="text-caption text-muted-foreground wrap-anywhere line-clamp-3"
>
{{ ticket.description }}
</p>
<p
v-if="ticket.is_blocked && ticket.blocked_comment"
class="text-caption text-warning-foreground bg-warning wrap-anywhere rounded-sm px-3 py-2"
>
{{
t('tickets.blockerComment', {
comment: ticket.blocked_comment,
})
}}
</p>
</Link>
<div v-if="$slots.actions" class="border-t p-4">
<slot name="actions" />
</div>
</article>
</template>
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ArrowUpRight } from 'lucide-vue-next';
import TicketMetadata from '@/components/tickets/TicketMetadata.vue';
import { Badge } from '@/components/ui/badge';
import { useLocalization } from '@/composables/useLocalization';
import { show as showTicket } from '@/routes/projects/tickets';
import type {
TicketPresentationEmits,
TicketPresentationProps,
} from '@/types/ticket-presentation';
withDefaults(defineProps<TicketPresentationProps>(), {
showDescription: true,
showStatus: true,
});
const emit = defineEmits<TicketPresentationEmits>();
const { t } = useLocalization();
</script>
<template>
<li
:class="[
'bg-card text-card-foreground min-w-0 rounded-md border',
ticket.is_blocked ? 'border-warning' : 'border-border',
]"
>
<div class="flex min-w-0 flex-col lg:flex-row lg:items-stretch">
<Link
:href="showTicket({ project: projectId, ticket: ticket.id })"
:aria-label="
t('tickets.open', {
key: ticket.ticket_key,
title: ticket.title,
})
"
class="focus-visible:ring-ring/35 gap-card min-h-control hover:bg-accent/35 group flex min-w-0 flex-1 flex-col rounded-t-md p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2 lg:rounded-l-md lg:rounded-tr-none"
@click="emit('open', ticket.id)"
>
<div class="flex min-w-0 items-start gap-3">
<Badge
variant="outline"
class="wrap-anywhere max-w-full whitespace-normal"
>
{{ ticket.ticket_key }}
</Badge>
<h3
class="text-body group-hover:text-primary wrap-anywhere min-w-0 flex-1 font-semibold"
>
{{ ticket.title }}
</h3>
<ArrowUpRight
class="text-muted-foreground group-hover:text-primary size-4 shrink-0"
aria-hidden="true"
/>
</div>
<TicketMetadata :ticket="ticket" :show-status="showStatus" />
<p
v-if="showDescription && ticket.description"
class="text-caption text-muted-foreground wrap-anywhere line-clamp-2"
>
{{ ticket.description }}
</p>
<p
v-if="ticket.is_blocked && ticket.blocked_comment"
class="text-caption text-warning-foreground bg-warning wrap-anywhere rounded-sm px-3 py-2"
>
{{
t('tickets.blockerComment', {
comment: ticket.blocked_comment,
})
}}
</p>
</Link>
<div
v-if="$slots.actions"
class="border-t p-4 lg:flex lg:w-80 lg:items-center lg:border-l lg:border-t-0"
>
<slot name="actions" />
</div>
</div>
</li>
</template>
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { CircleUserRound, OctagonAlert } from 'lucide-vue-next';
import { computed } from 'vue';
import { Badge } from '@/components/ui/badge';
import { UserAvatar } from '@/components/ui/avatar';
import { useLocalization } from '@/composables/useLocalization';
import { ticketStatusLabel } from '@/lib/tickets';
import type { TicketModel } from '@/types/models/ticket-model';
const props = withDefaults(
defineProps<{
showStatus?: boolean;
ticket: TicketModel;
}>(),
{
showStatus: true,
},
);
const { t } = useLocalization();
const statusLabel = computed(() =>
ticketStatusLabel(props.ticket.ticket_status, t('tickets.noStatus')),
);
</script>
<template>
<div class="gap-stack flex min-w-0 flex-wrap items-center">
<Badge
v-if="ticket.is_blocked"
variant="warning"
:aria-label="t('tickets.blocked')"
>
<OctagonAlert aria-hidden="true" />
{{ t('tickets.blocked') }}
</Badge>
<Badge
variant="secondary"
:aria-label="
t('tickets.typeLabel', { name: ticket.ticket_type.name })
"
:data-ticket-type-id="ticket.ticket_type.id"
>
{{ ticket.ticket_type.name }}
</Badge>
<Badge
variant="outline"
:aria-label="
t('tickets.priorityLabel', {
name: ticket.ticket_priority.name,
})
"
:data-ticket-priority-id="ticket.ticket_priority.id"
>
{{ ticket.ticket_priority.name }}
</Badge>
<Badge
v-if="showStatus"
variant="outline"
:aria-label="t('tickets.statusLabel', { name: statusLabel })"
:data-ticket-status-id="ticket.ticket_status?.id"
>
{{ statusLabel }}
</Badge>
<span
v-if="ticket.assignee"
class="text-caption text-muted-foreground flex min-w-0 items-center gap-1.5"
:aria-label="
t('tickets.assignedTo', { name: ticket.assignee.name })
"
>
<UserAvatar
:user="ticket.assignee"
size="sm"
:label="ticket.assignee.name"
/>
<span class="max-w-40 truncate">{{ ticket.assignee.name }}</span>
</span>
<span
v-else
class="text-caption text-muted-foreground flex items-center gap-1.5"
>
<CircleUserRound class="size-4" aria-hidden="true" />
{{ t('tickets.unassigned') }}
</span>
</div>
</template>
+2
View File
@@ -0,0 +1,2 @@
export { default as TicketCard } from './TicketCard.vue';
export { default as TicketListItem } from './TicketListItem.vue';
+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;
}
}
+20
View File
@@ -0,0 +1,20 @@
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
export function ticketStatusLabel(
ticketStatus: TicketStatusModel | null,
fallback: string,
): string {
if (ticketStatus === null) {
return fallback;
}
if (
ticketStatus.board_column === null ||
ticketStatus.board_column === undefined ||
ticketStatus.board_column.name === ticketStatus.name
) {
return ticketStatus.name;
}
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
}
+335
View File
@@ -0,0 +1,335 @@
export const en = {
'projectCreation.title': 'Create a project',
'projectCreation.description':
'Give your work a home. Start fresh or reuse a setup that already works for your team.',
'projectCreation.start': 'How would you like to start?',
'projectCreation.scratch': 'Start from scratch',
'projectCreation.scratchDescription':
'Create an empty project with the default setup.',
'projectCreation.duplicate': 'Duplicate a project',
'projectCreation.duplicateDescription':
'Choose an existing project and copy selected settings.',
'projectCreation.template': 'Use a template',
'projectCreation.templateDescription':
'Start with a saved, reusable project configuration.',
'projectCreation.noProjects':
'No accessible projects are available to copy yet.',
'projectCreation.noTemplates': 'No published templates are available yet.',
'projectCreation.details': 'Project details',
'projectCreation.name': 'Project name',
'projectCreation.namePlaceholder': 'Platform engineering',
'projectCreation.key': 'Project key',
'projectCreation.keyPlaceholder': 'CORE',
'projectCreation.keyHelp':
'Use up to 5 letters or numbers. Keys are unique and converted to uppercase.',
'projectCreation.copyTitle': 'Choose what to copy',
'projectCreation.copyHelp':
'Copy configuration into an independent project. Select only what you need.',
'projectCreation.sourceProject': 'Source project',
'projectCreation.selectProject': 'Select a source project',
'projectCreation.copyOptions': 'Configuration areas',
'projectCreation.copyDefaults':
'Unselected areas keep the default setup. You can also continue without copying any settings.',
'projectCreation.chooseTemplate': 'Choose a template',
'projectCreation.templateLabel': 'Template',
'projectCreation.selectTemplate': 'Select a template',
'projectCreation.templateHelp':
'The project stays linked to this template. Inherited areas follow its configuration; you can override them in project settings.',
'projectCreation.includedAreas': 'Included configuration',
'projectCreation.templateDefaults':
'This template uses the default project setup.',
'projectCreation.summary': 'Your new project',
'projectCreation.untitled': 'Add a project name',
'projectCreation.ticketPreview': 'First ticket key',
'projectCreation.setup': 'Starting point',
'projectCreation.cleanStart':
'Every project starts with an empty backlog and its own ticket numbering. Tickets and their history are never copied.',
'projectCreation.submit': 'Create project',
'projectCreation.creating': 'Creating project…',
'projectCreation.areas.projectSettings.label': 'Project settings',
'projectCreation.areas.projectSettings.description':
'Story points and author visibility.',
'projectCreation.areas.boardColumns.label': 'Board columns',
'projectCreation.areas.boardColumns.description':
'Columns and their order on the board.',
'projectCreation.areas.ticketStatuses.label': 'Ticket statuses',
'projectCreation.areas.ticketStatuses.description':
'Statuses and their board columns.',
'projectCreation.areas.statusTransitions.label': 'Status transitions',
'projectCreation.areas.statusTransitions.description':
'Allowed moves between statuses.',
'projectCreation.areas.ticketTypes.label': 'Ticket types',
'projectCreation.areas.ticketTypes.description':
'Available types, including parent types.',
'projectCreation.areas.ticketPriorities.label': 'Ticket priorities',
'projectCreation.areas.ticketPriorities.description':
'Available priority levels.',
'projectCreation.areas.ticketFields.label': 'Custom ticket fields',
'projectCreation.areas.ticketFields.description':
'Field definitions without ticket values.',
'projectCreation.areas.ticketLinkTypes.label': 'Ticket link types',
'projectCreation.areas.ticketLinkTypes.description':
'Relationship labels between tickets.',
'projectCreation.areas.repositories.label': 'Repository connections',
'projectCreation.areas.repositories.description':
'Repositories linked to the project.',
'projectCreation.areas.notifications.label': 'Notification actions',
'projectCreation.areas.notifications.description':
'Notification rules and delivery settings.',
'projectCreation.selectedCount': {
one: '{count} area selected',
other: '{count} areas selected',
},
'projects.workspace': 'Workspace',
'projects.description':
'Your project spaces, ready to work in. Open a project or jump straight to its board and backlog.',
'projects.create': 'New project',
'projects.updated': 'Project update',
'projects.available': {
one: '{count} project available',
other: '{count} projects available',
},
'projects.open': 'Open {name} overview',
'projects.openBoard': 'Open {name} board',
'projects.openBacklog': 'Open {name} backlog',
'projects.overview': 'Project overview',
'projects.empty.title': 'No projects to show',
'projects.empty.createDescription':
'Create a project to give your team a place to plan and track work.',
'projects.empty.accessDescription':
'Projects appear here when you have access. Ask a workspace or project administrator to add you.',
'projects.empty.create': 'Create your first project',
'projects.back': 'All projects',
'projects.hubDescription':
'Follow work on the board or explore tickets in the backlog.',
'projects.workAreas': 'Project work areas',
'projects.boardDescription':
'See work across the project’s workflow and open tickets in their current status.',
'projects.backlogDescription':
'Explore incoming work, review ticket details, and plan what comes next.',
'projects.boardAction': 'Open board',
'projects.backlogAction': 'Open backlog',
'projects.settingsDescription':
'Manage this project’s configuration, integrations, and access.',
'projects.saveTemplate': 'Save as new template',
'projects.templateTitle': 'Based on {name}',
'projects.templateDescription':
'Inherited areas follow the linked template. Overridden areas use this project’s own configuration.',
'projects.inheritedArea': '{name} · inherited',
'projects.overriddenArea': '{name} · overridden',
'dashboard.eyebrow': 'Personal workspace',
'dashboard.greeting': 'Welcome back, {name}',
'dashboard.description':
'A current view of tickets assigned to you in projects you can access.',
'dashboard.summary': 'Work summary',
'dashboard.summary.assignedTickets': 'Assigned tickets',
'dashboard.summary.projects': 'Projects with assigned work',
'dashboard.summary.blockedTickets': 'Blocked tickets',
'dashboard.assignedWork': 'My assigned tickets',
'dashboard.assignedWorkDescription':
'Your work is grouped by project and ordered with the newest tickets first.',
'dashboard.projectTickets': 'Tickets assigned to you in {name}',
'dashboard.openProject': 'Open {name}',
'dashboard.openProjectAction': 'Open project',
'dashboard.empty.title': 'No tickets assigned to you',
'dashboard.empty.description':
'Tickets assigned to you in projects you can access will appear here.',
'dashboard.empty.action': 'Browse projects',
'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.forgot.title': 'Reset your password',
'auth.forgot.description':
'Enter your account email and we will send you a secure reset link.',
'auth.forgot.email': 'Email address',
'auth.forgot.emailPlaceholder': 'you@example.com',
'auth.forgot.emailHint':
'Use the email address associated with your KanbanGit account.',
'auth.forgot.submit': 'Send reset link',
'auth.forgot.pending': 'Sending reset link…',
'auth.forgot.sentTitle': 'Reset link sent',
'auth.reset.title': 'Choose a new password',
'auth.reset.description':
'Create a new password for the account linked below.',
'auth.reset.email': 'Account email',
'auth.reset.password': 'New password',
'auth.reset.passwordHint':
'Use a password you do not use for another account.',
'auth.reset.passwordConfirmation': 'Confirm new password',
'auth.reset.submit': 'Save new password',
'auth.reset.pending': 'Updating your password…',
'auth.reset.invalidTitle': 'This reset link is no longer valid',
'auth.reset.requestNew': 'Request a new reset link',
'auth.reset.footer':
'For your security, reset links expire and can only be used once.',
'auth.confirm.title': 'Confirm it is you',
'auth.confirm.description':
'This action affects sensitive account information. Enter your password to continue.',
'auth.confirm.password': 'Current password',
'auth.confirm.passwordHint':
'Your password is checked securely and is never saved in a draft.',
'auth.confirm.submit': 'Confirm and continue',
'auth.confirm.pending': 'Confirming your password…',
'auth.confirm.footer':
'Password confirmation expires automatically after a short time.',
'auth.forceChange.title': 'Secure your account',
'auth.forceChange.description':
'You need to replace your temporary password before continuing to KanbanGit.',
'auth.forceChange.currentPassword': 'Temporary password',
'auth.forceChange.password': 'New password',
'auth.forceChange.passwordHint':
'Choose a password that differs from your temporary password.',
'auth.forceChange.passwordConfirmation': 'Confirm new password',
'auth.forceChange.submit': 'Save password and continue',
'auth.forceChange.pending': 'Securing your account…',
'auth.forceChange.statusTitle': 'Password update',
'auth.forceChange.wrongAccount': 'Not the account you expected?',
'auth.forceChange.footer':
'Your password entries are kept in memory only and are cleared when you leave.',
'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.',
'tickets.open': 'Open {key}: {title}',
'tickets.blocked': 'Blocked',
'tickets.blockerComment': 'Blocker: {comment}',
'tickets.typeLabel': 'Type: {name}',
'tickets.priorityLabel': 'Priority: {name}',
'tickets.statusLabel': 'Status: {name}',
'tickets.noStatus': 'No status',
'tickets.assignedTo': 'Assigned to {name}',
'tickets.unassigned': 'Unassigned',
'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;
+197 -33
View File
@@ -1,47 +1,211 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3'; import { Head, Link } from '@inertiajs/vue3';
import PlaceholderPattern from '@/components/PlaceholderPattern.vue'; import {
ArrowUpRight,
FolderGit2,
ListTodo,
OctagonAlert,
} from 'lucide-vue-next';
import { computed } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { TicketCard } from '@/components/tickets';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { dashboard } from '@/routes/index'; import { dashboard } from '@/routes/index';
import type { BreadcrumbItem } from '@/types'; import { index as projectsIndex, show as showProject } from '@/routes/projects';
import type { BreadcrumbItem, DashboardPageProps } from '@/types';
const breadcrumbs: BreadcrumbItem[] = [ const props = defineProps<DashboardPageProps>();
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.dashboard'), href: dashboard() },
]);
const assignedTickets = computed(() =>
props.assignedTicketsByProject.flatMap((group) => group.tickets),
);
const summaryItems = computed(() => [
{ {
title: 'Dashboard', label: t('dashboard.summary.assignedTickets'),
href: dashboard(), value: assignedTickets.value.length,
icon: ListTodo,
}, },
]; {
label: t('dashboard.summary.projects'),
value: props.assignedTicketsByProject.length,
icon: FolderGit2,
},
{
label: t('dashboard.summary.blockedTickets'),
value: assignedTickets.value.filter((ticket) => ticket.is_blocked)
.length,
icon: OctagonAlert,
},
]);
</script> </script>
<template> <template>
<Head title="Dashboard" />
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<div <Head :title="t('navigation.dashboard')" />
class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4"
> <div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
<div class="grid auto-rows-min gap-4 md:grid-cols-3"> <header class="gap-stack grid min-w-0">
<div <p class="text-label text-muted-foreground uppercase">
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border" {{ t('dashboard.eyebrow') }}
> </p>
<PlaceholderPattern /> <h1 class="text-headline-mobile md:text-headline break-words">
</div> {{
<div t('dashboard.greeting', {
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border" name: $page.props.auth.user.name,
> })
<PlaceholderPattern /> }}
</div> </h1>
<div <p class="text-muted-foreground max-w-2xl">
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border" {{ t('dashboard.description') }}
> </p>
<PlaceholderPattern /> </header>
</div>
</div> <section :aria-label="t('dashboard.summary')">
<div <ul class="grid gap-3 sm:grid-cols-3">
class="border-sidebar-border/70 dark:border-sidebar-border relative min-h-[100vh] flex-1 rounded-xl border md:min-h-min" <li
v-for="item in summaryItems"
:key="item.label"
class="min-w-0"
>
<Card class="h-full gap-0 rounded-md py-0">
<CardContent
class="flex min-w-0 items-center justify-between gap-4 p-4"
>
<div class="min-w-0">
<p
class="text-caption text-muted-foreground break-words"
>
{{ item.label }}
</p>
<p class="text-title mt-1 tabular-nums">
{{ item.value }}
</p>
</div>
<span
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-md"
>
<component
:is="item.icon"
class="size-5"
aria-hidden="true"
/>
</span>
</CardContent>
</Card>
</li>
</ul>
</section>
<section
aria-labelledby="assigned-work-title"
class="gap-column grid min-w-0"
> >
<PlaceholderPattern /> <header class="gap-stack grid min-w-0">
</div> <h2 id="assigned-work-title" class="text-title">
{{ t('dashboard.assignedWork') }}
</h2>
<p class="text-muted-foreground max-w-2xl">
{{ t('dashboard.assignedWorkDescription') }}
</p>
</header>
<FeedbackState
v-if="assignedTicketsByProject.length === 0"
state="empty"
:title="t('dashboard.empty.title')"
:description="t('dashboard.empty.description')"
>
<Button as-child variant="outline">
<Link :href="projectsIndex()">
<FolderGit2 aria-hidden="true" />
{{ t('dashboard.empty.action') }}
</Link>
</Button>
</FeedbackState>
<div v-else class="gap-page grid min-w-0">
<section
v-for="group in assignedTicketsByProject"
:key="group.project.id"
:aria-labelledby="`dashboard-project-${group.project.id}`"
class="gap-column grid min-w-0"
>
<header
class="flex min-w-0 flex-col gap-3 border-b pb-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="flex min-w-0 items-center gap-3">
<Badge
variant="outline"
class="max-w-full shrink-0 whitespace-normal break-all"
>
{{ group.project.key }}
</Badge>
<div class="min-w-0">
<h3
:id="`dashboard-project-${group.project.id}`"
class="text-body break-words font-semibold"
>
{{ group.project.name }}
</h3>
<p
class="text-caption text-muted-foreground"
>
{{
t('tickets.count', {
count: group.tickets.length,
})
}}
</p>
</div>
</div>
<Button
as-child
variant="link"
class="self-start sm:self-auto"
>
<Link
:href="showProject(group.project.id)"
:aria-label="
t('dashboard.openProject', {
name: group.project.name,
})
"
>
{{ t('dashboard.openProjectAction') }}
<ArrowUpRight aria-hidden="true" />
</Link>
</Button>
</header>
<ul
:aria-label="
t('dashboard.projectTickets', {
name: group.project.name,
})
"
class="gap-column grid min-w-0 md:grid-cols-2 2xl:grid-cols-3"
>
<li
v-for="ticket in group.tickets"
:key="ticket.id"
class="min-w-0"
>
<TicketCard
:project-id="group.project.id"
:ticket="ticket"
/>
</li>
</ul>
</section>
</div>
</section>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
+64 -39
View File
@@ -1,52 +1,77 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import { ShieldCheck } from 'lucide-vue-next';
import { onUnmounted } from 'vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.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 { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner'; import { useFormProtection } from '@/composables/useFormProtection';
import AuthLayout from '@/layouts/AuthLayout.vue'; import { useLocalization } from '@/composables/useLocalization';
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { store } from '@/routes/password/confirm'; import { store } from '@/routes/password/confirm';
const { t } = useLocalization();
const form = useForm({ password: '' }).dontRemember('password');
const protection = useFormProtection(form, { isDirty: () => false });
const submit = (): void =>
protection.submit(store(), { onSuccess: () => form.reset() });
onUnmounted(() => form.reset());
</script> </script>
<template> <template>
<AuthLayout <AuthFlowLayout
title="Confirm your password" :title="t('auth.confirm.title')"
description="This is a secure area of the application. Please confirm your password before continuing." :description="t('auth.confirm.description')"
:icon="ShieldCheck"
> >
<Head title="Confirm password" /> <Head :title="t('auth.confirm.title')" />
<form
<Form :action="store.url()"
v-bind="store.form()" method="post"
reset-on-success class="grid gap-5"
v-slot="{ errors, processing }" @submit.prevent="submit"
> >
<div class="space-y-6"> <AuthFormFeedback
<div class="grid gap-2"> :state="protection.state.value"
<Label htmlFor="password">Password</Label> :response-status="protection.responseStatus.value"
<PasswordInput :loading-title="t('auth.confirm.pending')"
id="password" @retry="submit"
name="password" />
class="mt-1 block w-full" <div class="gap-stack grid">
required <Label for="password">{{ t('auth.confirm.password') }}</Label>
autocomplete="current-password" <PasswordInput
autofocus id="password"
/> v-model="form.password"
name="password"
<InputError :message="errors.password" /> required
</div> autocomplete="current-password"
autofocus
<div class="flex items-center"> :disabled="form.processing"
<Button :invalid="Boolean(form.errors.password)"
class="w-full" aria-describedby="password-hint password-error"
:disabled="processing" />
data-test="confirm-password-button" <p
> id="password-hint"
<Spinner v-if="processing" /> class="text-caption text-muted-foreground"
Confirm password >
</Button> {{ t('auth.confirm.passwordHint') }}
</div> </p>
<InputError
id="password-error"
:message="form.errors.password"
/>
</div> </div>
</Form> <Button
</AuthLayout> type="submit"
class="w-full"
:loading="form.processing"
:loading-label="t('auth.confirm.pending')"
data-test="confirm-password-button"
>{{ t('auth.confirm.submit') }}</Button
>
</form>
<template #footer>{{ t('auth.confirm.footer') }}</template>
</AuthFlowLayout>
</template> </template>
+155 -100
View File
@@ -1,115 +1,170 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import { LogOut } from 'lucide-vue-next'; import { KeyRound } from 'lucide-vue-next';
import { computed, onUnmounted } from 'vue';
import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController'; import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController';
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 { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
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'; import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { logout } from '@/routes/index';
withDefaults( withDefaults(defineProps<{ status?: string | null }>(), { status: null });
defineProps<{ const { t } = useLocalization();
status?: string | null; const form = useForm({
}>(), current_password: '',
{ password: '',
status: null, password_confirmation: '',
}, }).dontRemember('current_password', 'password', 'password_confirmation');
); const logoutForm = useForm({});
const protection = useFormProtection(form, { isDirty: () => false });
const logoutProtection = useFormProtection(logoutForm);
const busy = computed(() => form.processing || logoutForm.processing);
const submit = (): void =>
protection.submit(SecurityController.update(), {
onSuccess: () => form.reset(),
});
const signOut = (): void => {
if (busy.value) {
return;
}
form.reset();
logoutProtection.submit(logout());
};
onUnmounted(() => form.reset());
</script> </script>
<template> <template>
<AuthLayout <AuthFlowLayout
title="Change your password" :title="t('auth.forceChange.title')"
description="You must update your password before you can continue using the application." :description="t('auth.forceChange.description')"
:icon="KeyRound"
> >
<Head title="Change your password" /> <Head :title="t('auth.forceChange.title')" />
<FeedbackState
<div class="mb-6 flex justify-end">
<Button as-child variant="ghost" size="sm">
<Link :href="logout()" as="button">
<LogOut class="size-4" />
Log out
</Link>
</Button>
</div>
<div
v-if="status" v-if="status"
class="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900" state="success"
:title="t('auth.forceChange.statusTitle')"
:description="status"
/>
<form
:action="SecurityController.update.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.forceChange.pending')"
v-bind="SecurityController.update.form()" @retry="submit"
reset-on-success />
:reset-on-error="[ <div class="gap-stack grid">
'password', <Label for="current_password">{{
'password_confirmation', t('auth.forceChange.currentPassword')
'current_password', }}</Label>
]" <PasswordInput
v-slot="{ errors, processing }" id="current_password"
> v-model="form.current_password"
<div class="space-y-6"> name="current_password"
<div class="grid gap-2"> required
<Label htmlFor="current_password">Current password</Label> autocomplete="current-password"
<PasswordInput autofocus
id="current_password" :disabled="busy"
name="current_password" :invalid="Boolean(form.errors.current_password)"
class="mt-1 block w-full" aria-describedby="current-password-error"
required />
autocomplete="current-password" <InputError
autofocus id="current-password-error"
placeholder="Current password" :message="form.errors.current_password"
/> />
<InputError :message="errors.current_password" />
</div>
<div class="grid gap-2">
<Label htmlFor="password">New password</Label>
<PasswordInput
id="password"
name="password"
class="mt-1 block w-full"
required
autocomplete="new-password"
placeholder="New password"
/>
<InputError :message="errors.password" />
</div>
<div class="grid gap-2">
<Label htmlFor="password_confirmation">
Confirm new password
</Label>
<PasswordInput
id="password_confirmation"
name="password_confirmation"
class="mt-1 block w-full"
required
autocomplete="new-password"
placeholder="Confirm new password"
/>
<InputError :message="errors.password_confirmation" />
</div>
<div class="flex items-center">
<Button
class="w-full"
:disabled="processing"
data-test="force-password-change-button"
>
<Spinner v-if="processing" />
Save new password
</Button>
</div>
</div> </div>
</Form> <div class="gap-stack grid">
</AuthLayout> <Label for="password">{{
t('auth.forceChange.password')
}}</Label>
<PasswordInput
id="password"
v-model="form.password"
name="password"
required
autocomplete="new-password"
:disabled="busy"
:invalid="Boolean(form.errors.password)"
aria-describedby="new-password-hint new-password-error"
/>
<p
id="new-password-hint"
class="text-caption text-muted-foreground"
>
{{ t('auth.forceChange.passwordHint') }}
</p>
<InputError
id="new-password-error"
:message="form.errors.password"
/>
</div>
<div class="gap-stack grid">
<Label for="password_confirmation">{{
t('auth.forceChange.passwordConfirmation')
}}</Label>
<PasswordInput
id="password_confirmation"
v-model="form.password_confirmation"
name="password_confirmation"
required
autocomplete="new-password"
:disabled="busy"
:invalid="Boolean(form.errors.password_confirmation)"
aria-describedby="new-password-confirmation-error"
/>
<InputError
id="new-password-confirmation-error"
:message="form.errors.password_confirmation"
/>
</div>
<Button
type="submit"
class="w-full"
:disabled="logoutForm.processing"
:loading="form.processing"
:loading-label="t('auth.forceChange.pending')"
data-test="force-password-change-button"
>{{ t('auth.forceChange.submit') }}</Button
>
</form>
<div class="gap-stack pt-column grid border-t">
<p class="text-body text-muted-foreground">
{{ t('auth.forceChange.wrongAccount') }}
</p>
<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="outline"
class="w-full"
:disabled="form.processing"
:loading="logoutForm.processing"
:loading-label="t('auth.signingOut')"
>{{ t('auth.signOut') }}</Button
>
</form>
</div>
<template #footer>{{ t('auth.forceChange.footer') }}</template>
</AuthFlowLayout>
</template> </template>
+72 -48
View File
@@ -1,65 +1,89 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import { Mail } 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 TextLink from '@/components/TextLink.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 { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner'; import { useFormProtection } from '@/composables/useFormProtection';
import AuthLayout from '@/layouts/AuthLayout.vue'; import { useLocalization } from '@/composables/useLocalization';
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { login } from '@/routes/index'; import { login } from '@/routes/index';
import { email } from '@/routes/password'; import { email } from '@/routes/password';
defineProps<{ defineProps<{ status?: string }>();
status?: string; const { t } = useLocalization();
}>(); const form = useForm({ email: '' }).dontRemember('email');
const protection = useFormProtection(form, { isDirty: () => false });
const submit = (): void => protection.submit(email());
onUnmounted(() => form.reset());
</script> </script>
<template> <template>
<AuthLayout <AuthFlowLayout
title="Forgot password" :title="t('auth.forgot.title')"
description="Enter your email to receive a password reset link" :description="t('auth.forgot.description')"
:icon="Mail"
> >
<Head title="Forgot password" /> <Head :title="t('auth.forgot.title')" />
<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.forgot.sentTitle')"
:description="status"
/>
<form
:action="email.url()"
method="post"
class="grid gap-5"
@submit.prevent="submit"
> >
{{ status }} <AuthFormFeedback
</div> :state="protection.state.value"
:response-status="protection.responseStatus.value"
<div class="space-y-6"> :loading-title="t('auth.forgot.pending')"
<Form v-bind="email.form()" v-slot="{ errors, processing }"> @retry="submit"
<div class="grid gap-2"> />
<Label for="email">Email address</Label> <div class="gap-stack grid">
<Input <Label for="email">{{ t('auth.forgot.email') }}</Label>
id="email" <Input
type="email" id="email"
name="email" v-model="form.email"
autocomplete="off" name="email"
autofocus type="email"
placeholder="email@example.com" inputmode="email"
/> autocomplete="email"
<InputError :message="errors.email" /> autocapitalize="none"
</div> :spellcheck="false"
required
<div class="my-6 flex items-center justify-start"> autofocus
<Button :disabled="form.processing"
class="w-full" :placeholder="t('auth.forgot.emailPlaceholder')"
:disabled="processing" :invalid="Boolean(form.errors.email)"
data-test="email-password-reset-link-button" aria-describedby="email-hint email-error"
> />
<Spinner v-if="processing" /> <p id="email-hint" class="text-caption text-muted-foreground">
Email password reset link {{ t('auth.forgot.emailHint') }}
</Button> </p>
</div> <InputError id="email-error" :message="form.errors.email" />
</Form>
<div class="text-muted-foreground space-x-1 text-center text-sm">
<span>Or, return to</span>
<TextLink :href="login()">log in</TextLink>
</div> </div>
</div> <Button
</AuthLayout> type="submit"
class="w-full"
:loading="form.processing"
:loading-label="t('auth.forgot.pending')"
data-test="email-password-reset-link-button"
>{{ t('auth.forgot.submit') }}</Button
>
</form>
<template #footer
><TextLink :href="login()">{{
t('auth.backToLogin')
}}</TextLink></template
>
</AuthFlowLayout>
</template> </template>
+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>
+122 -74
View File
@@ -1,88 +1,136 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3'; import { Head, Link, useForm } from '@inertiajs/vue3';
import { ref } from 'vue'; import { KeyRound } from 'lucide-vue-next';
import { computed, 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 { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
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 AuthLayout from '@/layouts/AuthLayout.vue'; import { useLocalization } from '@/composables/useLocalization';
import { update } from '@/routes/password'; import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { request, update } from '@/routes/password';
const props = defineProps<{ const props = defineProps<{ token: string; email: string }>();
token: string; const { t } = useLocalization();
email: string; const form = useForm({
}>(); token: props.token,
email: props.email,
const inputEmail = ref(props.email); password: '',
password_confirmation: '',
}).dontRemember('token', 'email', 'password', 'password_confirmation');
const protection = useFormProtection(form, { isDirty: () => false });
const invalidLink = computed(() => Boolean(form.errors.email));
const feedbackState = computed(() =>
invalidLink.value ? 'idle' : protection.state.value,
);
const submit = (): void =>
protection.submit(update(), {
onSuccess: () => form.reset('password', 'password_confirmation'),
});
onUnmounted(() => form.reset());
</script> </script>
<template> <template>
<AuthLayout <AuthFlowLayout
title="Reset password" :title="t('auth.reset.title')"
description="Please enter your new password below" :description="t('auth.reset.description')"
:icon="KeyRound"
> >
<Head title="Reset password" /> <Head :title="t('auth.reset.title')" />
<FeedbackState
<Form v-if="invalidLink"
v-bind="update.form()" state="error"
:transform="(data) => ({ ...data, token, email })" :title="t('auth.reset.invalidTitle')"
:reset-on-success="['password', 'password_confirmation']" :description="form.errors.email"
v-slot="{ errors, processing }"
> >
<div class="grid gap-6"> <Button as-child variant="outline" class="w-full">
<div class="grid gap-2"> <Link :href="request()">{{ t('auth.reset.requestNew') }}</Link>
<Label for="email">Email</Label> </Button>
<Input </FeedbackState>
id="email" <form
type="email" :action="update.url()"
name="email" method="post"
autocomplete="email" class="grid gap-5"
v-model="inputEmail" @submit.prevent="submit"
class="mt-1 block w-full" >
readonly <AuthFormFeedback
/> :state="feedbackState"
<InputError :message="errors.email" class="mt-2" /> :response-status="protection.responseStatus.value"
</div> :loading-title="t('auth.reset.pending')"
@retry="submit"
<div class="grid gap-2"> />
<Label for="password">Password</Label> <input type="hidden" name="token" :value="form.token" />
<PasswordInput <div class="gap-stack grid">
id="password" <Label for="email">{{ t('auth.reset.email') }}</Label>
name="password" <Input
autocomplete="new-password" id="email"
class="mt-1 block w-full" v-model="form.email"
autofocus name="email"
placeholder="Password" type="email"
/> autocomplete="username"
<InputError :message="errors.password" /> readonly
</div> :disabled="form.processing"
:invalid="Boolean(form.errors.email)"
<div class="grid gap-2"> aria-describedby="email-error"
<Label for="password_confirmation"> />
Confirm password <InputError id="email-error" :message="form.errors.email" />
</Label>
<PasswordInput
id="password_confirmation"
name="password_confirmation"
autocomplete="new-password"
class="mt-1 block w-full"
placeholder="Confirm password"
/>
<InputError :message="errors.password_confirmation" />
</div>
<Button
type="submit"
class="mt-4 w-full"
:disabled="processing"
data-test="reset-password-button"
>
<Spinner v-if="processing" />
Reset password
</Button>
</div> </div>
</Form> <div class="gap-stack grid">
</AuthLayout> <Label for="password">{{ t('auth.reset.password') }}</Label>
<PasswordInput
id="password"
v-model="form.password"
name="password"
autocomplete="new-password"
required
autofocus
:disabled="form.processing"
:invalid="Boolean(form.errors.password)"
aria-describedby="password-hint password-error"
/>
<p
id="password-hint"
class="text-caption text-muted-foreground"
>
{{ t('auth.reset.passwordHint') }}
</p>
<InputError
id="password-error"
:message="form.errors.password"
/>
</div>
<div class="gap-stack grid">
<Label for="password_confirmation">{{
t('auth.reset.passwordConfirmation')
}}</Label>
<PasswordInput
id="password_confirmation"
v-model="form.password_confirmation"
name="password_confirmation"
autocomplete="new-password"
required
:disabled="form.processing"
:invalid="Boolean(form.errors.password_confirmation)"
aria-describedby="password-confirmation-error"
/>
<InputError
id="password-confirmation-error"
:message="form.errors.password_confirmation"
/>
</div>
<Button
type="submit"
class="w-full"
:loading="form.processing"
:loading-label="t('auth.reset.pending')"
data-test="reset-password-button"
>{{ t('auth.reset.submit') }}</Button
>
</form>
<template #footer>{{ t('auth.reset.footer') }}</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>
+84 -169
View File
@@ -2,6 +2,7 @@
import { Form, Head, Link } from '@inertiajs/vue3'; import { Form, Head, Link } from '@inertiajs/vue3';
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController'; import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
import Heading from '@/components/Heading.vue'; import Heading from '@/components/Heading.vue';
import { TicketListItem } from '@/components/tickets';
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 {
@@ -12,13 +13,11 @@ import {
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { ticketStatusLabel } from '@/lib/tickets';
import { index as boardIndex } from '@/routes/projects/board'; import { index as boardIndex } from '@/routes/projects/board';
import { index as backlogIndex } from '@/routes/projects/backlog'; import { index as backlogIndex } from '@/routes/projects/backlog';
import { index, show } from '@/routes/projects/index'; import { index, show } from '@/routes/projects/index';
import { import { create as createTicket } from '@/routes/projects/tickets';
create as createTicket,
show as showTicket,
} from '@/routes/projects/tickets';
import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types'; import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types';
import type { TicketModel } from '@/types/models/ticket-model'; import type { TicketModel } from '@/types/models/ticket-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model'; import type { TicketStatusModel } from '@/types/models/ticket-status-model';
@@ -40,25 +39,6 @@ const breadcrumbs: BreadcrumbItem[] = [
}, },
]; ];
const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => {
if (ticketStatus === null) {
return 'Unknown status';
}
if (
ticketStatus.board_column === null ||
ticketStatus.board_column === undefined
) {
return ticketStatus.name;
}
if (ticketStatus.board_column.name === ticketStatus.name) {
return ticketStatus.name;
}
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
};
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => { const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter( return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id, (ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
@@ -132,163 +112,98 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
No backlog tickets exist yet. No backlog tickets exist yet.
</div> </div>
<div v-else class="overflow-x-auto"> <ul v-else class="gap-column grid p-4">
<table <TicketListItem
class="divide-border min-w-full divide-y text-sm" v-for="ticket in tickets"
:key="ticket.id"
:project-id="project.id"
:ticket="ticket"
> >
<thead <template v-if="canEditTickets" #actions>
class="bg-muted/40 text-muted-foreground text-left" <Form
> v-bind="
<tr> ProjectBoardController.move.form({
<th class="px-4 py-3 font-medium">Key</th> project: project.id,
<th class="px-4 py-3 font-medium">Title</th> ticket: ticket.id,
<th class="px-4 py-3 font-medium">Type</th> })
<th class="px-4 py-3 font-medium"> "
Status class="flex w-full flex-col gap-2"
</th> v-slot="{ processing }"
<th class="px-4 py-3 font-medium"> >
Priority <input
</th> type="hidden"
<th name="redirect_to"
v-if="canEditTickets" value="backlog"
class="px-4 py-3 font-medium" />
<label
:for="`backlog-ticket-status-${ticket.id}`"
class="text-label text-muted-foreground uppercase"
> >
Move to status Move to status
</th> </label>
</tr> <div
</thead> class="flex flex-col gap-2 sm:flex-row"
<tbody class="divide-border divide-y"> >
<tr <select
v-for="ticket in tickets" :id="`backlog-ticket-status-${ticket.id}`"
:key="ticket.id" name="ticket_status_id"
class="bg-background" :disabled="
> allowedTargetStatuses(ticket)
<td class="px-4 py-4"> .length === 0 || processing
<Link
:href="
showTicket({
project: project.id,
ticket: ticket.id,
})
" "
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-h-control min-w-0 flex-1 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
> >
<Badge variant="outline"> <option
{{ ticket.ticket_key }}
</Badge>
</Link>
</td>
<td class="px-4 py-4">
<Link
:href="
showTicket({
project: project.id,
ticket: ticket.id,
})
"
class="text-foreground hover:text-primary font-medium transition"
>
{{ ticket.title }}
</Link>
<div
v-if="ticket.description"
class="text-muted-foreground mt-1 line-clamp-2"
>
{{ ticket.description }}
</div>
</td>
<td class="text-muted-foreground px-4 py-4">
{{ ticket.ticket_type.name }}
</td>
<td class="text-muted-foreground px-4 py-4">
{{
ticketStatusLabel(
ticket.ticket_status,
)
}}
</td>
<td class="text-muted-foreground px-4 py-4">
{{ ticket.ticket_priority.name }}
</td>
<td v-if="canEditTickets" class="px-4 py-4">
<Form
v-bind="
ProjectBoardController.move.form(
{
project: project.id,
ticket: ticket.id,
},
)
"
class="flex flex-col gap-2 sm:flex-row"
v-slot="{ processing }"
>
<input
type="hidden"
name="redirect_to"
value="backlog"
/>
<select
name="ticket_status_id"
:disabled="
allowedTargetStatuses(
ticket,
).length === 0 || processing
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-w-44 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-if="
allowedTargetStatuses(
ticket,
).length === 0
"
value=""
>
No allowed moves
</option>
<option
v-for="ticketStatus in allowedTargetStatuses(
ticket,
)"
:key="ticketStatus.id"
:value="ticketStatus.id"
>
{{
ticketStatusLabel(
ticketStatus,
)
}}
</option>
</select>
<Button
type="submit"
size="sm"
:disabled="
processing ||
allowedTargetStatuses(
ticket,
).length === 0
"
>
Move
</Button>
<p
v-if=" v-if="
allowedTargetStatuses( allowedTargetStatuses(
ticket, ticket,
).length === 0 ).length === 0
" "
class="text-muted-foreground text-xs sm:basis-full" value=""
> >
This status does not allow No allowed moves
moving anywhere else. </option>
</p> <option
</Form> v-for="ticketStatus in allowedTargetStatuses(
</td> ticket,
</tr> )"
</tbody> :key="ticketStatus.id"
</table> :value="ticketStatus.id"
</div> >
{{
ticketStatusLabel(
ticketStatus,
'Unknown status',
)
}}
</option>
</select>
<Button
type="submit"
size="sm"
:disabled="
processing ||
allowedTargetStatuses(ticket)
.length === 0
"
>
Move
</Button>
</div>
<p
v-if="
allowedTargetStatuses(ticket)
.length === 0
"
class="text-caption text-muted-foreground"
>
This status does not allow moving
anywhere else.
</p>
</Form>
</template>
</TicketListItem>
</ul>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+10 -88
View File
@@ -2,6 +2,7 @@
import { Form, Head, Link } from '@inertiajs/vue3'; import { Form, Head, Link } from '@inertiajs/vue3';
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController'; import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
import Heading from '@/components/Heading.vue'; import Heading from '@/components/Heading.vue';
import { TicketCard } from '@/components/tickets';
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 {
@@ -12,14 +13,12 @@ import {
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { ticketStatusLabel } from '@/lib/tickets';
import { index as backlogIndex } from '@/routes/projects/backlog'; import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as projectIndex, show } from '@/routes/projects/index'; import { index as projectIndex, show } from '@/routes/projects/index';
import { board as boardSettings } from '@/routes/projects/settings'; import { board as boardSettings } from '@/routes/projects/settings';
import { index as boardIndex } from '@/routes/projects/board'; import { index as boardIndex } from '@/routes/projects/board';
import { import { create as createTicket } from '@/routes/projects/tickets';
create as createTicket,
show as showTicket,
} from '@/routes/projects/tickets';
import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types'; import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
import type { TicketModel } from '@/types/models/ticket-model'; import type { TicketModel } from '@/types/models/ticket-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model'; import type { TicketStatusModel } from '@/types/models/ticket-status-model';
@@ -41,25 +40,6 @@ const breadcrumbs: BreadcrumbItem[] = [
}, },
]; ];
const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => {
if (ticketStatus === null) {
return 'Unknown status';
}
if (
ticketStatus.board_column === null ||
ticketStatus.board_column === undefined
) {
return ticketStatus.name;
}
if (ticketStatus.board_column.name === ticketStatus.name) {
return ticketStatus.name;
}
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
};
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => { const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter( return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id, (ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
@@ -180,72 +160,13 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
</div> </div>
<div v-else class="flex flex-col gap-4"> <div v-else class="flex flex-col gap-4">
<Card <TicketCard
v-for="ticket in boardColumn.tickets" v-for="ticket in boardColumn.tickets"
:key="ticket.id" :key="ticket.id"
:class="[ :project-id="project.id"
'gap-0', :ticket="ticket"
ticket.is_blocked
? 'border-amber-300 bg-amber-50/70 dark:border-amber-700/60 dark:bg-amber-950/30'
: '',
]"
> >
<CardHeader class="pb-4"> <template v-if="canEditTickets" #actions>
<div class="flex flex-wrap gap-2">
<Badge variant="outline">
{{ ticket.ticket_key }}
</Badge>
<Badge
v-if="ticket.is_blocked"
variant="destructive"
>
Blocked
</Badge>
<Badge variant="secondary">
{{ ticket.ticket_type.name }}
</Badge>
<Badge variant="secondary">
{{ ticket.ticket_priority.name }}
</Badge>
<Badge variant="outline">
{{
ticketStatusLabel(
ticket.ticket_status,
)
}}
</Badge>
</div>
<CardTitle class="text-base">
<Link
:href="
showTicket({
project: project.id,
ticket: ticket.id,
})
"
class="hover:text-primary transition"
>
{{ ticket.title }}
</Link>
</CardTitle>
<CardDescription
v-if="ticket.description"
class="line-clamp-3"
>
{{ ticket.description }}
</CardDescription>
<CardDescription
v-if="
ticket.is_blocked &&
ticket.blocked_comment
"
class="mt-2 text-amber-900 dark:text-amber-200"
>
{{ ticket.blocked_comment }}
</CardDescription>
</CardHeader>
<CardContent v-if="canEditTickets" class="pt-0">
<Form <Form
v-bind=" v-bind="
ProjectBoardController.move.form({ ProjectBoardController.move.form({
@@ -300,6 +221,7 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
{{ {{
ticketStatusLabel( ticketStatusLabel(
ticketStatus, ticketStatus,
'Unknown status',
) )
}} }}
</option> </option>
@@ -328,8 +250,8 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
anywhere else. anywhere else.
</p> </p>
</Form> </Form>
</CardContent> </template>
</Card> </TicketCard>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+566 -282
View File
@@ -1,349 +1,633 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3'; import { Head, Link, useForm } from '@inertiajs/vue3';
import { computed } from 'vue';
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { import {
Card, ArrowLeft,
CardContent, Check,
CardDescription, Copy,
CardHeader, FolderPlus,
CardTitle, LayoutTemplate,
} from '@/components/ui/card'; } from 'lucide-vue-next';
import { computed, watch } from 'vue';
import { store } from '@/actions/App/Http/Controllers/ProjectController';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
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 { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { create, index } from '@/routes/projects/index'; import { create, index } from '@/routes/projects';
import type { BreadcrumbItem, ProjectsCreatePageProps } from '@/types'; import type { BreadcrumbItem, ProjectsCreatePageProps } from '@/types';
const props = defineProps<ProjectsCreatePageProps>(); const props = defineProps<ProjectsCreatePageProps>();
const { t } = useLocalization();
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ { title: t('navigation.projects'), href: index() },
title: 'Projects', { title: t('projects.create'), href: create() },
href: index(), ]);
},
{
title: 'New project',
href: create(),
},
];
const defaultTemplateId = const defaultTemplateId =
props.selectedSourceTemplateId ?? props.sourceTemplates[0]?.id ?? null; props.selectedSourceTemplateId ?? props.sourceTemplates[0]?.id ?? null;
const form = useForm(
const form = useForm({ `ProjectsCreate:${props.selectedSourceTemplateId ?? 'default'}`,
copy_areas: [] as string[], {
creation_mode: defaultTemplateId !== null ? 'template' : 'scratch', creation_mode: defaultTemplateId !== null ? 'template' : 'scratch',
key: '', name: '',
name: '', key: '',
source_project_id: props.sourceProjects[0]?.id source_project_id: props.sourceProjects[0]?.id
? String(props.sourceProjects[0].id) ? String(props.sourceProjects[0].id)
: '', : '',
source_template_id: source_template_id:
defaultTemplateId !== null ? String(defaultTemplateId) : '', defaultTemplateId !== null ? String(defaultTemplateId) : '',
}); copy_areas: [] as string[],
},
const hasSourceProjects = computed(() => props.sourceProjects.length > 0); );
const hasSourceTemplates = computed(() => props.sourceTemplates.length > 0); const protection = useFormProtection(form);
const modes = computed(() => [
const copyAreasError = computed(() => { {
return ( value: 'scratch',
icon: FolderPlus,
title: t('projectCreation.scratch'),
description: t('projectCreation.scratchDescription'),
disabled: false,
},
{
value: 'duplicate',
icon: Copy,
title: t('projectCreation.duplicate'),
description: t(
props.sourceProjects.length
? 'projectCreation.duplicateDescription'
: 'projectCreation.noProjects',
),
disabled: props.sourceProjects.length === 0,
},
{
value: 'template',
icon: LayoutTemplate,
title: t('projectCreation.template'),
description: t(
props.sourceTemplates.length
? 'projectCreation.templateDescription'
: 'projectCreation.noTemplates',
),
disabled: props.sourceTemplates.length === 0,
},
]);
const areaKeys = {
project_settings: 'projectCreation.areas.projectSettings',
board_columns: 'projectCreation.areas.boardColumns',
ticket_statuses: 'projectCreation.areas.ticketStatuses',
status_transitions: 'projectCreation.areas.statusTransitions',
ticket_types: 'projectCreation.areas.ticketTypes',
ticket_priorities: 'projectCreation.areas.ticketPriorities',
ticket_fields: 'projectCreation.areas.ticketFields',
ticket_link_types: 'projectCreation.areas.ticketLinkTypes',
repository_connections: 'projectCreation.areas.repositories',
notification_actions: 'projectCreation.areas.notifications',
} as const;
function areaText(value: string, part: 'label' | 'description'): string {
const key = areaKeys[value as keyof typeof areaKeys];
return key
? t(`${key}.${part}`)
: (props.copyAreaOptions.find((area) => area.value === value)?.[part] ??
value);
}
const selectedProject = computed(() =>
props.sourceProjects.find(
(project) => String(project.id) === form.source_project_id,
),
);
const selectedTemplate = computed(() =>
props.sourceTemplates.find(
(template) => String(template.id) === form.source_template_id,
),
);
const selectedMode = computed(() =>
modes.value.find((mode) => mode.value === form.creation_mode),
);
const ticketKey = computed(
() => `${form.key.trim().toUpperCase() || 'CORE'}-1`,
);
const copyAreasError = computed(
() =>
form.errors.copy_areas ?? form.errors.copy_areas ??
Object.entries(form.errors).find(([key]) => Object.entries(form.errors).find(([key]) =>
key.startsWith('copy_areas.'), key.startsWith('copy_areas.'),
)?.[1] ?? )?.[1],
'' );
); const selectClass =
}); 'border-input bg-card min-h-control w-full min-w-0 rounded-md border px-3 py-2 text-base shadow-xs outline-none hover:border-primary/45 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 disabled:opacity-60 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25 md:text-body';
const submit = (): void => { watch(
if (form.creation_mode !== 'duplicate') { () => form.creation_mode,
form.source_project_id = ''; () => {
form.copy_areas = []; form.clearErrors(
} 'creation_mode',
'source_project_id',
'source_template_id',
'copy_areas',
);
for (const key of Object.keys(form.errors)) {
if (key.startsWith('copy_areas.')) {
form.clearErrors(key as 'copy_areas');
}
}
protection.clearFeedback();
},
);
if (form.creation_mode !== 'template') { function submit(): void {
form.source_template_id = ''; form.transform((data) => ({
} ...data,
source_project_id:
form.post(ProjectController.store.url(), { data.creation_mode === 'duplicate' ? data.source_project_id : '',
preserveScroll: true, source_template_id:
}); data.creation_mode === 'template' ? data.source_template_id : '',
}; copy_areas: data.creation_mode === 'duplicate' ? data.copy_areas : [],
}));
protection.submit(store(), { preserveScroll: true });
}
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head title="New project" /> <Head :title="t('projects.create')" />
<div
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-6xl flex-1 flex-col p-4"
>
<header class="gap-stack grid">
<Link
:href="index()"
class="text-caption text-muted-foreground hover:text-primary mb-2 inline-flex min-h-10 items-center gap-2 justify-self-start"
>
<ArrowLeft class="size-4" aria-hidden="true" />{{
t('projects.back')
}}
</Link>
<p class="text-label text-muted-foreground uppercase">
{{ t('projects.workspace') }}
</p>
<h1 class="text-headline-mobile md:text-headline">
{{ t('projectCreation.title') }}
</h1>
<p class="text-muted-foreground max-w-2xl">
{{ t('projectCreation.description') }}
</p>
</header>
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4"> <form
<Heading class="gap-page grid min-w-0"
title="Create project" :aria-busy="form.processing"
description="Create a fresh project, duplicate selected settings from an existing project, or start from a saved template." @submit.prevent="submit"
/> >
<FormFeedback
<Card> :state="protection.state.value"
<CardHeader> :title="
<CardTitle>Project details</CardTitle> form.processing
<CardDescription> ? t('projectCreation.creating')
The key is uppercased automatically, must be unique, and : undefined
can contain up to 5 characters. "
</CardDescription> @retry="submit"
</CardHeader> />
<fieldset
<CardContent> class="gap-column grid min-w-0"
<form class="space-y-6" @submit.prevent="submit"> :disabled="form.processing"
<div class="grid gap-3"> aria-describedby="creation-mode-error"
<Label>How do you want to start?</Label> >
<div class="grid gap-3 sm:grid-cols-3"> <legend class="text-title mb-3">
<Label {{ t('projectCreation.start') }}
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4" </legend>
> <div class="gap-column grid md:grid-cols-3">
<input <label
v-model="form.creation_mode" v-for="mode in modes"
type="radio" :key="mode.value"
value="scratch" class="relative min-w-0"
class="mt-1"
/>
<div class="space-y-1">
<div class="font-medium">
Create from scratch
</div>
<p
class="text-muted-foreground text-sm"
>
Start with the default project
setup.
</p>
</div>
</Label>
<Label
class="flex items-start gap-3 rounded-xl border p-4"
:class="
hasSourceProjects
? 'cursor-pointer'
: 'cursor-not-allowed opacity-60'
"
>
<input
v-model="form.creation_mode"
type="radio"
value="duplicate"
class="mt-1"
:disabled="!hasSourceProjects"
/>
<div class="space-y-1">
<div class="font-medium">
Create from existing project
</div>
<p
class="text-muted-foreground text-sm"
>
Copy only the settings you
explicitly select. Tickets are never
duplicated.
</p>
</div>
</Label>
<Label
class="flex items-start gap-3 rounded-xl border p-4"
:class="
hasSourceTemplates
? 'cursor-pointer'
: 'cursor-not-allowed opacity-60'
"
>
<input
v-model="form.creation_mode"
type="radio"
value="template"
class="mt-1"
:disabled="!hasSourceTemplates"
/>
<div class="space-y-1">
<div class="font-medium">
Create from template
</div>
<p
class="text-muted-foreground text-sm"
>
Reuse a saved configuration
template.
</p>
</div>
</Label>
</div>
</div>
<div class="grid gap-2">
<Label for="name">Project name</Label>
<Input
id="name"
v-model="form.name"
required
autofocus
placeholder="Kanban foundation"
/>
<InputError :message="form.errors.name" />
</div>
<div class="grid gap-2">
<Label for="key">Project key</Label>
<Input
id="key"
v-model="form.key"
required
maxlength="5"
placeholder="CORE"
class="uppercase"
/>
<p class="text-muted-foreground text-sm">
Example ticket keys will look like
<span class="text-foreground font-medium"
>CORE-1</span
>.
</p>
<InputError :message="form.errors.key" />
</div>
<div
v-if="form.creation_mode === 'duplicate'"
class="space-y-6 rounded-xl border border-dashed p-4"
> >
<div class="grid gap-2"> <input
<Label for="source_project_id" v-model="form.creation_mode"
>Source project</Label type="radio"
name="creation_mode"
:value="mode.value"
:disabled="mode.disabled"
:aria-invalid="!!form.errors.creation_mode"
:aria-labelledby="`mode-${mode.value}-title`"
:aria-describedby="`mode-${mode.value}-description`"
class="peer sr-only"
/>
<span
class="bg-card peer-checked:border-primary peer-checked:bg-accent/50 peer-focus-visible:ring-ring/40 gap-stack flex h-full cursor-pointer flex-col rounded-md border p-4 transition-colors peer-focus-visible:ring-2 peer-disabled:cursor-not-allowed peer-disabled:opacity-60"
:class="
!mode.disabled
? 'hover:border-primary/50'
: ''
"
>
<span
class="flex items-center justify-between gap-3"
> >
<component
:is="mode.icon"
class="text-primary size-5"
aria-hidden="true"
/>
<Check
v-if="form.creation_mode === mode.value"
class="text-primary size-4"
aria-hidden="true"
/>
</span>
<span
:id="`mode-${mode.value}-title`"
class="font-semibold"
>{{ mode.title }}</span
>
<span
:id="`mode-${mode.value}-description`"
class="text-caption text-muted-foreground"
>{{ mode.description }}</span
>
</span>
</label>
</div>
<InputError
id="creation-mode-error"
:message="form.errors.creation_mode"
/>
</fieldset>
<div
class="gap-page grid min-w-0 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)] lg:items-start"
>
<div class="gap-page grid min-w-0">
<section
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
aria-labelledby="project-details-title"
>
<h2 id="project-details-title" class="text-title">
{{ t('projectCreation.details') }}
</h2>
<div class="gap-stack grid">
<Label for="name">{{
t('projectCreation.name')
}}</Label>
<Input
id="name"
v-model="form.name"
name="name"
required
maxlength="255"
:disabled="form.processing"
:placeholder="
t('projectCreation.namePlaceholder')
"
:invalid="!!form.errors.name"
aria-describedby="name-error"
/>
<InputError
id="name-error"
:message="form.errors.name"
/>
</div>
<div class="gap-stack grid">
<Label for="key">{{
t('projectCreation.key')
}}</Label>
<Input
id="key"
v-model="form.key"
name="key"
required
maxlength="5"
:disabled="form.processing"
:placeholder="
t('projectCreation.keyPlaceholder')
"
class="uppercase sm:max-w-48"
:invalid="!!form.errors.key"
aria-describedby="key-help key-error"
autocapitalize="characters"
:spellcheck="false"
/>
<p
id="key-help"
class="text-caption text-muted-foreground"
>
{{ t('projectCreation.keyHelp') }}
</p>
<InputError
id="key-error"
:message="form.errors.key"
/>
</div>
</section>
<section
v-if="form.creation_mode === 'duplicate'"
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
aria-labelledby="copy-title"
>
<div class="gap-stack grid">
<h2 id="copy-title" class="text-title">
{{ t('projectCreation.copyTitle') }}
</h2>
<p class="text-caption text-muted-foreground">
{{ t('projectCreation.copyHelp') }}
</p>
</div>
<div class="gap-stack grid min-w-0">
<Label for="source_project_id">{{
t('projectCreation.sourceProject')
}}</Label>
<select <select
id="source_project_id" id="source_project_id"
v-model="form.source_project_id" v-model="form.source_project_id"
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" required
:disabled="!hasSourceProjects" :class="selectClass"
:disabled="form.processing"
:aria-invalid="
!!form.errors.source_project_id
"
aria-describedby="source-project-error"
> >
<option value=""> <option value="">
Select a source project {{ t('projectCreation.selectProject') }}
</option> </option>
<option <option
v-for="sourceProject in sourceProjects" v-for="project in sourceProjects"
:key="sourceProject.id" :key="project.id"
:value="String(sourceProject.id)" :value="String(project.id)"
> >
{{ sourceProject.name }} ({{ {{ project.name }} ({{ project.key }})
sourceProject.key
}})
</option> </option>
</select> </select>
<p class="text-muted-foreground text-sm">
The new project keeps its own identity, key,
and future ticket numbering.
</p>
<InputError <InputError
id="source-project-error"
:message="form.errors.source_project_id" :message="form.errors.source_project_id"
/> />
</div> </div>
<fieldset
<div class="grid gap-3"> class="min-w-0"
<div> :disabled="form.processing"
<Label>Copy options</Label> aria-describedby="copy-help copy-areas-error"
<p class="text-muted-foreground text-sm"> >
Choose which settings should be copied <legend class="mb-2 font-semibold">
into the new project. {{ t('projectCreation.copyOptions') }}
</p> </legend>
</div> <p
id="copy-help"
<div class="grid gap-3"> class="text-caption text-muted-foreground mb-3"
<Label >
v-for="copyAreaOption in copyAreaOptions" {{ t('projectCreation.copyDefaults') }}
:key="copyAreaOption.value" </p>
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4" <div class="gap-stack grid sm:grid-cols-2">
<label
v-for="area in copyAreaOptions"
:key="area.value"
class="hover:border-primary/45 has-checked:border-primary has-checked:bg-accent/50 flex min-h-11 cursor-pointer items-start gap-3 rounded-md border p-3 transition-colors"
> >
<input <input
v-model="form.copy_areas" v-model="form.copy_areas"
type="checkbox" type="checkbox"
:value="copyAreaOption.value" :value="area.value"
class="mt-1" :aria-labelledby="`copy-${area.value}-label`"
:aria-describedby="`copy-${area.value}-description copy-areas-error`"
:aria-invalid="!!copyAreasError"
class="accent-primary mt-0.5 size-5 shrink-0"
/> />
<div class="space-y-1"> <span class="gap-stack grid min-w-0">
<div class="font-medium"> <span
{{ copyAreaOption.label }} :id="`copy-${area.value}-label`"
</div> class="text-body font-semibold"
<p >{{
class="text-muted-foreground text-sm" areaText(
area.value,
'label',
)
}}</span
> >
{{ copyAreaOption.description }} <span
</p> :id="`copy-${area.value}-description`"
</div> class="text-caption text-muted-foreground"
</Label> >{{
areaText(
area.value,
'description',
)
}}</span
>
</span>
</label>
</div> </div>
<InputError
id="copy-areas-error"
class="mt-3"
:message="copyAreasError"
/>
</fieldset>
</section>
<p class="text-muted-foreground text-sm"> <section
Operational ticket data is always left
behind so the new project starts clean.
</p>
<InputError :message="copyAreasError" />
</div>
</div>
<div
v-else-if="form.creation_mode === 'template'" v-else-if="form.creation_mode === 'template'"
class="space-y-4 rounded-xl border border-dashed p-4" class="bg-card gap-column grid min-w-0 rounded-md border p-5"
aria-labelledby="template-title"
> >
<div class="grid gap-2"> <h2 id="template-title" class="text-title">
<Label for="source_template_id">Template</Label> {{ t('projectCreation.chooseTemplate') }}
</h2>
<div class="gap-stack grid min-w-0">
<Label for="source_template_id">{{
t('projectCreation.templateLabel')
}}</Label>
<select <select
id="source_template_id" id="source_template_id"
v-model="form.source_template_id" v-model="form.source_template_id"
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" required
:disabled="!hasSourceTemplates" :class="selectClass"
:disabled="form.processing"
:aria-invalid="
!!form.errors.source_template_id
"
aria-describedby="template-help source-template-error"
> >
<option value="">Select a template</option> <option value="">
{{
t('projectCreation.selectTemplate')
}}
</option>
<option <option
v-for="sourceTemplate in sourceTemplates" v-for="template in sourceTemplates"
:key="sourceTemplate.id" :key="template.id"
:value="String(sourceTemplate.id)" :value="String(template.id)"
> >
{{ sourceTemplate.name }} ({{ {{ template.name }} ({{
sourceTemplate.slug template.slug
}}) }})
</option> </option>
</select> </select>
<p class="text-muted-foreground text-sm"> <p
Templates apply their saved configuration id="template-help"
groups to the new project. class="text-caption text-muted-foreground"
>
{{ t('projectCreation.templateHelp') }}
</p> </p>
<InputError <InputError
id="source-template-error"
:message="form.errors.source_template_id" :message="form.errors.source_template_id"
/> />
</div> </div>
</div> <div
v-if="selectedTemplate"
class="gap-stack grid"
aria-live="polite"
>
<h3 class="text-body font-semibold">
{{ t('projectCreation.includedAreas') }}
</h3>
<ul
v-if="selectedTemplate.copy_areas.length"
class="flex flex-wrap gap-2"
>
<li
v-for="area in selectedTemplate.copy_areas"
:key="area"
>
<Badge
variant="secondary"
class="whitespace-normal"
>{{
areaText(area, 'label')
}}</Badge
>
</li>
</ul>
<p
v-else
class="text-caption text-muted-foreground"
>
{{ t('projectCreation.templateDefaults') }}
</p>
</div>
</section>
</div>
<div <aside
v-else-if=" class="bg-card gap-column grid min-w-0 rounded-md border p-5"
!hasSourceProjects && !hasSourceTemplates aria-labelledby="summary-title"
" >
class="text-muted-foreground text-sm" <h2 id="summary-title" class="text-title">
{{ t('projectCreation.summary') }}
</h2>
<dl class="gap-column grid min-w-0">
<div class="gap-stack grid">
<dt class="text-caption text-muted-foreground">
{{ t('projectCreation.name') }}
</dt>
<dd class="break-words font-semibold">
{{
form.name.trim() ||
t('projectCreation.untitled')
}}
</dd>
</div>
<div class="gap-stack grid">
<dt class="text-caption text-muted-foreground">
{{ t('projectCreation.ticketPreview') }}
</dt>
<dd>
<Badge variant="outline">{{
ticketKey
}}</Badge>
</dd>
</div>
<div class="gap-stack grid">
<dt class="text-caption text-muted-foreground">
{{ t('projectCreation.setup') }}
</dt>
<dd class="break-words">
{{ selectedMode?.title }}
</dd>
</div>
<div
v-if="form.creation_mode === 'duplicate'"
class="gap-stack grid"
>
<dt class="text-caption text-muted-foreground">
{{ t('projectCreation.sourceProject') }}
</dt>
<dd class="break-words">
{{
selectedProject?.name ||
t('projectCreation.selectProject')
}}
</dd>
<dd class="text-caption text-muted-foreground">
{{
t('projectCreation.selectedCount', {
count: form.copy_areas.length,
})
}}
</dd>
</div>
<div
v-else-if="form.creation_mode === 'template'"
class="gap-stack grid"
>
<dt class="text-caption text-muted-foreground">
{{ t('projectCreation.templateLabel') }}
</dt>
<dd class="break-words">
{{
selectedTemplate?.name ||
t('projectCreation.selectTemplate')
}}
</dd>
</div>
</dl>
<p
class="text-caption text-muted-foreground border-t pt-4"
> >
No accessible source projects or saved templates are {{ t('projectCreation.cleanStart') }}
available yet. </p>
</div> </aside>
</div>
<div class="flex items-center gap-3"> <footer
<Button type="submit" :disabled="form.processing"> class="flex flex-col-reverse gap-3 border-t pt-5 sm:flex-row sm:items-center sm:justify-end"
Create project >
</Button> <Button
as-child
<Button as-child variant="outline"> variant="outline"
<Link :href="index()">Cancel</Link> :disabled="form.processing"
</Button> ><Link :href="index()">{{
</div> t('common.cancel')
</form> }}</Link></Button
</CardContent> >
</Card> <Button
type="submit"
:loading="form.processing"
:aria-label="
t(
form.processing
? 'projectCreation.creating'
: 'projectCreation.submit',
)
"
:loading-label="t('projectCreation.creating')"
><FolderPlus
v-if="!form.processing"
aria-hidden="true"
/>{{
t(
form.processing
? 'projectCreation.creating'
: 'projectCreation.submit',
)
}}</Button
>
</footer>
</form>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
+158 -77
View File
@@ -1,103 +1,184 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3'; import { Head, Link } from '@inertiajs/vue3';
import { Folder, Plus } from 'lucide-vue-next'; import {
import Heading from '@/components/Heading.vue'; ArrowUpRight,
Columns3,
FolderGit2,
ListTodo,
Plus,
} from 'lucide-vue-next';
import { computed } from 'vue';
import FeedbackState from '@/components/FeedbackState.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';
import { import { useLocalization } from '@/composables/useLocalization';
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { create, index, show } from '@/routes/projects/index'; import { create, index, show } from '@/routes/projects';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as boardIndex } from '@/routes/projects/board';
import type { BreadcrumbItem, ProjectsIndexPageProps } from '@/types'; import type { BreadcrumbItem, ProjectsIndexPageProps } from '@/types';
defineProps<ProjectsIndexPageProps>(); defineProps<ProjectsIndexPageProps>();
const { t } = useLocalization();
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ { title: t('navigation.projects'), href: index() },
title: 'Projects', ]);
href: index(),
},
];
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head title="Projects" /> <Head :title="t('navigation.projects')" />
<div class="flex flex-1 flex-col gap-6 p-4"> <div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
<div <header
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
> >
<Heading <div class="gap-stack grid min-w-0">
title="Projects" <p class="text-label text-muted-foreground uppercase">
description="Create and browse project spaces that will later host boards, backlog, and settings." {{ t('projects.workspace') }}
/> </p>
<h1 class="text-headline-mobile md:text-headline">
{{ t('navigation.projects') }}
</h1>
<p class="text-muted-foreground max-w-2xl">
{{ t('projects.description') }}
</p>
</div>
<Button <Button
v-if="$page.props.auth.permissions.projectCreate" v-if="$page.props.auth.permissions.projectCreate"
as-child as-child
class="self-start"
> >
<Link :href="create()"> <Link :href="create()"
<Plus class="size-4" /> ><Plus aria-hidden="true" />{{
New project t('projects.create')
</Link> }}</Link
</Button>
</div>
<div
v-if="status"
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
>
{{ status }}
</div>
<div
v-if="projects.length === 0"
class="text-muted-foreground rounded-xl border border-dashed px-6 py-10 text-center text-sm"
>
No projects exist yet. Create the first one to start building
out board, backlog, and settings areas.
</div>
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Link
v-for="project in projects"
:key="project.id"
:href="show(project.id)"
class="group block"
>
<Card
class="border-border/80 group-hover:border-primary/40 h-full transition-colors"
> >
<CardHeader class="gap-3"> </Button>
<div class="flex items-start justify-between gap-3"> </header>
<div class="space-y-1">
<CardTitle>{{ project.name }}</CardTitle>
<CardDescription>
Project workspace entry point
</CardDescription>
</div>
<Badge variant="outline" class="px-2.5 py-1"> <FeedbackState
{{ project.key }} v-if="status"
</Badge> state="success"
</div> :title="t('projects.updated')"
</CardHeader> :description="status"
/>
<CardContent <FeedbackState
class="text-muted-foreground flex items-center gap-2 text-sm" v-if="projects.length === 0"
state="empty"
:title="t('projects.empty.title')"
:description="
$page.props.auth.permissions.projectCreate
? t('projects.empty.createDescription')
: t('projects.empty.accessDescription')
"
>
<Button
v-if="$page.props.auth.permissions.projectCreate"
as-child
variant="outline"
>
<Link :href="create()"
><Plus aria-hidden="true" />{{
t('projects.empty.create')
}}</Link
>
</Button>
</FeedbackState>
<section
v-else
aria-labelledby="project-list-title"
class="gap-column grid"
>
<h2
id="project-list-title"
class="text-caption text-muted-foreground"
>
{{ t('projects.available', { count: projects.length }) }}
</h2>
<ul class="gap-column grid md:grid-cols-2 xl:grid-cols-3">
<li
v-for="project in projects"
:key="project.id"
class="min-w-0"
>
<article
class="bg-card text-card-foreground flex h-full min-w-0 flex-col rounded-md border"
> >
<Folder class="size-4" /> <Link
Open board, backlog, and settings hub :href="show(project.id)"
</CardContent> :aria-label="
</Card> t('projects.open', { name: project.name })
</Link> "
</div> class="gap-column hover:bg-accent/50 group flex min-w-0 flex-1 items-start rounded-t-md p-5 transition-colors"
>
<span
class="bg-accent text-primary flex size-11 shrink-0 items-center justify-center rounded-md"
>
<FolderGit2
class="size-5"
aria-hidden="true"
/>
</span>
<div class="gap-stack grid min-w-0 flex-1">
<Badge
variant="outline"
class="max-w-full justify-self-start whitespace-normal break-all"
>{{ project.key }}</Badge
>
<h3
class="text-title group-hover:text-primary break-words"
>
{{ project.name }}
</h3>
<span
class="text-caption text-muted-foreground"
>{{ t('projects.overview') }}</span
>
</div>
<ArrowUpRight
class="text-muted-foreground mt-1 size-4 shrink-0"
aria-hidden="true"
/>
</Link>
<div
class="gap-stack p-card grid grid-cols-2 border-t"
>
<Button as-child variant="secondary">
<Link
:href="boardIndex(project.id)"
:aria-label="
t('projects.openBoard', {
name: project.name,
})
"
>
<Columns3 aria-hidden="true" />{{
t('navigation.board')
}}
</Link>
</Button>
<Button as-child variant="ghost">
<Link
:href="backlogIndex(project.id)"
:aria-label="
t('projects.openBacklog', {
name: project.name,
})
"
>
<ListTodo aria-hidden="true" />{{
t('navigation.backlog')
}}
</Link>
</Button>
</div>
</article>
</li>
</ul>
</section>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
+186 -165
View File
@@ -1,206 +1,227 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3'; import { Head, Link } from '@inertiajs/vue3';
import { FolderGit2, LayoutGrid, ListTodo, Settings2 } from 'lucide-vue-next'; import {
import Heading from '@/components/Heading.vue'; ArrowLeft,
ArrowRight,
Columns3,
Layers3,
ListTodo,
} from 'lucide-vue-next';
import { computed } from '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';
import { import { useLocalization } from '@/composables/useLocalization';
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { access, index, show } from '@/routes/projects/index'; import { projectSettingsNavigation } from '@/lib/navigation';
import { index as boardIndex } from '@/routes/projects/board'; import { index, show } from '@/routes/projects';
import { index as backlogIndex } from '@/routes/projects/backlog'; import { index as backlogIndex } from '@/routes/projects/backlog';
import { import { index as boardIndex } from '@/routes/projects/board';
board as boardSettings,
general as generalSettings,
metadata as metadataSettings,
notifications as notificationSettings,
repositories as repositorySettings,
} from '@/routes/projects/settings';
import { create as projectTemplateCreate } from '@/routes/projects/templates'; import { create as projectTemplateCreate } from '@/routes/projects/templates';
import type { BreadcrumbItem, ProjectShowPageProps } from '@/types'; import type { BreadcrumbItem, ProjectShowPageProps } from '@/types';
const props = defineProps<ProjectShowPageProps>(); const props = defineProps<ProjectShowPageProps>();
const { t } = useLocalization();
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: index() },
{ title: props.project.name, href: show(props.project.id) },
]);
const projectAreas = computed(() => [
{ {
title: 'Projects', title: t('navigation.board'),
href: index(), description: t('projects.boardDescription'),
action: t('projects.boardAction'),
href: boardIndex(props.project.id),
icon: Columns3,
}, },
{ {
title: props.project.name, title: t('navigation.backlog'),
href: show(props.project.id), description: t('projects.backlogDescription'),
}, action: t('projects.backlogAction'),
]; href: backlogIndex(props.project.id),
const projectAreas = [
{
title: 'Backlog',
description:
'Create tickets, review incoming work, and move ready items into the board workflow.',
icon: ListTodo, icon: ListTodo,
}, },
{ ]);
title: 'Board', const settingsItems = computed(() =>
description: projectSettingsNavigation(
'Track active work across the project-defined Kanban columns and update ticket status.', {
icon: LayoutGrid, id: props.project.id,
}, name: props.project.name,
{ key: props.project.key,
title: 'Project settings', canManage: props.canManageAccess,
description: canCreateTickets: false,
'Manage board columns, ticket metadata, and the project-specific workflow configuration.', },
icon: Settings2, t,
}, ),
{ );
title: 'Repositories',
description:
'Link project repositories so tickets can later discover related branches and pull requests.',
icon: FolderGit2,
},
];
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="project.name" /> <Head :title="project.name" />
<div class="flex flex-1 flex-col gap-6 p-4"> <div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
<div <Link
:href="index()"
class="text-caption text-muted-foreground hover:text-primary min-h-control inline-flex items-center gap-2 self-start rounded-md"
>
<ArrowLeft class="size-4" aria-hidden="true" />{{
t('projects.back')
}}
</Link>
<header
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
> >
<div class="space-y-3"> <div class="gap-stack grid min-w-0">
<Badge variant="outline" class="px-3 py-1"> <Badge
{{ project.key }}
</Badge>
<Heading
:title="project.name"
description="Project hub for the upcoming backlog, board, and settings work."
/>
</div>
<div class="flex flex-wrap gap-3">
<Button as-child variant="outline">
<Link :href="boardIndex(project.id)">Open board</Link>
</Button>
<Button as-child variant="outline">
<Link :href="backlogIndex(project.id)">
Open backlog
</Link>
</Button>
<Button
v-if="$page.props.auth.permissions.projectCreate"
as-child
variant="outline" variant="outline"
class="max-w-full justify-self-start whitespace-normal break-all"
>{{ project.key }}</Badge
> >
<Link :href="projectTemplateCreate(project.id)"> <h1
Save as new template class="text-headline-mobile md:text-headline break-words"
</Link> >
</Button> {{ project.name }}
</h1>
<Button as-child variant="outline"> <p class="text-muted-foreground max-w-2xl">
<Link :href="index()">Back to projects</Link> {{ t('projects.hubDescription') }}
</Button> </p>
</div> </div>
</div> <Button
v-if="$page.props.auth.permissions.projectCreate"
as-child
variant="outline"
class="self-start"
>
<Link :href="projectTemplateCreate(project.id)"
><Layers3 aria-hidden="true" />{{
t('projects.saveTemplate')
}}</Link
>
</Button>
</header>
<Card v-if="templateState?.inheritsFromTemplate"> <section
<CardHeader> :aria-label="t('projects.workAreas')"
<CardTitle> class="gap-column grid md:grid-cols-2"
Built from template {{ templateState.templateName }} >
</CardTitle> <Link
<CardDescription> v-for="area in projectAreas"
This project keeps its template link. Inherited areas :key="area.href.url"
follow the template baseline; overridden areas were :href="area.href"
customized locally. :aria-label="area.action"
</CardDescription> class="gap-column bg-card hover:border-primary/50 hover:shadow-card-hover group flex min-w-0 flex-col rounded-md border p-5 transition-[border-color,box-shadow]"
</CardHeader> >
<CardContent class="flex flex-wrap gap-2"> <span
class="bg-accent text-primary flex size-11 items-center justify-center rounded-md"
>
<component
:is="area.icon"
class="size-5"
aria-hidden="true"
/>
</span>
<div class="gap-stack grid">
<h2 class="text-title">{{ area.title }}</h2>
<p class="text-muted-foreground">
{{ area.description }}
</p>
</div>
<span
class="text-primary mt-auto flex items-center gap-2 pt-2 font-semibold"
>
{{ area.action
}}<ArrowRight class="size-4" aria-hidden="true" />
</span>
</Link>
</section>
<section
v-if="settingsItems.length"
aria-labelledby="project-settings-title"
class="gap-column grid"
>
<div class="gap-stack grid">
<h2 id="project-settings-title" class="text-title">
{{ t('navigation.projectSettings') }}
</h2>
<p class="text-muted-foreground">
{{ t('projects.settingsDescription') }}
</p>
</div>
<ul class="gap-stack grid sm:grid-cols-2 xl:grid-cols-3">
<li
v-for="item in settingsItems"
:key="item.title"
class="min-w-0"
>
<Link
:href="item.href"
class="gap-card bg-card hover:bg-accent hover:text-accent-foreground min-h-control flex h-full items-center rounded-md border p-4 transition-colors"
>
<component
:is="item.icon"
class="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
<span
class="min-w-0 flex-1 break-words font-medium"
>{{ item.title }}</span
>
<ArrowRight
class="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
</Link>
</li>
</ul>
</section>
<section
v-if="templateState?.inheritsFromTemplate"
aria-labelledby="project-template-title"
class="gap-column bg-card flex min-w-0 flex-col rounded-md border p-5"
>
<div class="gap-card flex items-start">
<Layers3
class="text-muted-foreground mt-0.5 size-5 shrink-0"
aria-hidden="true"
/>
<div class="gap-stack grid min-w-0">
<h2
id="project-template-title"
class="text-title break-words"
>
{{
t('projects.templateTitle', {
name: templateState.templateName,
})
}}
</h2>
<p class="text-muted-foreground">
{{ t('projects.templateDescription') }}
</p>
</div>
</div>
<div class="gap-stack flex flex-wrap">
<Badge <Badge
v-for="area in templateState.inheritedAreas" v-for="area in templateState.inheritedAreas"
:key="`inherited-${area.value}`" :key="`inherited-${area.value}`"
variant="outline" variant="outline"
class="max-w-full whitespace-normal break-words"
> >
{{ area.label }} · inherited {{ t('projects.inheritedArea', { name: area.label }) }}
</Badge> </Badge>
<Badge <Badge
v-for="area in templateState.overriddenAreas" v-for="area in templateState.overriddenAreas"
:key="`overridden-${area.value}`" :key="`overridden-${area.value}`"
variant="secondary"
class="max-w-full whitespace-normal break-words"
> >
{{ area.label }} · overridden {{ t('projects.overriddenArea', { name: area.label }) }}
</Badge> </Badge>
</CardContent> </div>
</Card> </section>
<div v-if="canManageAccess" class="flex flex-wrap gap-3">
<Button as-child>
<Link :href="boardSettings(project.id)">
Board settings
</Link>
</Button>
<Button
v-if="$page.props.auth.permissions.globalAdmin"
as-child
variant="outline"
>
<Link :href="generalSettings(project.id)">
General settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="metadataSettings(project.id)">
Ticket settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="repositorySettings(project.id)">
Repository settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="notificationSettings(project.id)">
Notification settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="access(project.id)">Manage access</Link>
</Button>
</div>
<div class="grid gap-4 md:grid-cols-3">
<Card
v-for="area in projectAreas"
:key="area.title"
class="h-full"
>
<CardHeader>
<div
class="bg-muted text-foreground mb-3 flex size-10 items-center justify-center rounded-full"
>
<component :is="area.icon" class="size-5" />
</div>
<CardTitle>{{ area.title }}</CardTitle>
<CardDescription>{{
area.description
}}</CardDescription>
</CardHeader>
<CardContent class="text-muted-foreground text-sm">
Use the project shortcuts above to open this area.
</CardContent>
</Card>
</div>
</div> </div>
</AppLayout> </AppLayout>
</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;
}; };
+9
View File
@@ -39,6 +39,15 @@ export type ProjectDuplicateCopyAreaOption = {
value: string; value: string;
}; };
export type AssignedTicketsProjectGroup = {
project: ProjectModel;
tickets: TicketModel[];
};
export type DashboardPageProps = {
assignedTicketsByProject: AssignedTicketsProjectGroup[];
};
export type ProjectsIndexPageProps = { export type ProjectsIndexPageProps = {
projects: ProjectModel[]; projects: ProjectModel[];
status?: string | null; status?: string | null;
+12
View File
@@ -0,0 +1,12 @@
import type { TicketModel } from '@/types/models/ticket-model';
export type TicketPresentationProps = {
projectId: number;
showDescription?: boolean;
showStatus?: boolean;
ticket: TicketModel;
};
export type TicketPresentationEmits = {
open: [ticketId: number];
};
+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
+2 -1
View File
@@ -1,5 +1,6 @@
<?php <?php
use App\Http\Controllers\DashboardController;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@@ -17,7 +18,7 @@ Route::get('api/docs/openapi.json', function (): Response {
})->name('api.docs.openapi'); })->name('api.docs.openapi');
Route::middleware(['auth', 'verified'])->group(function () { Route::middleware(['auth', 'verified'])->group(function () {
Route::inertia('dashboard', 'Dashboard')->name('dashboard'); Route::get('dashboard', DashboardController::class)->name('dashboard');
require __DIR__.'/web/admin.php'; require __DIR__.'/web/admin.php';
require __DIR__.'/web/project-templates.php'; require __DIR__.'/web/project-templates.php';

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