Compare commits

..
2 Commits
Author SHA1 Message Date
Twyco 94f98027f0 remove ui docs
CI/CD / Publish immutable image (push) Skipped
CI/CD / test_and_check_app (push) Successful in 4m2s
CI/CD / test_and_check_app (pull_request) Successful in 4m46s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-09 11:21:04 +02:00
Twyco 9003bcd58d Add MCP tool to list project ticket types
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-09 09:41:12 +02:00
154 changed files with 2824 additions and 12504 deletions
@@ -47,7 +47,6 @@ 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,7 +29,6 @@ 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',
@@ -1,24 +0,0 @@
<?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),
]);
}
}
+1 -5
View File
@@ -16,11 +16,7 @@ class HandleAppearance
*/ */
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
$appearance = $request->cookie('appearance'); View::share('appearance', $request->cookie('appearance') ?? 'system');
$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,7 +4,6 @@ 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;
@@ -47,11 +46,6 @@ 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)),
@@ -61,36 +55,6 @@ 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
@@ -1,92 +0,0 @@
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.
+122 -87
View File
@@ -1,98 +1,56 @@
@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 (&:where(.dark, .dark *)); @custom-variant dark (&:is(.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: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'; --font-sans:
Instrument Sans, ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--radius-sm: 0.125rem; --radius-lg: var(--radius);
--radius-md: var(--radius); --radius-md: calc(var(--radius) - 2px);
--radius-lg: 0.5rem; --radius-sm: calc(var(--radius) - 4px);
--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);
@@ -103,35 +61,112 @@
--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; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background font-sans text-body text-foreground antialiased; @apply bg-background text-foreground;
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
@@ -1,96 +0,0 @@
/* 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,8 +3,7 @@ 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 { createAppearance } from '@/composables/useAppearance'; import { initializeTheme } 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';
@@ -18,11 +17,12 @@ 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: 'var(--primary)', color: '#4B5563',
}, },
}); });
// 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 = defineProps<Props>(); const props = withDefaults(defineProps<Props>(), {
const { t } = useLocalization(); title: 'Something went wrong.',
});
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 ?? t('common.error') }}</AlertTitle> <AlertTitle>{{ title }}</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">
+9 -10
View File
@@ -1,17 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { Columns3 } from 'lucide-vue-next'; import AppLogoIcon from '@/components/AppLogoIcon.vue';
</script> </script>
<template> <template>
<span class="flex min-w-0 items-center gap-3"> <div
<span class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md"
class="bg-brand text-brand-foreground flex size-8 shrink-0 items-center justify-center rounded-md"
> >
<Columns3 class="size-5" aria-hidden="true" /> <AppLogoIcon class="size-5 fill-current text-white dark:text-black" />
</span> </div>
<span <div class="ml-1 grid flex-1 text-left text-sm">
class="text-title truncate font-semibold group-data-[collapsible=icon]:hidden" <span class="mb-0.5 truncate font-semibold leading-tight"
>KanbanGit</span >Laravel Starter Kit</span
> >
</span> </div>
</template> </template>
+84 -80
View File
@@ -1,10 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link, router } from '@inertiajs/vue3'; import { Link, usePage } from '@inertiajs/vue3';
import { Settings2, X } from 'lucide-vue-next'; import {
import { computed, onMounted, onUnmounted } from 'vue'; BookOpen,
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 { Button } from '@/components/ui/button'; import NavUser from '@/components/NavUser.vue';
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -13,98 +22,93 @@ 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 { edit as profile } from '@/routes/profile'; import { index as adminUsers } from '@/routes/admin/users';
import { show } from '@/routes/projects'; import { index as projectTemplatesIndex } from '@/routes/project-templates';
import { index as projectsIndex } from '@/routes/projects';
import { index as teamsIndex } from '@/routes/teams';
import type { NavItem } from '@/types';
const { t } = useLocalization(); const page = usePage();
const { currentUrl } = useCurrentUrl();
const { project, workspaceItems, projectItems, projectSettingsItems } = const mainNavItems = computed<NavItem[]>(() => {
useAppNavigation(); const items: NavItem[] = [
const { isMobile, setOpenMobile } = useSidebar();
const accountItems = computed(() => [
{ {
title: t('navigation.settings'), title: 'Dashboard',
href: profile(), href: dashboard(),
icon: Settings2, icon: LayoutGrid,
isActive: currentUrl.value.startsWith('/settings/'),
}, },
]); {
let removeNavigationListener: (() => void) | undefined; title: 'Projects',
onMounted(() => { href: projectsIndex(),
removeNavigationListener = router.on('navigate', () => icon: Folder,
setOpenMobile(false), },
); ];
if (page.props.auth.permissions.projectCreate) {
items.push({
title: 'Project templates',
href: projectTemplatesIndex(),
icon: Layers3,
}); });
onUnmounted(() => removeNavigationListener?.()); }
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;
});
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 id="app-navigation" collapsible="icon" variant="sidebar"> <Sidebar collapsible="icon" variant="inset">
<SidebarHeader class="border-sidebar-border gap-4 border-b p-3"> <SidebarHeader>
<div class="flex items-center gap-2"> <SidebarMenu>
<SidebarMenu class="min-w-0">
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton <SidebarMenuButton size="lg" as-child>
size="lg" <Link :href="dashboard()">
as-child <AppLogo />
tooltip="KanbanGit" </Link>
>
<Link :href="dashboard()" aria-label="KanbanGit"
><AppLogo
/></Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </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>
<nav :aria-label="t('navigation.main')"> <NavMain :items="mainNavItems" />
<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">
<NavMain :items="accountItems" :label="t('navigation.account')" /> <SidebarFooter>
<NavFooter :items="footerNavItems" />
<NavUser />
</SidebarFooter> </SidebarFooter>
</Sidebar> </Sidebar>
<slot />
</template> </template>
+12 -130
View File
@@ -1,145 +1,27 @@
<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(defineProps<{ breadcrumbs?: BreadcrumbItem[] }>(), { withDefaults(
defineProps<{
breadcrumbs?: BreadcrumbItem[];
}>(),
{
breadcrumbs: () => [], breadcrumbs: () => [],
});
const page = usePage();
const { project } = useAppNavigation();
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="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" 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"
> >
<div class="flex min-w-0 flex-1 items-center gap-2"> <div class="flex items-center gap-2">
<SidebarTrigger class="shrink-0" /> <SidebarTrigger class="-ml-1" />
<Breadcrumbs v-if="breadcrumbs.length" :breadcrumbs="breadcrumbs" /> <template v-if="breadcrumbs && breadcrumbs.length > 0">
<span v-else class="truncate font-semibold">KanbanGit</span> <Breadcrumbs :breadcrumbs="breadcrumbs" />
</div> </template>
<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>
+9 -15
View File
@@ -1,39 +1,33 @@
<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: 'appearance.light' }, { value: 'light', Icon: Sun, label: 'Light' },
{ value: 'dark', Icon: Moon, label: 'appearance.dark' }, { value: 'dark', Icon: Moon, label: 'Dark' },
{ value: 'system', Icon: Monitor, label: 'appearance.system' }, { value: 'system', Icon: Monitor, label: 'System' },
] as const; ] as const;
</script> </script>
<template> <template>
<div <div
role="group" class="inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800"
: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="[
'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', 'flex items-center rounded-md px-3.5 py-1.5 transition-colors',
appearance === value appearance === value
? 'bg-primary text-primary-foreground' ? 'shadow-xs bg-white dark:bg-neutral-700 dark:text-neutral-100'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground', : 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60',
]" ]"
> >
<component :is="Icon" class="size-4 shrink-0" aria-hidden="true" /> <component :is="Icon" class="-ml-1 h-4 w-4" />
<span>{{ t(label) }}</span> <span class="ml-1.5 text-sm">{{ label }}</span>
</button> </button>
</div> </div>
</template> </template>
@@ -1,78 +0,0 @@
<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>
+6 -25
View File
@@ -9,48 +9,29 @@ 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 class="min-w-0" :aria-label="t('navigation.breadcrumbs')"> <Breadcrumb>
<BreadcrumbList class="flex-nowrap"> <BreadcrumbList>
<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 class="truncate" :title="item.title">{{ <BreadcrumbPage>{{ item.title }}</BreadcrumbPage>
item.title
}}</BreadcrumbPage>
</template> </template>
<template v-else> <template v-else>
<BreadcrumbLink as-child> <BreadcrumbLink as-child>
<Link <Link :href="item.href">{{ item.title }}</Link>
:href="item.href"
class="max-w-40 truncate"
:title="item.title"
>{{ item.title }}</Link
>
</BreadcrumbLink> </BreadcrumbLink>
</template> </template>
</BreadcrumbItem> </BreadcrumbItem>
<BreadcrumbSeparator <BreadcrumbSeparator v-if="index !== breadcrumbs.length - 1" />
v-if="index !== breadcrumbs.length - 1"
class="hidden md:block"
/>
</template> </template>
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
-72
View File
@@ -1,72 +0,0 @@
<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
@@ -1,76 +0,0 @@
<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" role="alert"> <div v-show="message">
<p class="text-body text-destructive"> <p class="text-sm text-red-600 dark:text-red-500">
{{ message }} {{ message }}
</p> </p>
</div> </div>
+6 -29
View File
@@ -12,46 +12,23 @@ 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-3 py-2"> <SidebarGroup class="px-2 py-0">
<SidebarGroupLabel class="text-label text-muted-foreground uppercase">{{ <SidebarGroupLabel>Platform</SidebarGroupLabel>
label <SidebarMenu>
}}</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=" :is-active="isCurrentUrl(item.href)"
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 <Link :href="item.href">
:href="item.href" <component :is="item.icon" />
: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>
+4 -11
View File
@@ -2,7 +2,6 @@
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';
@@ -10,14 +9,12 @@ 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,
@@ -31,8 +28,7 @@ defineExpose({
ref="inputRef" ref="inputRef"
v-model="model" v-model="model"
:type="showPassword ? 'text' : 'password'" :type="showPassword ? 'text' : 'password'"
:class="cn('pr-12', props.class)" :class="cn('pr-10', props.class)"
:disabled="disabled"
v-bind="$attrs" v-bind="$attrs"
/> />
<button <button
@@ -40,14 +36,11 @@ defineExpose({
@click="showPassword = !showPassword" @click="showPassword = !showPassword"
:class=" :class="
cn( cn(
'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', '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]',
) )
" "
:aria-label=" :aria-label="showPassword ? 'Hide password' : 'Show password'"
t(showPassword ? 'auth.password.hide' : 'auth.password.show') :tabindex="-1"
"
: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" />
@@ -1,89 +0,0 @@
<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>
+22 -11
View File
@@ -1,22 +1,33 @@
<script setup lang="ts"> <script setup lang="ts">
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue'; import { computed } from 'vue';
import { useInitials } from '@/composables/useInitials';
import type { User } from '@/types'; import type { User } from '@/types';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
withDefaults(defineProps<{ user: User; showEmail?: boolean }>(), { type Props = {
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>
<div class="flex min-w-0 items-center gap-2"> <UserAvatar />
<UserAvatar :user="user" />
<div class="grid min-w-0 flex-1 text-left text-sm leading-tight"> <div class="grid 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 <span v-if="showEmail" class="text-muted-foreground truncate text-xs">{{
v-if="showEmail" user.email
class="text-muted-foreground truncate text-xs" }}</span>
>{{ user.email }}</span
>
</div>
</div> </div>
</template> </template>
+2 -4
View File
@@ -11,7 +11,6 @@ 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;
@@ -22,7 +21,6 @@ const handleLogout = () => {
}; };
defineProps<Props>(); defineProps<Props>();
const { t } = useLocalization();
</script> </script>
<template> <template>
@@ -36,7 +34,7 @@ const { t } = useLocalization();
<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" />
{{ t('navigation.settings') }} Settings
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
@@ -50,7 +48,7 @@ const { t } = useLocalization();
data-test="logout-button" data-test="logout-button"
> >
<LogOut class="mr-2 h-4 w-4" /> <LogOut class="mr-2 h-4 w-4" />
{{ t('navigation.logout') }} Log out
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
</template> </template>
@@ -1,84 +0,0 @@
<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>
@@ -1,88 +0,0 @@
<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>
@@ -1,91 +0,0 @@
<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
@@ -1,2 +0,0 @@
export { default as TicketCard } from './TicketCard.vue';
export { default as TicketListItem } from './TicketListItem.vue';
+7 -24
View File
@@ -1,34 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AvatarRootProps } from "reka-ui"; import type { HTMLAttributes } from "vue"
import type { HTMLAttributes } from "vue"; import { AvatarRoot } from "reka-ui"
import type { AvatarVariants } from "."; import { cn } from "@/lib/utils"
import { reactiveOmit } from "@vueuse/core";
import { AvatarRoot, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
import { avatarVariants } from ".";
const props = withDefaults( const props = defineProps<{
defineProps< class?: HTMLAttributes["class"]
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"
:data-size="size" :class="cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', props.class)"
:data-shape="shape"
v-bind="forwardedProps"
:class="cn(avatarVariants({ size, shape }), props.class)"
> >
<slot /> <slot />
</AvatarRoot> </AvatarRoot>
@@ -1,27 +1,20 @@
<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< const props = defineProps<AvatarFallbackProps & { class?: HTMLAttributes["class"] }>()
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=" :class="cn('bg-muted flex size-full items-center justify-center rounded-full', props.class)"
cn(
'bg-accent text-accent-foreground flex size-full items-center justify-center font-semibold',
props.class,
)
"
> >
<slot /> <slot />
</AvatarFallback> </AvatarFallback>
@@ -1,39 +1,41 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AvatarVariants } from "@/components/ui/avatar";
import type { UserModel } from "@/types/models/user-model";
import { usePage } from "@inertiajs/vue3";
import { computed } from "vue";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { getInitials } from "@/composables/useInitials"; import { getInitials } from "@/composables/useInitials";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar/index";
import { usePage } from "@inertiajs/vue3";
import { computed, PropType } from "vue";
import { UserModel } from "@/types/models/user-model";
const props = withDefaults( const props = defineProps({
defineProps<{ user: {
user?: UserModel; type: Object as PropType<UserModel>,
rounded?: boolean;
size?: AvatarVariants["size"];
label?: string;
}>(),
{
rounded: true,
size: "default",
}, },
); rounded: {
type: Boolean,
default: false,
},
})
const page = usePage(); const page = usePage();
const user = computed(() => props.user ?? page.props.auth.user); const auth = computed(() => page.props.auth);
const accessibleLabel = computed(() => props.label ?? user.value.name);
const initials = computed(() => getInitials(user.value.name)); const user = computed(() => props.user ?? auth.value.user)
</script> </script>
<template> <template>
<Avatar :size="size" :shape="rounded ? 'circle' : 'rounded'"> <Avatar class="size-8 overflow-hidden" :class="rounded ? 'rounded-full' : 'rounded-lg'">
<AvatarImage <AvatarImage
v-if="user.avatar" v-if="user.avatar"
:src="user.avatar" :src="user.avatar"
:alt="accessibleLabel" :alt="user.name"
/> />
<AvatarFallback :aria-label="accessibleLabel"> <AvatarFallback
{{ initials }} class="rounded-lg bg-neutral-200 font-semibold text-black dark:bg-neutral-700 dark:text-white"
>
{{ getInitials(auth.user?.name) }}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
</template> </template>
+3 -31
View File
@@ -1,31 +1,3 @@
import type { VariantProps } from "class-variance-authority"; export { default as Avatar } from "./Avatar.vue"
import { cva } from "class-variance-authority"; export { default as AvatarFallback } from "./AvatarFallback.vue"
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>;
+12 -14
View File
@@ -1,20 +1,18 @@
<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< const props = defineProps<PrimitiveProps & {
PrimitiveProps & { variant?: BadgeVariants["variant"]
variant?: BadgeVariants["variant"]; class?: HTMLAttributes["class"]
class?: HTMLAttributes["class"]; }>()
}
>();
const delegatedProps = reactiveOmit(props, "class", "variant"); const delegatedProps = reactiveOmit(props, "class")
</script> </script>
<template> <template>
+9 -14
View File
@@ -1,31 +1,26 @@
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 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", "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",
{ {
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/80", "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive: destructive:
"border-transparent bg-destructive text-destructive-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/35", "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",
outline: outline:
"border-border bg-card text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", "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: { defaultVariants: {
variant: "default", variant: "default",
}, },
}, },
); )
export type BadgeVariants = VariantProps<typeof badgeVariants>; export type BadgeVariants = VariantProps<typeof badgeVariants>
+10 -28
View File
@@ -1,47 +1,29 @@
<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 { computed } from "vue"; import { cn } from "@/lib/utils"
import { Spinner } from "@/components/ui/spinner"; import { buttonVariants } from "."
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"
:data-loading="loading ? '' : undefined"
:as="as" :as="as"
:as-child="asChild" :as-child="asChild"
:disabled="isDisabled"
:aria-disabled="isDisabled ? 'true' : undefined"
:aria-busy="loading ? 'true' : undefined"
:class="cn(buttonVariants({ variant, size }), props.class)" :class="cn(buttonVariants({ variant, size }), props.class)"
> >
<Spinner
v-if="loading"
data-slot="button-spinner"
:label="loadingLabel"
/>
<slot /> <slot />
</Primitive> </Primitive>
</template> </template>
+19 -18
View File
@@ -1,31 +1,32 @@
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 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", "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",
{ {
variants: { variants: {
variant: { variant: {
default: default:
"border border-transparent bg-brand text-brand-foreground shadow-xs hover:brightness-95", "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: destructive:
"border border-transparent bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/35", "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: outline:
"border border-input bg-card text-foreground shadow-xs hover:border-primary/45 hover:bg-accent hover:text-accent-foreground", "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary: secondary:
"border border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "border border-transparent hover:bg-accent hover:text-accent-foreground", ghost:
link: "min-h-0 border-0 px-0 text-primary underline-offset-4 shadow-none hover:underline", "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
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: { defaultVariants: {
@@ -33,5 +34,5 @@ export const buttonVariants = cva(
size: "default", size: "default",
}, },
}, },
); )
export type ButtonVariants = VariantProps<typeof buttonVariants>; export type ButtonVariants = VariantProps<typeof buttonVariants>
@@ -1,47 +1,34 @@
<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 = withDefaults( const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>()
defineProps< const emits = defineEmits<CheckboxRootEmits>()
CheckboxRootProps & {
class?: HTMLAttributes["class"];
invalid?: boolean;
}
>(),
{ invalid: false },
);
const emits = defineEmits<CheckboxRootEmits>();
const delegatedProps = reactiveOmit(props, "class", "invalid"); const delegatedProps = reactiveOmit(props, "class")
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"
:data-invalid="invalid ? '' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
v-bind="forwarded" v-bind="forwarded"
:class=" :class="
cn( 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',
'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', props.class)"
props.class,
)
"
> >
<CheckboxIndicator <CheckboxIndicator
data-slot="checkbox-indicator" data-slot="checkbox-indicator"
class="grid place-content-center text-current transition-none" class="grid place-content-center text-current transition-none"
> >
<slot v-bind="slotProps"> <slot v-bind="slotProps">
<Check class="size-4" stroke-width="2.5" /> <Check class="size-3.5" />
</slot> </slot>
</CheckboxIndicator> </CheckboxIndicator>
</CheckboxRoot> </CheckboxRoot>
@@ -1,41 +1,29 @@
<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( const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
defineProps<
DialogContentProps & {
class?: HTMLAttributes["class"];
showCloseButton?: boolean;
}
>(),
{
showCloseButton: true, showCloseButton: true,
}, })
); const emits = defineEmits<DialogContentEmits>()
const emits = defineEmits<DialogContentEmits>();
const delegatedProps = reactiveOmit(props, "class", "showCloseButton"); const delegatedProps = reactiveOmit(props, "class")
const { restoreFocus } = useDialogFocus();
const { t } = useLocalization();
const forwarded = useForwardPropsEmits(delegatedProps, emits); const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script> </script>
<template> <template>
@@ -44,23 +32,21 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
<DialogContent <DialogContent
data-slot="dialog-content" data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }" v-bind="{ ...$attrs, ...forwarded }"
@close-auto-focus="restoreFocus"
:class=" :class="
cn( cn(
'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', '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',
props.class, 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-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" 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"
> >
<X /> <X />
<span class="sr-only">{{ t("common.close") }}</span> <span class="sr-only">Close</span>
</DialogClose> </DialogClose>
</DialogContent> </DialogContent>
</DialogPortal> </DialogPortal>
@@ -1,16 +1,16 @@
<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-stack pr-6 text-left', props.class)" :class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
> >
<slot /> <slot />
</div> </div>
@@ -1,33 +1,27 @@
<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< const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
DialogContentProps & { class?: HTMLAttributes["class"] } const emits = defineEmits<DialogContentEmits>()
>();
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>
@@ -38,32 +32,26 @@ const { t } = useLocalization();
<DialogContent <DialogContent
:class=" :class="
cn( cn(
'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', '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, props.class,
) )
" "
v-bind="{ ...$attrs, ...forwarded }" v-bind="{ ...$attrs, ...forwarded }"
@close-auto-focus="restoreFocus" @pointer-down-outside="(event) => {
@pointer-down-outside="
(event) => {
const originalEvent = event.detail.originalEvent; const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement; const target = originalEvent.target as HTMLElement;
if ( if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
originalEvent.offsetX > target.clientWidth ||
originalEvent.offsetY > target.clientHeight
) {
event.preventDefault(); event.preventDefault();
} }
} }"
"
> >
<slot /> <slot />
<DialogClose <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" class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
> >
<X class="w-4 h-4" /> <X class="w-4 h-4" />
<span class="sr-only">{{ t("common.close") }}</span> <span class="sr-only">Close</span>
</DialogClose> </DialogClose>
</DialogContent> </DialogContent>
</DialogOverlay> </DialogOverlay>
@@ -1,42 +1,33 @@
<script setup lang="ts"> <script setup lang="ts">
import type { import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from "reka-ui"
DropdownMenuCheckboxItemEmits, import type { HTMLAttributes } from "vue"
DropdownMenuCheckboxItemProps, import { reactiveOmit } from "@vueuse/core"
} from "reka-ui"; import { Check } from "lucide-vue-next"
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< const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>()
DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] } const emits = defineEmits<DropdownMenuCheckboxItemEmits>()
>();
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=" :class=" cn(
cn( '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',
'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, props.class,
) )"
"
>
<span
class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"
> >
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuItemIndicator> <DropdownMenuItemIndicator>
<slot name="indicator-icon"> <slot name="indicator-icon">
<Check class="size-4" /> <Check class="size-4" />
@@ -1,34 +1,29 @@
<script setup lang="ts"> <script setup lang="ts">
import type { import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui"
DropdownMenuContentEmits, import type { HTMLAttributes } from "vue"
DropdownMenuContentProps, import { reactiveOmit } from "@vueuse/core"
} 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< defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(),
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>
@@ -36,12 +31,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
<DropdownMenuContent <DropdownMenuContent
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
v-bind="{ ...$attrs, ...forwarded }" v-bind="{ ...$attrs, ...forwarded }"
:class=" :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)"
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] 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',
props.class,
)
"
> >
<slot /> <slot />
</DropdownMenuContent> </DropdownMenuContent>
@@ -1,26 +1,21 @@
<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( const props = withDefaults(defineProps<DropdownMenuItemProps & {
defineProps< class?: HTMLAttributes["class"]
DropdownMenuItemProps & { inset?: boolean
class?: HTMLAttributes["class"]; variant?: "default" | "destructive"
inset?: boolean; }>(), {
variant?: "default" | "destructive";
}
>(),
{
variant: "default", 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>
@@ -29,12 +24,7 @@ const forwardedProps = useForwardProps(delegatedProps);
:data-inset="inset ? '' : undefined" :data-inset="inset ? '' : undefined"
:data-variant="variant" :data-variant="variant"
v-bind="forwardedProps" v-bind="forwardedProps"
:class=" :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)"
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 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',
props.class,
)
"
> >
<slot /> <slot />
</DropdownMenuItem> </DropdownMenuItem>
@@ -1,43 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import type { import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from "reka-ui"
DropdownMenuRadioItemEmits, import type { HTMLAttributes } from "vue"
DropdownMenuRadioItemProps, import { reactiveOmit } from "@vueuse/core"
} from "reka-ui"; import { Circle } from "lucide-vue-next"
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< const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }>()
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=" :class="cn(
cn( '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',
'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, props.class,
) )"
"
>
<span
class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"
> >
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuItemIndicator> <DropdownMenuItemIndicator>
<slot name="indicator-icon"> <slot name="indicator-icon">
<Circle class="size-2 fill-current" /> <Circle class="size-2 fill-current" />
@@ -1,33 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import type { import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui"
DropdownMenuSubContentEmits, import type { HTMLAttributes } from "vue"
DropdownMenuSubContentProps, import { reactiveOmit } from "@vueuse/core"
} from "reka-ui"; import {
import type { HTMLAttributes } from "vue"; DropdownMenuSubContent,
import { reactiveOmit } from "@vueuse/core"; useForwardPropsEmits,
import { DropdownMenuSubContent, useForwardPropsEmits } from "reka-ui"; } from "reka-ui"
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils"
const props = defineProps< const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }>()
DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] } const emits = defineEmits<DropdownMenuSubContentEmits>()
>();
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=" :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)"
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] 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',
props.class,
)
"
> >
<slot /> <slot />
</DropdownMenuSubContent> </DropdownMenuSubContent>
@@ -1,32 +1,28 @@
<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 { DropdownMenuSubTrigger, useForwardProps } from "reka-ui"; import {
import { cn } from "@/lib/utils"; DropdownMenuSubTrigger,
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps< const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
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=" :class="cn(
cn( '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',
'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, props.class,
) )"
"
> >
<slot /> <slot />
<ChevronRight class="ml-auto size-4" /> <ChevronRight class="ml-auto size-4" />
+16 -21
View File
@@ -1,38 +1,33 @@
<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"
:data-invalid="invalid ? '' : undefined" :class="cn(
:aria-invalid="invalid ? 'true' : 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',
:class=" 'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
cn( 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
'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, props.class,
) )"
" >
/>
</template> </template>
@@ -1,31 +1,31 @@
<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>
@@ -33,25 +33,16 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
<SelectContent <SelectContent
data-slot="select-content" data-slot="select-content"
v-bind="{ ...$attrs, ...forwarded }" v-bind="{ ...$attrs, ...forwarded }"
:class=" :class="cn(
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 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
'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', position === 'popper'
position === 'popper' && && 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
'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, props.class,
) )
" "
> >
<SelectScrollUpButton /> <SelectScrollUpButton />
<SelectViewport <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')">
:class="
cn(
'p-1',
position === 'popper' &&
'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1',
)
"
>
<slot /> <slot />
</SelectViewport> </SelectViewport>
<SelectScrollDownButton /> <SelectScrollDownButton />
@@ -1,23 +1,21 @@
<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< const props = defineProps<SelectItemProps & { class?: HTMLAttributes["class"] }>()
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>
@@ -26,14 +24,12 @@ const forwardedProps = useForwardProps(delegatedProps);
v-bind="forwardedProps" v-bind="forwardedProps"
:class=" :class="
cn( cn(
'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', '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',
props.class, props.class,
) )
" "
> >
<span <span class="absolute right-2 flex size-3.5 items-center justify-center">
class="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectItemIndicator> <SelectItemIndicator>
<slot name="indicator-icon"> <slot name="indicator-icon">
<Check class="size-4" /> <Check class="size-4" />
@@ -1,45 +1,33 @@
<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< defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"], size?: "sm" | "default" }>(),
SelectTriggerProps & { { size: "default" },
class?: HTMLAttributes["class"]; )
size?: "sm" | "default";
invalid?: boolean;
}
>(),
{ size: "default", invalid: false },
);
const delegatedProps = reactiveOmit(props, "class", "size", "invalid"); const delegatedProps = reactiveOmit(props, "class", "size")
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"
:data-invalid="invalid ? '' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
v-bind="forwardedProps" v-bind="forwardedProps"
:class=" :class="cn(
cn( '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',
'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',
props.class, props.class,
) )"
"
> >
<slot /> <slot />
<SelectIcon as-child> <SelectIcon as-child>
<ChevronDown <ChevronDown class="size-4 opacity-50" />
class="size-4 transition-transform duration-150 group-data-[state=open]/select-trigger:rotate-180"
/>
</SelectIcon> </SelectIcon>
</SelectTrigger> </SelectTrigger>
</template> </template>
@@ -1,38 +1,34 @@
<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>
@@ -40,30 +36,26 @@ const { t } = useLocalization();
<SheetOverlay /> <SheetOverlay />
<DialogContent <DialogContent
data-slot="sheet-content" data-slot="sheet-content"
:class=" :class="cn(
cn(
'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', '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',
side === 'right' && side === 'right'
'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-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 === 'left' && side === 'left'
'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-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 === 'top' && side === 'top'
'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-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
side === 'bottom' && side === 'bottom'
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t', && 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
props.class, props.class)"
)
"
v-bind="{ ...$attrs, ...forwarded }" v-bind="{ ...$attrs, ...forwarded }"
@close-auto-focus="restoreFocus"
> >
<slot /> <slot />
<DialogClose <DialogClose
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" 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"
> >
<X class="size-4" /> <X class="size-4" />
<span class="sr-only">{{ t("common.close") }}</span> <span class="sr-only">Close</span>
</DialogClose> </DialogClose>
</DialogContent> </DialogContent>
</DialogPortal> </DialogPortal>
+20 -42
View File
@@ -1,69 +1,51 @@
<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 <div
v-if="collapsible === 'none'" v-if="collapsible === 'none'"
data-slot="sidebar" data-slot="sidebar"
:class=" :class="cn('bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col', props.class)"
cn(
'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',
props.class,
)
"
v-bind="$attrs" v-bind="$attrs"
> >
<slot /> <slot />
</div> </div>
<Sheet v-else-if="isMobile" :open="openMobile" @update:open="setOpenMobile"> <Sheet v-else-if="isMobile" :open="openMobile" v-bind="$attrs" @update:open="setOpenMobile">
<SheetContent <SheetContent
v-bind="$attrs"
data-sidebar="sidebar" data-sidebar="sidebar"
data-slot="sidebar" data-slot="sidebar"
data-mobile="true" data-mobile="true"
:side="side" :side="side"
class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) max-w-[calc(100vw-2rem)] gap-0 p-0 [&>button]:hidden" class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
@close-auto-focus="restoreTriggerFocus"
:style="{ :style="{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE, '--sidebar-width': SIDEBAR_WIDTH_MOBILE,
}" }"
> >
<SheetHeader class="sr-only"> <SheetHeader class="sr-only">
<SheetTitle>{{ t("navigation.main") }}</SheetTitle> <SheetTitle>Sidebar</SheetTitle>
<SheetDescription>{{ <SheetDescription>Displays the mobile sidebar.</SheetDescription>
t("navigation.mobileDescription")
}}</SheetDescription>
</SheetHeader> </SheetHeader>
<div class="flex min-h-0 h-full w-full flex-col"> <div class="flex h-full w-full flex-col">
<slot /> <slot />
</div> </div>
</SheetContent> </SheetContent>
@@ -80,20 +62,17 @@ function restoreTriggerFocus(event: Event) {
> >
<!-- This is what handles the sidebar gap on desktop --> <!-- This is what handles the sidebar gap on desktop -->
<div <div
:class=" :class="cn(
cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear', 'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0', 'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180', 'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset' variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]' ? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)', : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
) )"
"
/> />
<div <div
:class=" :class="cn(
cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex', 'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left' side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]' ? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
@@ -103,8 +82,7 @@ function restoreTriggerFocus(event: Event) {
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]' ? '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', : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
props.class, props.class,
) )"
"
v-bind="$attrs" v-bind="$attrs"
> >
<div <div
@@ -1,22 +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="sidebar-content" data-slot="sidebar-content"
data-sidebar="content" data-sidebar="content"
:class=" :class="cn('flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden', props.class)"
cn(
'flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto',
props.class,
)
"
> >
<slot /> <slot />
</div> </div>
@@ -1,22 +1,20 @@
<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=" :class="cn(
cn( 'bg-background relative flex w-full flex-1 flex-col',
'bg-background relative flex min-w-0 w-full flex-1 flex-col',
'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', '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, props.class,
) )"
"
> >
<slot /> <slot />
</main> </main>
@@ -1,42 +1,30 @@
<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 { import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
Tooltip, import SidebarMenuButtonChild from "./SidebarMenuButtonChild.vue"
TooltipContent, import { useSidebar } from "./utils"
TooltipTrigger,
} from "@/components/ui/tooltip";
import SidebarMenuButtonChild from "./SidebarMenuButtonChild.vue";
import { useSidebar } from "./utils";
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}); })
const props = withDefaults( const props = withDefaults(defineProps<SidebarMenuButtonProps & {
defineProps< tooltip?: string | Component
SidebarMenuButtonProps & { }>(), {
tooltip?: string | Component;
}
>(),
{
as: "button", as: "button",
variant: "default", variant: "default",
size: "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 <SidebarMenuButtonChild v-if="!tooltip" v-bind="{ ...delegatedProps, ...$attrs }">
v-if="!tooltip || isMobile || state !== 'collapsed'"
v-bind="{ ...delegatedProps, ...$attrs }"
>
<slot /> <slot />
</SidebarMenuButtonChild> </SidebarMenuButtonChild>
@@ -1,83 +1,58 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes, Ref } from "vue"; import type { HTMLAttributes, Ref } from "vue"
import { import { defaultDocument, useEventListener, useMediaQuery, useVModel } from "@vueuse/core"
defaultDocument, import { TooltipProvider } from "reka-ui"
useEventListener, import { computed, ref } from "vue"
useMediaQuery, import { cn } from "@/lib/utils"
useVModel, import { provideSidebarContext, SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, SIDEBAR_KEYBOARD_SHORTCUT, SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON } from "./utils"
} 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( const props = withDefaults(defineProps<{
defineProps<{ defaultOpen?: boolean
defaultOpen?: boolean; open?: boolean
open?: boolean; class?: HTMLAttributes["class"]
class?: HTMLAttributes["class"]; }>(), {
}>(), defaultOpen: !defaultDocument?.cookie.includes(`${SIDEBAR_COOKIE_NAME}=false`),
{
defaultOpen: !defaultDocument?.cookie.includes(
`${SIDEBAR_COOKIE_NAME}=false`,
),
open: undefined, open: undefined,
}, })
);
const emits = defineEmits<{ const emits = defineEmits<{
"update:open": [open: boolean]; "update:open": [open: boolean]
}>(); }>()
const isMobile = useMediaQuery("(width < 768px)"); const isMobile = useMediaQuery("(max-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 return isMobile.value ? setOpenMobile(!openMobile.value) : setOpen(!open.value)
? setOpenMobile(!openMobile.value)
: setOpen(!open.value);
} }
useEventListener("keydown", (event: KeyboardEvent) => { useEventListener("keydown", (event: KeyboardEvent) => {
if ( if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.key === SIDEBAR_KEYBOARD_SHORTCUT && event.preventDefault()
(event.metaKey || event.ctrlKey) toggleSidebar()
) {
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,
@@ -87,7 +62,7 @@ provideSidebarContext({
openMobile, openMobile,
setOpenMobile, setOpenMobile,
toggleSidebar, toggleSidebar,
}); })
</script> </script>
<template> <template>
@@ -98,12 +73,7 @@ provideSidebarContext({
'--sidebar-width': SIDEBAR_WIDTH, '--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON, '--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
}" }"
:class=" :class="cn('group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full', props.class)"
cn(
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
props.class,
)
"
v-bind="$attrs" v-bind="$attrs"
> >
<slot /> <slot />
@@ -1,17 +1,15 @@
<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, openMobile, toggleSidebar } = useSidebar(); const { isMobile, state, toggleSidebar } = useSidebar()
const { t } = useLocalization();
</script> </script>
<template> <template>
@@ -20,25 +18,11 @@ const { t } = useLocalization();
data-slot="sidebar-trigger" data-slot="sidebar-trigger"
variant="ghost" variant="ghost"
size="icon" size="icon"
type="button" :class="cn('h-7 w-7', props.class)"
aria-controls="app-navigation"
:aria-expanded="isMobile ? openMobile : state === 'expanded'"
:aria-label="
isMobile
? t(openMobile ? 'navigation.close' : 'navigation.open')
: t(
state === 'collapsed'
? 'navigation.expand'
: 'navigation.collapse',
)
"
:class="cn('size-control', props.class)"
@click="toggleSidebar" @click="toggleSidebar"
> >
<PanelLeftOpen <PanelLeftOpen v-if="isMobile || state === 'collapsed'" />
v-if="isMobile || state === 'collapsed'" <PanelLeftClose v-else />
aria-hidden="true" <span class="sr-only">Toggle sidebar</span>
/>
<PanelLeftClose v-else aria-hidden="true" />
</Button> </Button>
</template> </template>
+10 -11
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,12 +11,11 @@ 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 = "4rem"; export const SIDEBAR_WIDTH_ICON = "3rem"
export const SIDEBAR_KEYBOARD_SHORTCUT = "b"; export const SIDEBAR_KEYBOARD_SHORTCUT = "b"
export const [useSidebar, provideSidebarContext] = export const [useSidebar, provideSidebarContext] = createContext<SidebarContext>("Sidebar")
createContext<SidebarContext>("Sidebar");
+6 -12
View File
@@ -1,23 +1,17 @@
<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 { computed } from "vue"; import { cn } from "@/lib/utils"
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="accessibleLabel" aria-label="Loading"
:class="cn('size-4 animate-spin', props.class)" :class="cn('size-4 animate-spin', props.class)"
/> />
</template> </template>
@@ -1,25 +0,0 @@
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),
),
};
}
+98 -81
View File
@@ -1,107 +1,124 @@
import type { App, ComputedRef, InjectionKey, Ref } from 'vue'; import type { ComputedRef, Ref } from 'vue';
import { computed, inject, readonly, ref } from 'vue'; import { computed, onMounted, 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: Readonly<Ref<Appearance>>; appearance: Ref<Appearance>;
resolvedAppearance: ComputedRef<ResolvedAppearance>; resolvedAppearance: ComputedRef<ResolvedAppearance>;
updateAppearance: (value: Appearance) => void; updateAppearance: (value: Appearance) => void;
}; };
const appearanceKey: InjectionKey<UseAppearanceReturn> = Symbol('appearance'); export function updateTheme(value: Appearance): void {
export function normalizeAppearance(value: unknown): Appearance {
return value === 'light' || value === 'dark' ? value : 'system';
}
/** 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(
'dark',
resolvedAppearance.value === 'dark',
);
document.documentElement.style.colorScheme = resolvedAppearance.value;
document.documentElement.dataset.appearance = appearance.value;
}
function updateAppearance(value: Appearance): void {
appearance.value = normalizeAppearance(value);
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return; return;
} }
applyTheme(); if (value === 'system') {
try { const mediaQueryList = window.matchMedia(
window.localStorage.setItem('appearance', appearance.value); '(prefers-color-scheme: dark)',
} catch { );
// Private or embedded browsers may deny storage; the current theme still works. const systemTheme = mediaQueryList.matches ? 'dark' : 'light';
document.documentElement.classList.toggle(
'dark',
systemTheme === 'dark',
);
} else {
document.documentElement.classList.toggle('dark', value === 'dark');
} }
document.cookie = `appearance=${appearance.value};path=/;max-age=31536000;SameSite=Lax`;
} }
function handleSystemChange(event: MediaQueryListEvent): void { const setCookie = (name: string, value: string, days = 365) => {
systemDark.value = event.matches; if (typeof document === 'undefined') {
applyTheme(); return;
} }
function handleStorageChange(event: StorageEvent): void { const maxAge = days * 24 * 60 * 60;
if (event.key === 'appearance' || event.key === null) {
updateAppearance(normalizeAppearance(event.newValue)); document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`;
};
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;
});
function updateAppearance(value: Appearance) {
appearance.value = value;
// Store in localStorage for client-side persistence...
localStorage.setItem('appearance', value);
// Store in cookie for SSR...
setCookie('appearance', value);
updateTheme(value);
} }
return { return {
appearance: readonly(appearance), 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;
}
-154
View File
@@ -1,154 +0,0 @@
import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue';
import type { TicketModel } from '@/types/models/ticket-model';
/** Pointer capture keeps mouse, pen and touch drags on the same code path. */
export function useBoardDrag(options: {
canStart: (ticket: TicketModel) => boolean;
drop: (ticket: TicketModel, columnId: number) => void;
}) {
const ticket = shallowRef<TicketModel | null>(null);
const active = ref(false);
const x = ref(0);
const y = ref(0);
const columnId = ref<number | null>(null);
let pointerId: number | null = null;
let handle: HTMLElement | null = null;
let startX = 0;
let startY = 0;
let frame = 0;
const draggedTicket = computed(() => (active.value ? ticket.value : null));
function locateTarget(): void {
const target = document
.elementFromPoint(x.value, y.value)
?.closest<HTMLElement>('[data-board-drop-column]');
columnId.value = target ? Number(target.dataset.boardDropColumn) : null;
}
function scroll(): void {
if (!active.value) {
return;
}
const element = document.elementFromPoint(x.value, y.value);
const region = element?.closest<HTMLElement>(
'[data-board-drag-scroll]',
);
if (region) {
const bounds = region.getBoundingClientRect();
const left = Math.max(0, bounds.left);
const right = Math.min(window.innerWidth, bounds.right);
const delta =
x.value < left + 44 ? -10 : x.value > right - 44 ? 10 : 0;
region.scrollLeft += delta;
}
if (!element?.closest('[data-board-mobile-targets]')) {
const delta =
y.value < 64 ? -10 : y.value > window.innerHeight - 64 ? 10 : 0;
window.scrollBy(0, delta);
}
locateTarget();
frame = requestAnimationFrame(scroll);
}
function cancel(): void {
const previousPointer = pointerId;
pointerId = null;
active.value = false;
ticket.value = null;
columnId.value = null;
cancelAnimationFrame(frame);
if (
previousPointer !== null &&
handle?.hasPointerCapture(previousPointer)
) {
handle.releasePointerCapture(previousPointer);
}
handle = null;
}
function start(event: PointerEvent, source: TicketModel): void {
if (
pointerId !== null ||
!event.isPrimary ||
event.button !== 0 ||
!options.canStart(source)
) {
return;
}
handle = event.currentTarget as HTMLElement;
handle.focus({ preventScroll: true });
handle.setPointerCapture(event.pointerId);
pointerId = event.pointerId;
ticket.value = source;
startX = x.value = event.clientX;
startY = y.value = event.clientY;
}
function move(event: PointerEvent): void {
if (event.pointerId !== pointerId) {
return;
}
x.value = event.clientX;
y.value = event.clientY;
if (
!active.value &&
Math.hypot(x.value - startX, y.value - startY) >= 6
) {
active.value = true;
frame = requestAnimationFrame(scroll);
}
if (active.value) {
event.preventDefault();
locateTarget();
}
}
function finish(event: PointerEvent): void {
if (event.pointerId !== pointerId) {
return;
}
x.value = event.clientX;
y.value = event.clientY;
locateTarget();
const source = draggedTicket.value;
const target = columnId.value;
cancel();
if (source && target !== null) {
options.drop(source, target);
}
}
function cancelPointer(event: PointerEvent): void {
if (event.pointerId === pointerId) {
cancel();
}
}
function keydown(event: KeyboardEvent): void {
if (event.key === 'Escape' && pointerId !== null) {
event.preventDefault();
cancel();
}
}
onMounted(() => {
document.addEventListener('pointermove', move, { passive: false });
document.addEventListener('pointerup', finish);
document.addEventListener('pointercancel', cancelPointer);
document.addEventListener('lostpointercapture', cancelPointer);
document.addEventListener('keydown', keydown);
window.addEventListener('blur', cancel);
});
onUnmounted(() => {
cancel();
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', finish);
document.removeEventListener('pointercancel', cancelPointer);
document.removeEventListener('lostpointercapture', cancelPointer);
document.removeEventListener('keydown', keydown);
window.removeEventListener('blur', cancel);
});
return { draggedTicket, columnId, x, y, start, cancel };
}
+30 -12
View File
@@ -3,7 +3,6 @@ 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>>;
@@ -23,21 +22,40 @@ export type UseCurrentUrlReturn = {
) => T | F; ) => T | F;
}; };
export function useCurrentUrl(): UseCurrentUrlReturn {
const page = usePage(); const page = usePage();
const currentUrlReactive = computed( const currentUrlReactive = computed(
() => new URL(page.url, 'http://localhost').pathname, () =>
new URL(
page.url,
typeof window !== 'undefined'
? window.location.origin
: 'http://localhost',
).pathname,
); );
export function useCurrentUrl(): UseCurrentUrlReturn {
function isCurrentUrl( function isCurrentUrl(
urlToCheck: NonNullable<InertiaLinkProps['href']>, urlToCheck: NonNullable<InertiaLinkProps['href']>,
currentUrl?: string, currentUrl?: string,
startsWith: boolean = false, startsWith: boolean = false,
) { ) {
return matchesNavigationUrl( const urlToCompare = currentUrl ?? currentUrlReactive.value;
toUrl(urlToCheck), const urlString = toUrl(urlToCheck);
currentUrl ?? currentUrlReactive.value,
startsWith, const comparePath = (path: string): boolean =>
); 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(
@@ -47,11 +65,11 @@ export function useCurrentUrl(): UseCurrentUrlReturn {
return isCurrentUrl(urlToCheck, currentUrl, true); return isCurrentUrl(urlToCheck, currentUrl, true);
} }
function whenCurrentUrl<T, F = null>( function whenCurrentUrl(
urlToCheck: NonNullable<InertiaLinkProps['href']>, urlToCheck: NonNullable<InertiaLinkProps['href']>,
ifTrue: T, ifTrue: any,
ifFalse: F = null as F, ifFalse: any = null,
): T | F { ) {
return isCurrentUrl(urlToCheck) ? ifTrue : ifFalse; return isCurrentUrl(urlToCheck) ? ifTrue : ifFalse;
} }
@@ -1,34 +0,0 @@
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 };
}
@@ -1,122 +0,0 @@
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 };
}
@@ -1,12 +0,0 @@
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;
}
@@ -1,42 +0,0 @@
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 };
}
+1 -16
View File
@@ -3,8 +3,6 @@ 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 = {
@@ -14,27 +12,14 @@ 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="min-w-0"> <AppContent variant="sidebar" class="overflow-x-hidden">
<AppSidebarHeader :breadcrumbs="breadcrumbs" /> <AppSidebarHeader :breadcrumbs="breadcrumbs" />
<ProjectNavigation />
<div
id="main-content"
tabindex="-1"
class="flex min-w-0 flex-1 flex-col"
>
<slot /> <slot />
</div>
</AppContent> </AppContent>
</AppShell> </AppShell>
</template> </template>
@@ -1,73 +0,0 @@
<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>
+17 -27
View File
@@ -1,11 +1,9 @@
<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';
@@ -14,29 +12,28 @@ 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 { t } = useLocalization(); const sidebarNavItems: NavItem[] = [
const sidebarNavItems = computed<NavItem[]>(() => [
{ {
title: t('navigation.profile'), title: 'Profile',
href: editProfile(), href: editProfile(),
}, },
{ {
title: t('navigation.security'), title: 'Security',
href: editSecurity(), href: editSecurity(),
}, },
{ {
title: t('navigation.gitConnections'), title: 'Git connections',
href: editGitConnections(), href: editGitConnections(),
}, },
{ {
title: t('navigation.applications'), title: 'Applications',
href: editApplications(), href: editApplications(),
}, },
{ {
title: t('navigation.appearance'), title: 'Appearance',
href: editAppearance(), href: editAppearance(),
}, },
]); ];
const { isCurrentOrParentUrl } = useCurrentUrl(); const { isCurrentOrParentUrl } = useCurrentUrl();
</script> </script>
@@ -44,34 +41,27 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
<template> <template>
<div class="px-4 py-6"> <div class="px-4 py-6">
<Heading <Heading
:title="t('navigation.settings')" title="Settings"
:description="t('navigation.settingsDescription')" description="Manage your profile, security, and Git integration settings"
/> />
<div class="flex min-w-0 flex-col gap-6 lg:flex-row lg:gap-12"> <div class="flex flex-col lg:flex-row lg:space-x-12">
<aside class="w-full min-w-0 lg:w-52 lg:shrink-0"> <aside class="w-full max-w-xl lg:w-48">
<nav <nav
class="flex gap-1 overflow-x-auto lg:flex-col" class="flex flex-col space-x-0 space-y-1"
:aria-label="t('navigation.settings')" aria-label="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="[
'shrink-0 justify-start lg:w-full', 'w-full justify-start',
{ 'bg-muted': isCurrentOrParentUrl(item.href) }, { 'bg-muted': isCurrentOrParentUrl(item.href) },
]" ]"
as-child as-child
> >
<Link <Link :href="item.href">
: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>
@@ -79,9 +69,9 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
</nav> </nav>
</aside> </aside>
<Separator class="lg:hidden" /> <Separator class="my-6 lg:hidden" />
<div class="min-w-0 flex-1 md:max-w-2xl"> <div class="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
@@ -1,42 +0,0 @@
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
@@ -1,159 +0,0 @@
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
@@ -1,166 +0,0 @@
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
@@ -1,20 +0,0 @@
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}`;
}
-367
View File
@@ -1,367 +0,0 @@
export const en = {
'board.title': '{name} board',
'board.description':
'Work in your configured workflow. Tickets without a board column stay in the backlog.',
'board.columns': 'Workflow columns',
'board.selectColumn': 'Choose a column',
'board.columnOption': '{name} ({count})',
'board.scrollHint': 'Scroll horizontally to explore all columns.',
'board.openBacklog': 'Open backlog',
'board.backToProject': 'Project overview',
'board.empty.title': 'No workflow columns yet',
'board.empty.description':
'Set up columns to start tracking work on this board.',
'board.emptyColumn': 'No tickets in this column.',
'board.emptyStatus': 'No tickets in this status.',
'board.noStatuses': 'No statuses configured for this column.',
'board.moveTicket': 'Move {key}',
'board.updateStatus': 'Update status',
'board.updatingStatus': 'Updating…',
'board.noMoves': 'No allowed moves',
'board.drag': 'Drag to move',
'board.dragTicket': 'Drag {key} to change status',
'board.dragHint':
'Drag a handle to a highlighted column, or use the status controls. Press Escape to cancel.',
'board.dropHint': 'Drop on a column. Hold near an edge to scroll.',
'board.dragging':
'Dragging {key}. Drop on an allowed column or press Escape to cancel.',
'board.chooseStatus': 'Choose a destination status',
'board.chooseStatusDescription':
'More than one status is available for {key} in this column.',
'board.targetStatus': 'Destination status',
'board.noMovesDescription':
'This status does not allow moving anywhere else.',
'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;
+25 -189
View File
@@ -1,211 +1,47 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import { import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
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 { index as projectsIndex, show as showProject } from '@/routes/projects'; import type { BreadcrumbItem } from '@/types';
import type { BreadcrumbItem, DashboardPageProps } from '@/types';
const props = defineProps<DashboardPageProps>(); const breadcrumbs: BreadcrumbItem[] = [
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(() => [
{ {
label: t('dashboard.summary.assignedTickets'), title: 'Dashboard',
value: assignedTickets.value.length, href: dashboard(),
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">
<Head :title="t('navigation.dashboard')" /> <div
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">
<header class="gap-stack grid min-w-0">
<p class="text-label text-muted-foreground uppercase">
{{ t('dashboard.eyebrow') }}
</p>
<h1 class="text-headline-mobile md:text-headline break-words">
{{
t('dashboard.greeting', {
name: $page.props.auth.user.name,
})
}}
</h1>
<p class="text-muted-foreground max-w-2xl">
{{ t('dashboard.description') }}
</p>
</header>
<section :aria-label="t('dashboard.summary')">
<ul class="grid gap-3 sm:grid-cols-3">
<li
v-for="item in summaryItems"
:key="item.label"
class="min-w-0"
> >
<Card class="h-full gap-0 rounded-md py-0"> <div class="grid auto-rows-min gap-4 md:grid-cols-3">
<CardContent <div
class="flex min-w-0 items-center justify-between gap-4 p-4" class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
> >
<div class="min-w-0"> <PlaceholderPattern />
<p
class="text-caption text-muted-foreground break-words"
>
{{ item.label }}
</p>
<p class="text-title mt-1 tabular-nums">
{{ item.value }}
</p>
</div> </div>
<span <div
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-md" class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
> >
<component <PlaceholderPattern />
:is="item.icon" </div>
class="size-5" <div
aria-hidden="true" class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
/>
</span>
</CardContent>
</Card>
</li>
</ul>
</section>
<section
aria-labelledby="assigned-work-title"
class="gap-column grid min-w-0"
> >
<header class="gap-stack grid min-w-0"> <PlaceholderPattern />
<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>
</div> </div>
<Button <div
as-child class="border-sidebar-border/70 dark:border-sidebar-border relative min-h-[100vh] flex-1 rounded-xl border md:min-h-min"
variant="link"
class="self-start sm:self-auto"
> >
<Link <PlaceholderPattern />
: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> </div>
</section>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
+28 -53
View File
@@ -1,77 +1,52 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3'; import { Form, Head } 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 { useFormProtection } from '@/composables/useFormProtection'; import { Spinner } from '@/components/ui/spinner';
import { useLocalization } from '@/composables/useLocalization'; import AuthLayout from '@/layouts/AuthLayout.vue';
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>
<AuthFlowLayout <AuthLayout
:title="t('auth.confirm.title')" title="Confirm your password"
:description="t('auth.confirm.description')" description="This is a secure area of the application. Please confirm your password before continuing."
:icon="ShieldCheck"
> >
<Head :title="t('auth.confirm.title')" /> <Head title="Confirm password" />
<form
:action="store.url()" <Form
method="post" v-bind="store.form()"
class="grid gap-5" reset-on-success
@submit.prevent="submit" v-slot="{ errors, processing }"
> >
<AuthFormFeedback <div class="space-y-6">
:state="protection.state.value" <div class="grid gap-2">
:response-status="protection.responseStatus.value" <Label htmlFor="password">Password</Label>
:loading-title="t('auth.confirm.pending')"
@retry="submit"
/>
<div class="gap-stack grid">
<Label for="password">{{ t('auth.confirm.password') }}</Label>
<PasswordInput <PasswordInput
id="password" id="password"
v-model="form.password"
name="password" name="password"
class="mt-1 block w-full"
required required
autocomplete="current-password" autocomplete="current-password"
autofocus 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.confirm.passwordHint') }}
</p>
<InputError
id="password-error"
:message="form.errors.password"
/> />
<InputError :message="errors.password" />
</div> </div>
<div class="flex items-center">
<Button <Button
type="submit"
class="w-full" class="w-full"
:loading="form.processing" :disabled="processing"
:loading-label="t('auth.confirm.pending')"
data-test="confirm-password-button" data-test="confirm-password-button"
>{{ t('auth.confirm.submit') }}</Button
> >
</form> <Spinner v-if="processing" />
<template #footer>{{ t('auth.confirm.footer') }}</template> Confirm password
</AuthFlowLayout> </Button>
</div>
</div>
</Form>
</AuthLayout>
</template> </template>
+74 -129
View File
@@ -1,170 +1,115 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3'; import { Form, Head, Link } from '@inertiajs/vue3';
import { KeyRound } from 'lucide-vue-next'; import { LogOut } 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 { useFormProtection } from '@/composables/useFormProtection'; import { Spinner } from '@/components/ui/spinner';
import { useLocalization } from '@/composables/useLocalization'; import AuthLayout from '@/layouts/AuthLayout.vue';
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue'; import { logout } from '@/routes';
import { logout } from '@/routes/index';
withDefaults(defineProps<{ status?: string | null }>(), { status: null }); withDefaults(
const { t } = useLocalization(); defineProps<{
const form = useForm({ status?: string | null;
current_password: '', }>(),
password: '', {
password_confirmation: '', status: null,
}).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>
<AuthFlowLayout <AuthLayout
:title="t('auth.forceChange.title')" title="Change your password"
:description="t('auth.forceChange.description')" description="You must update your password before you can continue using the application."
:icon="KeyRound"
> >
<Head :title="t('auth.forceChange.title')" /> <Head title="Change your password" />
<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"
state="success" class="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900"
:title="t('auth.forceChange.statusTitle')"
:description="status"
/>
<form
:action="SecurityController.update.url()"
method="post"
class="grid gap-5"
@submit.prevent="submit"
> >
<AuthFormFeedback {{ status }}
:state="protection.state.value" </div>
:response-status="protection.responseStatus.value"
:loading-title="t('auth.forceChange.pending')" <Form
@retry="submit" v-bind="SecurityController.update.form()"
/> reset-on-success
<div class="gap-stack grid"> :reset-on-error="[
<Label for="current_password">{{ 'password',
t('auth.forceChange.currentPassword') 'password_confirmation',
}}</Label> 'current_password',
]"
v-slot="{ errors, processing }"
>
<div class="space-y-6">
<div class="grid gap-2">
<Label htmlFor="current_password">Current password</Label>
<PasswordInput <PasswordInput
id="current_password" id="current_password"
v-model="form.current_password"
name="current_password" name="current_password"
class="mt-1 block w-full"
required required
autocomplete="current-password" autocomplete="current-password"
autofocus autofocus
:disabled="busy" placeholder="Current password"
:invalid="Boolean(form.errors.current_password)"
aria-describedby="current-password-error"
/>
<InputError
id="current-password-error"
:message="form.errors.current_password"
/> />
<InputError :message="errors.current_password" />
</div> </div>
<div class="gap-stack grid">
<Label for="password">{{ <div class="grid gap-2">
t('auth.forceChange.password') <Label htmlFor="password">New password</Label>
}}</Label>
<PasswordInput <PasswordInput
id="password" id="password"
v-model="form.password"
name="password" name="password"
class="mt-1 block w-full"
required required
autocomplete="new-password" autocomplete="new-password"
:disabled="busy" placeholder="New password"
: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"
/> />
<InputError :message="errors.password" />
</div> </div>
<div class="gap-stack grid">
<Label for="password_confirmation">{{ <div class="grid gap-2">
t('auth.forceChange.passwordConfirmation') <Label htmlFor="password_confirmation">
}}</Label> Confirm new password
</Label>
<PasswordInput <PasswordInput
id="password_confirmation" id="password_confirmation"
v-model="form.password_confirmation"
name="password_confirmation" name="password_confirmation"
class="mt-1 block w-full"
required required
autocomplete="new-password" autocomplete="new-password"
:disabled="busy" placeholder="Confirm new password"
:invalid="Boolean(form.errors.password_confirmation)"
aria-describedby="new-password-confirmation-error"
/>
<InputError
id="new-password-confirmation-error"
:message="form.errors.password_confirmation"
/> />
<InputError :message="errors.password_confirmation" />
</div> </div>
<div class="flex items-center">
<Button <Button
type="submit"
class="w-full" class="w-full"
:disabled="logoutForm.processing" :disabled="processing"
:loading="form.processing"
:loading-label="t('auth.forceChange.pending')"
data-test="force-password-change-button" data-test="force-password-change-button"
>{{ t('auth.forceChange.submit') }}</Button
> >
</form> <Spinner v-if="processing" />
<div class="gap-stack pt-column grid border-t"> Save new password
<p class="text-body text-muted-foreground"> </Button>
{{ 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> </div>
<template #footer>{{ t('auth.forceChange.footer') }}</template> </div>
</AuthFlowLayout> </Form>
</AuthLayout>
</template> </template>
+39 -63
View File
@@ -1,89 +1,65 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3'; import { Form, Head } 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 { useFormProtection } from '@/composables/useFormProtection'; import { Spinner } from '@/components/ui/spinner';
import { useLocalization } from '@/composables/useLocalization'; import AuthLayout from '@/layouts/AuthLayout.vue';
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<{ status?: string }>(); defineProps<{
const { t } = useLocalization(); status?: string;
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>
<AuthFlowLayout <AuthLayout
:title="t('auth.forgot.title')" title="Forgot password"
:description="t('auth.forgot.description')" description="Enter your email to receive a password reset link"
:icon="Mail"
> >
<Head :title="t('auth.forgot.title')" /> <Head title="Forgot password" />
<FeedbackState
<div
v-if="status" v-if="status"
state="success" class="mb-4 text-center text-sm font-medium text-green-600"
:title="t('auth.forgot.sentTitle')"
:description="status"
/>
<form
:action="email.url()"
method="post"
class="grid gap-5"
@submit.prevent="submit"
> >
<AuthFormFeedback {{ status }}
:state="protection.state.value" </div>
:response-status="protection.responseStatus.value"
:loading-title="t('auth.forgot.pending')" <div class="space-y-6">
@retry="submit" <Form v-bind="email.form()" v-slot="{ errors, processing }">
/> <div class="grid gap-2">
<div class="gap-stack grid"> <Label for="email">Email address</Label>
<Label for="email">{{ t('auth.forgot.email') }}</Label>
<Input <Input
id="email" id="email"
v-model="form.email"
name="email"
type="email" type="email"
inputmode="email" name="email"
autocomplete="email" autocomplete="off"
autocapitalize="none"
:spellcheck="false"
required
autofocus autofocus
:disabled="form.processing" placeholder="email@example.com"
:placeholder="t('auth.forgot.emailPlaceholder')"
:invalid="Boolean(form.errors.email)"
aria-describedby="email-hint email-error"
/> />
<p id="email-hint" class="text-caption text-muted-foreground"> <InputError :message="errors.email" />
{{ t('auth.forgot.emailHint') }}
</p>
<InputError id="email-error" :message="form.errors.email" />
</div> </div>
<div class="my-6 flex items-center justify-start">
<Button <Button
type="submit"
class="w-full" class="w-full"
:loading="form.processing" :disabled="processing"
:loading-label="t('auth.forgot.pending')"
data-test="email-password-reset-link-button" data-test="email-password-reset-link-button"
>{{ t('auth.forgot.submit') }}</Button
> >
</form> <Spinner v-if="processing" />
<template #footer Email password reset link
><TextLink :href="login()">{{ </Button>
t('auth.backToLogin') </div>
}}</TextLink></template </Form>
>
</AuthFlowLayout> <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>
</AuthLayout>
</template> </template>
+66 -90
View File
@@ -1,9 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3'; import { Form, Head } 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';
@@ -11,114 +7,94 @@ 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 { useFormProtection } from '@/composables/useFormProtection'; import { Spinner } from '@/components/ui/spinner';
import { useLocalization } from '@/composables/useLocalization'; import AuthBase from '@/layouts/AuthLayout.vue';
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<{ status?: string; canResetPassword: boolean }>(); defineProps<{
const { t } = useLocalization(); status?: string;
// Credentials stay in memory only; no remembered form key or browser storage. canResetPassword: boolean;
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>
<AuthFlowLayout <AuthBase
:title="t('auth.login.title')" title="Log in to your account"
:description="t('auth.login.description')" description="Enter your email and password below to log in"
:icon="LogIn"
> >
<Head :title="t('auth.login.submit')" /> <Head title="Log in" />
<FeedbackState
<div
v-if="status" v-if="status"
state="success" class="mb-4 text-center text-sm font-medium text-green-600"
:title="t('auth.login.statusTitle')"
:description="status"
/>
<form
:action="store.url()"
method="post"
class="grid gap-5"
@submit.prevent="submit"
> >
<AuthFormFeedback {{ status }}
:state="protection.state.value" </div>
:response-status="protection.responseStatus.value"
:loading-title="t('auth.login.pending')" <Form
@retry="submit" v-bind="store.form()"
/> :reset-on-success="['password']"
<div class="gap-stack grid"> v-slot="{ errors, processing }"
<Label for="email">{{ t('auth.login.email') }}</Label> class="flex flex-col gap-6"
>
<div class="grid gap-6">
<div class="grid gap-2">
<Label for="email">Email address</Label>
<Input <Input
id="email" id="email"
v-model="form.email"
name="email"
type="email" type="email"
inputmode="email" name="email"
autocomplete="username"
autocapitalize="none"
:spellcheck="false"
required required
autofocus autofocus
:disabled="form.processing" :tabindex="1"
:placeholder="t('auth.login.emailPlaceholder')" autocomplete="email"
:invalid="Boolean(form.errors.email)" placeholder="email@example.com"
aria-describedby="email-error"
/> />
<InputError id="email-error" :message="form.errors.email" /> <InputError :message="errors.email" />
</div> </div>
<div class="gap-stack grid">
<Label for="password">{{ t('auth.login.password') }}</Label> <div class="grid gap-2">
<PasswordInput <div class="flex items-center justify-between">
id="password" <Label for="password">Password</Label>
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 <TextLink
v-if="canResetPassword" v-if="canResetPassword"
:href="request()" :href="request()"
class="min-h-control text-body inline-flex items-center" class="text-sm"
>{{ t('auth.login.forgot') }}</TextLink :tabindex="5"
> >
Forgot password?
</TextLink>
</div> </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 <Button
type="submit" type="submit"
class="w-full" class="mt-4 w-full"
:loading="form.processing" :tabindex="4"
:loading-label="t('auth.login.pending')" :disabled="processing"
data-test="login-button" data-test="login-button"
>{{ t('auth.login.submit') }}</Button
> >
</form> <Spinner v-if="processing" />
<template #footer>{{ t('auth.login.help') }}</template> Log in
</AuthFlowLayout> </Button>
</div>
</Form>
</AuthBase>
</template> </template>
+52 -100
View File
@@ -1,136 +1,88 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3'; import { Form, Head } from '@inertiajs/vue3';
import { KeyRound } from 'lucide-vue-next'; import { ref } from 'vue';
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 { useFormProtection } from '@/composables/useFormProtection'; import { Spinner } from '@/components/ui/spinner';
import { useLocalization } from '@/composables/useLocalization'; import AuthLayout from '@/layouts/AuthLayout.vue';
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue'; import { update } from '@/routes/password';
import { request, update } from '@/routes/password';
const props = defineProps<{ token: string; email: string }>(); const props = defineProps<{
const { t } = useLocalization(); token: string;
const form = useForm({ email: string;
token: props.token, }>();
email: props.email,
password: '', const inputEmail = ref(props.email);
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>
<AuthFlowLayout <AuthLayout
:title="t('auth.reset.title')" title="Reset password"
:description="t('auth.reset.description')" description="Please enter your new password below"
:icon="KeyRound"
> >
<Head :title="t('auth.reset.title')" /> <Head title="Reset password" />
<FeedbackState
v-if="invalidLink" <Form
state="error" v-bind="update.form()"
:title="t('auth.reset.invalidTitle')" :transform="(data) => ({ ...data, token, email })"
:description="form.errors.email" :reset-on-success="['password', 'password_confirmation']"
v-slot="{ errors, processing }"
> >
<Button as-child variant="outline" class="w-full"> <div class="grid gap-6">
<Link :href="request()">{{ t('auth.reset.requestNew') }}</Link> <div class="grid gap-2">
</Button> <Label for="email">Email</Label>
</FeedbackState>
<form
:action="update.url()"
method="post"
class="grid gap-5"
@submit.prevent="submit"
>
<AuthFormFeedback
:state="feedbackState"
:response-status="protection.responseStatus.value"
:loading-title="t('auth.reset.pending')"
@retry="submit"
/>
<input type="hidden" name="token" :value="form.token" />
<div class="gap-stack grid">
<Label for="email">{{ t('auth.reset.email') }}</Label>
<Input <Input
id="email" id="email"
v-model="form.email"
name="email"
type="email" type="email"
autocomplete="username" name="email"
autocomplete="email"
v-model="inputEmail"
class="mt-1 block w-full"
readonly readonly
:disabled="form.processing"
:invalid="Boolean(form.errors.email)"
aria-describedby="email-error"
/> />
<InputError id="email-error" :message="form.errors.email" /> <InputError :message="errors.email" class="mt-2" />
</div> </div>
<div class="gap-stack grid">
<Label for="password">{{ t('auth.reset.password') }}</Label> <div class="grid gap-2">
<Label for="password">Password</Label>
<PasswordInput <PasswordInput
id="password" id="password"
v-model="form.password"
name="password" name="password"
autocomplete="new-password" autocomplete="new-password"
required class="mt-1 block w-full"
autofocus autofocus
:disabled="form.processing" placeholder="Password"
: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"
/> />
<InputError :message="errors.password" />
</div> </div>
<div class="gap-stack grid">
<Label for="password_confirmation">{{ <div class="grid gap-2">
t('auth.reset.passwordConfirmation') <Label for="password_confirmation">
}}</Label> Confirm password
</Label>
<PasswordInput <PasswordInput
id="password_confirmation" id="password_confirmation"
v-model="form.password_confirmation"
name="password_confirmation" name="password_confirmation"
autocomplete="new-password" autocomplete="new-password"
required class="mt-1 block w-full"
:disabled="form.processing" placeholder="Confirm password"
:invalid="Boolean(form.errors.password_confirmation)"
aria-describedby="password-confirmation-error"
/>
<InputError
id="password-confirmation-error"
:message="form.errors.password_confirmation"
/> />
<InputError :message="errors.password_confirmation" />
</div> </div>
<Button <Button
type="submit" type="submit"
class="w-full" class="mt-4 w-full"
:loading="form.processing" :disabled="processing"
:loading-label="t('auth.reset.pending')"
data-test="reset-password-button" data-test="reset-password-button"
>{{ t('auth.reset.submit') }}</Button
> >
</form> <Spinner v-if="processing" />
<template #footer>{{ t('auth.reset.footer') }}</template> Reset password
</AuthFlowLayout> </Button>
</div>
</Form>
</AuthLayout>
</template> </template>
+107 -147
View File
@@ -1,173 +1,133 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3'; import { Form, Head } from '@inertiajs/vue3';
import { KeyRound, ShieldCheck } from 'lucide-vue-next'; import { computed, ref } from 'vue';
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 { Label } from '@/components/ui/label'; import {
import { useFormProtection } from '@/composables/useFormProtection'; InputOTP,
import { useLocalization } from '@/composables/useLocalization'; InputOTPGroup,
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue'; InputOTPSlot,
import { login } from '@/routes/index'; } from '@/components/ui/input-otp';
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 { t } = useLocalization(); const authConfigContent = computed<TwoFactorConfigContent>(() => {
const recovery = ref(false); if (showRecoveryInput.value) {
const form = useForm({ code: '', recovery_code: '' }).dontRemember( return {
'code', title: 'Recovery code',
'recovery_code', description:
); 'Please confirm access to your account by entering one of your emergency recovery codes.',
const protection = useFormProtection(form, { isDirty: () => false }); buttonText: 'login using an authentication code',
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;
form.resetAndClearErrors(); return {
protection.clearFeedback(); title: 'Authentication code',
await nextTick(); description:
(recovery.value ? recoveryInput.value : codeInput.value)?.$el?.focus(); 'Enter the authentication code provided by your authenticator application.',
buttonText: 'login using a recovery code',
}; };
const pasteCode = (event: ClipboardEvent): void => { });
const code = event.clipboardData?.getData('text').replace(/\s/g, '') ?? '';
if (/^[0-9]{6}$/.test(code)) { const showRecoveryInput = ref<boolean>(false);
event.preventDefault();
form.code = code; const toggleRecoveryMode = (clearErrors: () => void): void => {
} showRecoveryInput.value = !showRecoveryInput.value;
clearErrors();
code.value = '';
}; };
const submit = (): void => {
form.transform((data) => const code = ref<string>('');
recovery.value
? { recovery_code: data.recovery_code.trim() }
: { code: data.code },
);
protection.submit(store(), { onSuccess: () => form.reset() });
};
onUnmounted(() => form.reset());
</script> </script>
<template> <template>
<AuthFlowLayout <AuthLayout
:title=" :title="authConfigContent.title"
t( :description="authConfigContent.description"
recovery
? 'auth.twoFactor.recoveryTitle'
: 'auth.twoFactor.title',
)
"
:description="
t(
recovery
? 'auth.twoFactor.recoveryDescription'
: 'auth.twoFactor.description',
)
"
:icon="recovery ? KeyRound : ShieldCheck"
> >
<Head :title="t('auth.twoFactor.title')" /> <Head title="Two-factor authentication" />
<form
:action="store.url()" <div class="space-y-6">
method="post" <template v-if="!showRecoveryInput">
class="grid gap-5" <Form
@submit.prevent="submit" v-bind="store.form()"
class="space-y-4"
reset-on-error
@error="code = ''"
#default="{ errors, processing, clearErrors }"
> >
<AuthFormFeedback <input type="hidden" name="code" :value="code" />
:state="protection.state.value" <div
:response-status="protection.responseStatus.value" class="flex flex-col items-center justify-center space-y-3 text-center"
:loading-title="t('auth.twoFactor.pending')" >
@retry="submit" <div class="flex w-full items-center justify-center">
/> <InputOTP
<div v-if="!recovery" class="gap-stack grid"> id="otp"
<Label for="code">{{ t('auth.twoFactor.code') }}</Label> v-model="code"
<Input :maxlength="6"
id="code" :disabled="processing"
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 autofocus
:disabled="form.processing" >
:invalid="Boolean(fieldError)" <InputOTPGroup>
aria-describedby="code-hint code-error" <InputOTPSlot
class="h-14 text-center font-mono text-2xl tracking-[0.35em] md:text-2xl" v-for="index in 6"
@paste="pasteCode" :key="index"
:index="index - 1"
/> />
<p id="code-hint" class="text-caption text-muted-foreground"> </InputOTPGroup>
{{ t('auth.twoFactor.codeHint') }} </InputOTP>
</p>
<InputError id="code-error" :message="fieldError" />
</div> </div>
<div v-else class="gap-stack grid"> <InputError :message="errors.code" />
<Label for="recovery_code">{{ </div>
t('auth.twoFactor.recoveryCode') <Button type="submit" class="w-full" :disabled="processing"
}}</Label> >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>
<template v-else>
<Form
v-bind="store.form()"
class="space-y-4"
reset-on-error
#default="{ errors, processing, clearErrors }"
>
<Input <Input
id="recovery_code"
key="recovery"
ref="recoveryInput"
v-model="form.recovery_code"
name="recovery_code" name="recovery_code"
type="text" type="text"
autocomplete="one-time-code" placeholder="Enter recovery code"
autocapitalize="none" :autofocus="showRecoveryInput"
:spellcheck="false"
required required
:disabled="form.processing"
:invalid="Boolean(fieldError)"
aria-describedby="recovery-hint recovery-error"
class="font-mono"
/> />
<p <InputError :message="errors.recovery_code" />
id="recovery-hint" <Button type="submit" class="w-full" :disabled="processing"
class="text-caption text-muted-foreground" >Continue</Button
> >
{{ t('auth.twoFactor.recoveryHint') }}
</p> <div class="text-muted-foreground text-center text-sm">
<InputError id="recovery-error" :message="fieldError" /> <span>or you can </span>
</div> <button
<Button
type="submit"
class="w-full"
:loading="form.processing"
:loading-label="t('auth.twoFactor.pending')"
>{{ t('auth.twoFactor.submit') }}</Button
>
<Button
type="button" type="button"
variant="outline" class="text-foreground hover:decoration-current! underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out dark:decoration-neutral-500"
class="w-full whitespace-normal" @click="() => toggleRecoveryMode(clearErrors)"
:disabled="form.processing"
@click="toggleMode"
>{{
t(
recovery
? 'auth.twoFactor.useCode'
: 'auth.twoFactor.useRecovery',
)
}}</Button
> >
</form> {{ authConfigContent.buttonText }}
<template #footer </button>
><TextLink :href="login()">{{ </div>
t('auth.backToLogin') </Form>
}}</TextLink></template </template>
> </div>
</AuthFlowLayout> </AuthLayout>
</template> </template>
+35 -110
View File
@@ -1,124 +1,49 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, router, useForm, usePage } from '@inertiajs/vue3'; import { Form, Head } from '@inertiajs/vue3';
import { Mail } from 'lucide-vue-next'; import TextLink from '@/components/TextLink.vue';
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 { useFormProtection } from '@/composables/useFormProtection'; import { Spinner } from '@/components/ui/spinner';
import { useLocalization } from '@/composables/useLocalization'; import AuthLayout from '@/layouts/AuthLayout.vue';
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue'; import { logout } from '@/routes/index';
import { dashboard, logout } from '@/routes/index';
import { send } from '@/routes/verification'; import { send } from '@/routes/verification';
const props = defineProps<{ status?: string }>(); defineProps<{
const { t } = useLocalization(); status?: string;
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>
<AuthFlowLayout <AuthLayout
:title="t('auth.verify.title')" title="Verify email"
:description="t('auth.verify.description')" description="Please verify your email address by clicking on the link we just emailed to you."
:icon="Mail"
> >
<Head :title="t('auth.verify.title')" /> <Head title="Email verification" />
<div <div
v-if="email" v-if="status === 'verification-link-sent'"
class="gap-stack bg-muted p-card grid rounded-md border" class="mb-4 text-center text-sm font-medium text-green-600"
> >
<p class="text-label text-muted-foreground uppercase tracking-wide"> A new verification link has been sent to the email address you
{{ t('auth.verify.emailLabel') }} provided during registration.
</p>
<p class="text-body break-all font-medium">{{ email }}</p>
</div> </div>
<FeedbackState
v-if="sent" <Form
state="success" v-bind="send.form()"
:title="t('auth.verify.sentTitle')" class="space-y-6 text-center"
:description="t('auth.verify.sentDescription')" v-slot="{ processing }"
/>
<form
:action="send.url()"
method="post"
class="gap-column grid"
@submit.prevent="resend"
> >
<AuthFormFeedback <Button :disabled="processing" variant="secondary">
:state=" <Spinner v-if="processing" />
resendProtection.state.value === 'success' Resend verification email
? 'idle' </Button>
: resendProtection.state.value
" <TextLink
:response-status="resendProtection.responseStatus.value" :href="logout()"
:loading-title="t('auth.verify.pending')" as="button"
@retry="resend" class="mx-auto block text-sm"
/>
<Button
type="submit"
class="w-full"
:disabled="busy"
:loading="resendForm.processing"
:loading-label="t('auth.verify.pending')"
>{{ t('auth.verify.resend') }}</Button
> >
</form> Log out
<div class="gap-stack pt-column grid border-t"> </TextLink>
<p class="text-body text-muted-foreground"> </Form>
{{ t('auth.verify.help') }} </AuthLayout>
</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>
+122 -37
View File
@@ -2,7 +2,6 @@
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 {
@@ -13,11 +12,13 @@ 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 { create as createTicket } from '@/routes/projects/tickets'; import {
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';
@@ -39,6 +40,25 @@ 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,
@@ -112,22 +132,94 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
No backlog tickets exist yet. No backlog tickets exist yet.
</div> </div>
<ul v-else class="gap-column grid p-4"> <div v-else class="overflow-x-auto">
<TicketListItem <table
class="divide-border min-w-full divide-y text-sm"
>
<thead
class="bg-muted/40 text-muted-foreground text-left"
>
<tr>
<th class="px-4 py-3 font-medium">Key</th>
<th class="px-4 py-3 font-medium">Title</th>
<th class="px-4 py-3 font-medium">Type</th>
<th class="px-4 py-3 font-medium">
Status
</th>
<th class="px-4 py-3 font-medium">
Priority
</th>
<th
v-if="canEditTickets"
class="px-4 py-3 font-medium"
>
Move to status
</th>
</tr>
</thead>
<tbody class="divide-border divide-y">
<tr
v-for="ticket in tickets" v-for="ticket in tickets"
:key="ticket.id" :key="ticket.id"
:project-id="project.id" class="bg-background"
:ticket="ticket"
> >
<template v-if="canEditTickets" #actions> <td class="px-4 py-4">
<Form <Link
v-bind=" :href="
ProjectBoardController.move.form({ showTicket({
project: project.id, project: project.id,
ticket: ticket.id, ticket: ticket.id,
}) })
" "
class="flex w-full flex-col gap-2" >
<Badge variant="outline">
{{ 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 }" v-slot="{ processing }"
> >
<input <input
@@ -135,23 +227,14 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
name="redirect_to" name="redirect_to"
value="backlog" value="backlog"
/> />
<label
:for="`backlog-ticket-status-${ticket.id}`"
class="text-label text-muted-foreground uppercase"
>
Move to status
</label>
<div
class="flex flex-col gap-2 sm:flex-row"
>
<select <select
:id="`backlog-ticket-status-${ticket.id}`"
name="ticket_status_id" name="ticket_status_id"
:disabled=" :disabled="
allowedTargetStatuses(ticket) allowedTargetStatuses(
.length === 0 || processing ticket,
).length === 0 || processing
" "
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" 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 <option
v-if=" v-if="
@@ -173,7 +256,6 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
{{ {{
ticketStatusLabel( ticketStatusLabel(
ticketStatus, ticketStatus,
'Unknown status',
) )
}} }}
</option> </option>
@@ -183,27 +265,30 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
size="sm" size="sm"
:disabled=" :disabled="
processing || processing ||
allowedTargetStatuses(ticket) allowedTargetStatuses(
.length === 0 ticket,
).length === 0
" "
> >
Move Move
</Button> </Button>
</div>
<p <p
v-if=" v-if="
allowedTargetStatuses(ticket) allowedTargetStatuses(
.length === 0 ticket,
).length === 0
" "
class="text-caption text-muted-foreground" class="text-muted-foreground text-xs sm:basis-full"
> >
This status does not allow moving This status does not allow
anywhere else. moving anywhere else.
</p> </p>
</Form> </Form>
</template> </td>
</TicketListItem> </tr>
</ul> </tbody>
</table>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+228 -567
View File
@@ -1,248 +1,91 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, Link, useForm } from '@inertiajs/vue3'; import { Form, Head, Link } from '@inertiajs/vue3';
import { ArrowLeft, GripVertical, ListTodo, Plus } from 'lucide-vue-next'; import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
import { computed, nextTick, ref, watch } from 'vue'; import Heading from '@/components/Heading.vue';
import { move } from '@/actions/App/Http/Controllers/ProjectBoardController';
import FeedbackState from '@/components/FeedbackState.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.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 {
Dialog, Card,
DialogContent, CardContent,
DialogDescription, CardDescription,
DialogFooter, CardHeader,
DialogHeader, CardTitle,
DialogTitle, } from '@/components/ui/card';
} from '@/components/ui/dialog';
import { useBoardDrag } from '@/composables/useBoardDrag';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
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 boardIndex } from '@/routes/projects/board';
import { index as projectIndex, show } from '@/routes/projects/index'; import { index as projectIndex, show } from '@/routes/projects/index';
import { create as createTicket } from '@/routes/projects/tickets'; import { board as boardSettings } from '@/routes/projects/settings';
import { index as boardIndex } from '@/routes/projects/board';
import {
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';
const props = defineProps<ProjectBoardPageProps>(); const props = defineProps<ProjectBoardPageProps>();
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: projectIndex() },
{ title: props.project.name, href: show(props.project.id) },
{ title: t('navigation.board'), href: boardIndex(props.project.id) },
]);
const selectedColumnId = ref<number>();
watch(
() =>
[
props.project.id,
props.boardColumns.map((column) => column.id),
] as const,
([projectId, columnIds], previous) => {
if (
projectId !== previous?.[0] ||
!columnIds.includes(selectedColumnId.value ?? -1)
) {
selectedColumnId.value = columnIds[0];
}
},
{ immediate: true },
);
const columns = computed(() =>
props.boardColumns.map((column) => {
const ticketsByStatus = new Map<number | null, TicketModel[]>();
for (const ticket of column.tickets ?? []) {
const tickets = ticketsByStatus.get(ticket.ticket_status_id) ?? [];
tickets.push(ticket);
ticketsByStatus.set(ticket.ticket_status_id, tickets);
}
return {
...column,
count: column.tickets?.length ?? 0,
groups: (column.ticket_statuses ?? []).map((status) => ({
status,
tickets: ticketsByStatus.get(status.id) ?? [],
})),
};
}),
);
const ticketCount = computed(() =>
columns.value.reduce((count, column) => count + column.count, 0),
);
const allowedTargetStatuses = (ticket: TicketModel) =>
(ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(status) => status.id !== ticket.ticket_status_id,
);
const moveForm = useForm({ ticket_status_id: 0, redirect_to: 'board' }); const breadcrumbs: BreadcrumbItem[] = [
const feedback = useFormProtection(moveForm, { isDirty: () => false }); {
const menuPending = ref<number | null>(null); title: 'Projects',
const busy = computed(() => moveForm.processing || menuPending.value !== null); href: projectIndex(),
const choice = ref<{ ticketId: number; columnId: number } | null>(null); },
const lastMove = ref<{ ticketId: number; statusId: number } | null>(null); {
const findTicket = (id: number) => title: props.project.name,
props.boardColumns href: show(props.project.id),
.flatMap((column) => column.tickets ?? []) },
.find((ticket) => ticket.id === id); {
const targetsForColumn = (ticket: TicketModel, columnId: number) => { title: 'Board',
const statusIds = href: boardIndex(props.project.id),
props.boardColumns },
.find((column) => column.id === columnId) ];
?.ticket_statuses?.map((status) => status.id) ?? [];
return allowedTargetStatuses(ticket).filter((status) => const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => {
statusIds.includes(status.id), 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[] => {
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
); );
}; };
const choiceTicket = computed(() =>
choice.value ? findTicket(choice.value.ticketId) : undefined,
);
const choiceTargets = computed(() =>
choice.value && choiceTicket.value
? targetsForColumn(choiceTicket.value, choice.value.columnId)
: [],
);
const choiceOpen = computed({
get: () => choice.value !== null,
set: (open: boolean) => {
if (!open) {
choice.value = null;
}
},
});
function submitMove(ticketId: number, statusId: number): void {
const ticket = findTicket(ticketId);
if (
busy.value ||
!props.canEditTickets ||
!ticket ||
!allowedTargetStatuses(ticket).some((status) => status.id === statusId)
) {
return;
}
lastMove.value = { ticketId, statusId };
moveForm.ticket_status_id = statusId;
moveForm.clearErrors();
feedback.submit(move({ project: props.project.id, ticket: ticketId }), {
errorBag: `board-ticket-${ticketId}`,
preserveScroll: true,
onSuccess: async () => {
const destination = props.boardColumns.find((column) =>
column.ticket_statuses?.some(
(status) => status.id === statusId,
),
);
if (destination) {
selectedColumnId.value = destination.id;
}
await nextTick();
document
.getElementById(`ticket-status-${ticketId}`)
?.focus({ preventScroll: true });
},
});
}
function dropTicket(ticket: TicketModel, columnId: number): void {
const current = findTicket(ticket.id);
if (!current || busy.value || !props.canEditTickets) {
return;
}
const targets = targetsForColumn(current, columnId);
if (targets.length === 1) {
submitMove(current.id, targets[0].id);
} else if (targets.length > 1) {
moveForm.ticket_status_id = targets[0].id;
choice.value = { ticketId: current.id, columnId };
}
}
const {
draggedTicket,
columnId: dropColumnId,
x: dragX,
y: dragY,
start: startDrag,
cancel: cancelDrag,
} = useBoardDrag({
canStart: (ticket) =>
props.canEditTickets &&
!busy.value &&
!choice.value &&
allowedTargetStatuses(ticket).length > 0,
drop: dropTicket,
});
const canDrop = (columnId: number) =>
!!draggedTicket.value &&
targetsForColumn(draggedTicket.value, columnId).length > 0;
const dropClasses = (columnId: number) =>
!draggedTicket.value
? {}
: {
'border-primary border-dashed': canDrop(columnId),
'ring-2 ring-primary bg-primary/10':
canDrop(columnId) && dropColumnId.value === columnId,
'opacity-40': !canDrop(columnId),
};
function confirmMove(): void {
if (
!choice.value ||
!choiceTargets.value.some(
(status) => status.id === moveForm.ticket_status_id,
)
) {
return;
}
const ticketId = choice.value.ticketId;
choice.value = null;
submitMove(ticketId, moveForm.ticket_status_id);
}
function focusStatus(ticketId: number): void {
document.getElementById(`ticket-status-${ticketId}`)?.focus();
}
watch(
() => [props.project.id, props.boardColumns, props.canEditTickets],
() => {
cancelDrag();
choice.value = null;
},
);
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="t('board.title', { name: project.name })" /> <Head :title="`${project.name} board`" />
<div class="gap-column md:p-page flex min-w-0 flex-1 flex-col p-4"> <div class="flex flex-1 flex-col gap-6 p-4">
<header <div
class="flex min-w-0 flex-col gap-4 xl:flex-row xl:items-start xl:justify-between" class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
> >
<div class="grid min-w-0 gap-2"> <div class="space-y-3">
<div class="flex min-w-0 flex-wrap items-center gap-3"> <Badge variant="outline" class="px-3 py-1">
<Badge {{ project.key }}
variant="outline" </Badge>
class="wrap-anywhere max-w-full whitespace-normal" <Heading
>{{ project.key }}</Badge :title="`${project.name} board`"
> description="Tickets only appear here once they are assigned to one of the project's workflow columns."
<p class="text-caption text-muted-foreground"> />
{{ t('tickets.count', { count: ticketCount }) }}
</p>
</div> </div>
<h1
class="text-headline-mobile md:text-headline wrap-anywhere" <div class="flex flex-wrap gap-3">
>
{{ t('board.title', { name: project.name }) }}
</h1>
<p class="text-caption text-muted-foreground max-w-2xl">
{{ t('board.description') }}
</p>
</div>
<div class="flex flex-wrap gap-2">
<Button v-if="canCreateTickets" as-child> <Button v-if="canCreateTickets" as-child>
<Link <Link
:href=" :href="
@@ -252,229 +95,166 @@ watch(
) )
" "
> >
<Plus aria-hidden="true" />{{ Create ticket
t('navigation.createTicket')
}}
</Link> </Link>
</Button> </Button>
<Button as-child variant="outline"> <Button as-child variant="outline">
<Link :href="backlogIndex(project.id)" <Link :href="backlogIndex(project.id)"
><ListTodo aria-hidden="true" />{{ >Open backlog</Link
t('board.openBacklog')
}}</Link
> >
</Button> </Button>
<Button as-child variant="ghost">
<Link :href="show(project.id)" <Button as-child variant="outline">
><ArrowLeft aria-hidden="true" />{{ <Link :href="show(project.id)">Back to project</Link>
t('board.backToProject')
}}</Link
>
</Button> </Button>
</div> </div>
</header> </div>
<FeedbackState v-if="status" state="success" :title="status" /> <div
<FormFeedback v-if="status"
:state="feedback.state.value" 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"
:description="moveForm.errors.ticket_status_id" >
@retry=" {{ status }}
lastMove && submitMove(lastMove.ticketId, lastMove.statusId) </div>
<Card v-if="boardColumns.length === 0">
<CardContent class="flex flex-col gap-4 p-6 text-sm">
<p class="text-muted-foreground">
The board has no workflow columns yet, so no tickets can
appear here.
</p>
<div v-if="canEditTickets">
<Button as-child variant="outline">
<Link :href="boardSettings(project.id)">
Open board settings
</Link>
</Button>
</div>
</CardContent>
</Card>
<div v-else class="grid gap-4 xl:grid-cols-3">
<Card
v-for="boardColumn in boardColumns"
:key="boardColumn.id"
class="flex h-full flex-col gap-0 overflow-hidden"
>
<CardHeader class="bg-muted/20 border-b">
<div class="flex items-start justify-between gap-3">
<div>
<CardTitle>{{ boardColumn.name }}</CardTitle>
<CardDescription>
{{ boardColumn.tickets?.length ?? 0 }}
active ticket(s)
</CardDescription>
<div
v-if="
(boardColumn.ticket_statuses?.length ??
0) > 0
" "
/> class="mt-3 flex flex-wrap gap-2"
<FeedbackState
v-if="columns.length === 0"
state="empty"
:title="t('board.empty.title')"
:description="t('board.empty.description')"
/>
<template v-else>
<div class="grid min-w-0 gap-2 md:hidden">
<label for="board-column" class="text-body font-semibold">{{
t('board.selectColumn')
}}</label>
<select
id="board-column"
v-model="selectedColumnId"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
:aria-controls="`board-column-${selectedColumnId}`"
> >
<option <Badge
v-for="column in columns" v-for="ticketStatus in boardColumn.ticket_statuses"
:key="column.id" :key="ticketStatus.id"
:value="column.id" variant="outline"
> >
{{ {{ ticketStatus.name }}
t('board.columnOption', { </Badge>
name: column.name,
count: column.count,
})
}}
</option>
</select>
</div> </div>
<p </div>
id="board-scroll-hint"
class="text-caption text-muted-foreground hidden md:block" <Badge variant="secondary">
#{{ boardColumn.position }}
</Badge>
</div>
</CardHeader>
<CardContent class="flex flex-1 flex-col gap-4 p-4">
<div
v-if="(boardColumn.tickets?.length ?? 0) === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
> >
{{ t('board.scrollHint') }} No tickets are currently in this column.
</p> </div>
<p
v-if="canEditTickets" <div v-else class="flex flex-col gap-4">
id="board-drag-hint" <Card
class="text-caption text-muted-foreground" v-for="ticket in boardColumn.tickets"
> :key="ticket.id"
{{ t('board.dragHint') }}
</p>
<section
:aria-label="t('board.columns')"
aria-describedby="board-scroll-hint"
tabindex="0"
data-board-drag-scroll
class="gap-column focus-visible:ring-ring/35 flex min-w-0 rounded-md pb-2 focus-visible:outline-none focus-visible:ring-2 md:overflow-x-auto md:overscroll-x-contain"
>
<section
v-for="column in columns"
:id="`board-column-${column.id}`"
:key="`${project.id}-${column.id}`"
:aria-labelledby="`board-column-title-${column.id}`"
:data-board-drop-column="column.id"
:class="[ :class="[
'bg-muted/40 gap-column w-full min-w-0 shrink-0 flex-col self-stretch rounded-md border p-3 md:flex md:w-80', 'gap-0',
selectedColumnId === column.id ? 'flex' : 'hidden', ticket.is_blocked
dropClasses(column.id), ? 'border-amber-300 bg-amber-50/70 dark:border-amber-700/60 dark:bg-amber-950/30'
: '',
]" ]"
> >
<header <CardHeader class="pb-4">
class="border-primary/25 flex min-w-0 items-start justify-between gap-3 border-b-2 pb-3" <div class="flex flex-wrap gap-2">
> <Badge variant="outline">
<h2 {{ ticket.ticket_key }}
:id="`board-column-title-${column.id}`" </Badge>
class="text-title wrap-anywhere"
>
{{ column.name }}
</h2>
<Badge <Badge
variant="secondary" v-if="ticket.is_blocked"
class="shrink-0 tabular-nums" variant="destructive"
:aria-label="
t('tickets.count', { count: column.count })
"
>{{ column.count }}</Badge
>
</header>
<p
v-if="column.count === 0"
class="text-caption text-muted-foreground rounded-md border border-dashed p-3"
>
{{ t('board.emptyColumn') }}
</p>
<p
v-if="column.groups.length === 0"
class="text-caption text-muted-foreground"
>
{{ t('board.noStatuses') }}
</p>
<section
v-for="group in column.groups"
:key="group.status.id"
:aria-labelledby="`board-status-${group.status.id}`"
class="grid min-w-0 gap-3"
>
<div
class="text-caption text-muted-foreground flex min-w-0 items-center justify-between gap-2"
>
<h3
:id="`board-status-${group.status.id}`"
class="wrap-anywhere font-semibold"
>
{{ group.status.name }}
</h3>
<span
class="shrink-0 tabular-nums"
:aria-label="
t('tickets.count', {
count: group.tickets.length,
})
"
>{{ group.tickets.length }}</span
> >
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> </div>
<p <CardTitle class="text-base">
v-if="group.tickets.length === 0" <Link
class="text-caption text-muted-foreground border-border rounded-md border border-dashed p-3" :href="
> showTicket({
{{ t('board.emptyStatus') }}
</p>
<ul v-else class="grid min-w-0 gap-3">
<li
v-for="ticket in group.tickets"
:key="ticket.id"
class="min-w-0"
:class="{
'opacity-50':
draggedTicket?.id === ticket.id,
}"
>
<TicketCard
:project-id="project.id"
:ticket="ticket"
:show-status="false"
>
<template
v-if="canEditTickets"
#actions
>
<Button
type="button"
variant="ghost"
class="mb-2 w-full cursor-grab touch-none select-none active:cursor-grabbing"
:disabled="
busy ||
allowedTargetStatuses(
ticket,
).length === 0
"
:aria-label="
t('board.dragTicket', {
key: ticket.ticket_key,
})
"
aria-describedby="board-drag-hint"
@pointerdown="
startDrag($event, ticket)
"
@click.prevent
@keydown.enter.prevent="
focusStatus(ticket.id)
"
@keydown.space.prevent="
focusStatus(ticket.id)
"
><GripVertical
aria-hidden="true"
/>{{ t('board.drag') }}</Button
>
<Form
v-bind="
move.form({
project: project.id, project: project.id,
ticket: ticket.id, ticket: ticket.id,
}) })
" "
v-slot="{ processing, errors }" class="hover:text-primary transition"
:error-bag="`board-ticket-${ticket.id}`" >
:options="{ {{ ticket.title }}
preserveScroll: true, </Link>
}" </CardTitle>
:on-before=" <CardDescription
() => v-if="ticket.description"
!busy && !draggedTicket class="line-clamp-3"
>
{{ ticket.description }}
</CardDescription>
<CardDescription
v-if="
ticket.is_blocked &&
ticket.blocked_comment
" "
@start="menuPending = ticket.id" class="mt-2 text-amber-900 dark:text-amber-200"
@finish="menuPending = null" >
class="grid min-w-0 gap-2" {{ ticket.blocked_comment }}
</CardDescription>
</CardHeader>
<CardContent v-if="canEditTickets" class="pt-0">
<Form
v-bind="
ProjectBoardController.move.form({
project: project.id,
ticket: ticket.id,
})
"
class="flex flex-col gap-2"
v-slot="{ processing }"
> >
<input <input
type="hidden" type="hidden"
@@ -483,33 +263,22 @@ watch(
/> />
<label <label
:for="`ticket-status-${ticket.id}`" :for="`ticket-status-${ticket.id}`"
class="text-caption text-muted-foreground font-semibold" class="text-muted-foreground text-xs font-medium uppercase tracking-wide"
>{{ >
t('board.moveTicket', { Move ticket
key: ticket.ticket_key, </label>
}) <div
}}</label class="flex flex-col gap-2 sm:flex-row"
> >
<select <select
:id="`ticket-status-${ticket.id}`" :id="`ticket-status-${ticket.id}`"
name="ticket_status_id" name="ticket_status_id"
:disabled=" :disabled="
busy ||
processing ||
allowedTargetStatuses( allowedTargetStatuses(
ticket, ticket,
).length === 0 ).length === 0 || processing
" "
:aria-invalid=" 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"
!!errors.ticket_status_id ||
undefined
"
:aria-describedby="
errors.ticket_status_id
? `ticket-status-error-${ticket.id}`
: undefined
"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60"
> >
<option <option
v-if=" v-if="
@@ -519,160 +288,52 @@ watch(
" "
value="" value=""
> >
{{ t('board.noMoves') }} No allowed moves
</option> </option>
<option <option
v-for="target in allowedTargetStatuses( v-for="ticketStatus in allowedTargetStatuses(
ticket, ticket,
)" )"
:key="target.id" :key="ticketStatus.id"
:value="target.id" :value="ticketStatus.id"
> >
{{ {{
ticketStatusLabel( ticketStatusLabel(
target, ticketStatus,
t(
'tickets.noStatus',
),
) )
}} }}
</option> </option>
</select> </select>
<InputError
:id="`ticket-status-error-${ticket.id}`"
:message="
errors.ticket_status_id
"
/>
<Button <Button
type="submit" type="submit"
variant="outline" size="sm"
:disabled=" :disabled="
busy ||
processing || processing ||
allowedTargetStatuses( allowedTargetStatuses(
ticket, ticket,
).length === 0 ).length === 0
" "
>{{
t(
processing
? 'board.updatingStatus'
: 'board.updateStatus',
)
}}</Button
> >
Update status
</Button>
</div>
<p <p
v-if=" v-if="
allowedTargetStatuses( allowedTargetStatuses(ticket)
ticket, .length === 0
).length === 0
" "
class="text-caption text-muted-foreground" class="text-muted-foreground text-xs"
> >
{{ This status does not allow moving
t( anywhere else.
'board.noMovesDescription',
)
}}
</p> </p>
</Form> </Form>
</template> </CardContent>
</TicketCard> </Card>
</li>
</ul>
</section>
</section>
</section>
</template>
<div
v-if="draggedTicket"
class="bg-card fixed inset-x-0 bottom-0 z-40 border-t p-3 shadow-lg md:hidden"
data-board-mobile-targets
>
<p class="text-caption mb-2 font-semibold">
{{ t('board.dropHint') }}
</p>
<div class="flex gap-2 overflow-x-auto" data-board-drag-scroll>
<div
v-for="column in columns"
:key="column.id"
:data-board-drop-column="column.id"
:class="dropClasses(column.id)"
class="text-body flex min-h-20 w-36 shrink-0 items-center rounded-md border p-3"
>
{{ column.name }}
</div> </div>
</CardContent>
</Card>
</div> </div>
</div> </div>
<div
v-if="draggedTicket"
aria-hidden="true"
class="bg-primary text-primary-foreground pointer-events-none fixed z-50 max-w-48 rounded-md px-3 py-2 shadow-lg"
:style="{
left: `${Math.max(0, dragX - 80)}px`,
top: `${Math.max(0, dragY - 56)}px`,
}"
>
{{ draggedTicket.ticket_key }}
</div>
<p class="sr-only" aria-live="polite">
{{
draggedTicket
? t('board.dragging', { key: draggedTicket.ticket_key })
: ''
}}
</p>
<Dialog v-model:open="choiceOpen">
<DialogContent>
<DialogHeader>
<DialogTitle>{{ t('board.chooseStatus') }}</DialogTitle>
<DialogDescription>{{
t('board.chooseStatusDescription', {
key: choiceTicket?.ticket_key ?? '',
})
}}</DialogDescription>
</DialogHeader>
<form class="grid gap-4" @submit.prevent="confirmMove">
<label
for="board-drop-status"
class="text-body font-semibold"
>{{ t('board.targetStatus') }}</label
>
<select
id="board-drop-status"
v-model="moveForm.ticket_status_id"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
>
<option
v-for="target in choiceTargets"
:key="target.id"
:value="target.id"
>
{{
ticketStatusLabel(
target,
t('tickets.noStatus'),
)
}}
</option>
</select>
<DialogFooter>
<Button
type="button"
variant="outline"
@click="choiceOpen = false"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
:disabled="busy || choiceTargets.length === 0"
>{{ t('board.updateStatus') }}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</AppLayout> </AppLayout>
</template> </template>
+241 -525
View File
@@ -1,633 +1,349 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3'; import { Head, Link, useForm } from '@inertiajs/vue3';
import { import { computed } from 'vue';
ArrowLeft, import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
Check, import Heading from '@/components/Heading.vue';
Copy,
FolderPlus,
LayoutTemplate,
} 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 InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
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'; import { create, index } from '@/routes/projects/index';
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 = computed<BreadcrumbItem[]>(() => [ const breadcrumbs: BreadcrumbItem[] = [
{ title: t('navigation.projects'), href: index() }, {
{ title: t('projects.create'), href: create() }, title: 'Projects',
]); 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(
`ProjectsCreate:${props.selectedSourceTemplateId ?? 'default'}`, const form = useForm({
{ copy_areas: [] as string[],
creation_mode: defaultTemplateId !== null ? 'template' : 'scratch', creation_mode: defaultTemplateId !== null ? 'template' : 'scratch',
name: '',
key: '', key: '',
name: '',
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 protection = useFormProtection(form); const hasSourceTemplates = computed(() => props.sourceTemplates.length > 0);
const modes = computed(() => [
{ const copyAreasError = computed(() => {
value: 'scratch', return (
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';
watch( const submit = (): void => {
() => form.creation_mode, if (form.creation_mode !== 'duplicate') {
() => { form.source_project_id = '';
form.clearErrors( form.copy_areas = [];
'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();
},
);
function submit(): void { if (form.creation_mode !== 'template') {
form.transform((data) => ({ form.source_template_id = '';
...data,
source_project_id:
data.creation_mode === 'duplicate' ? data.source_project_id : '',
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 });
} }
form.post(ProjectController.store.url(), {
preserveScroll: true,
});
};
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="t('projects.create')" /> <Head title="New project" />
<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>
<form <div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
class="gap-page grid min-w-0" <Heading
:aria-busy="form.processing" title="Create project"
@submit.prevent="submit" description="Create a fresh project, duplicate selected settings from an existing project, or start from a saved template."
>
<FormFeedback
:state="protection.state.value"
:title="
form.processing
? t('projectCreation.creating')
: undefined
"
@retry="submit"
/> />
<fieldset
class="gap-column grid min-w-0" <Card>
:disabled="form.processing" <CardHeader>
aria-describedby="creation-mode-error" <CardTitle>Project details</CardTitle>
> <CardDescription>
<legend class="text-title mb-3"> The key is uppercased automatically, must be unique, and
{{ t('projectCreation.start') }} can contain up to 5 characters.
</legend> </CardDescription>
<div class="gap-column grid md:grid-cols-3"> </CardHeader>
<label
v-for="mode in modes" <CardContent>
:key="mode.value" <form class="space-y-6" @submit.prevent="submit">
class="relative min-w-0" <div class="grid gap-3">
<Label>How do you want to start?</Label>
<div class="grid gap-3 sm:grid-cols-3">
<Label
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
> >
<input <input
v-model="form.creation_mode" v-model="form.creation_mode"
type="radio" type="radio"
name="creation_mode" value="scratch"
:value="mode.value" class="mt-1"
: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 <div class="space-y-1">
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" <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=" :class="
!mode.disabled hasSourceProjects
? 'hover:border-primary/50' ? 'cursor-pointer'
: '' : 'cursor-not-allowed opacity-60'
" "
> >
<span <input
class="flex items-center justify-between gap-3" v-model="form.creation_mode"
> type="radio"
<component value="duplicate"
:is="mode.icon" class="mt-1"
class="text-primary size-5" :disabled="!hasSourceProjects"
aria-hidden="true"
/> />
<Check <div class="space-y-1">
v-if="form.creation_mode === mode.value" <div class="font-medium">
class="text-primary size-4" Create from existing project
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> </div>
<InputError <p
id="creation-mode-error" class="text-muted-foreground text-sm"
:message="form.errors.creation_mode" >
/> Copy only the settings you
</fieldset> explicitly select. Tickets are never
duplicated.
</p>
</div>
</Label>
<div <Label
class="gap-page grid min-w-0 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)] lg:items-start" class="flex items-start gap-3 rounded-xl border p-4"
:class="
hasSourceTemplates
? 'cursor-pointer'
: 'cursor-not-allowed opacity-60'
"
> >
<div class="gap-page grid min-w-0"> <input
<section v-model="form.creation_mode"
class="bg-card gap-column grid min-w-0 rounded-md border p-5" type="radio"
aria-labelledby="project-details-title" 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"
> >
<h2 id="project-details-title" class="text-title"> Reuse a saved configuration
{{ t('projectCreation.details') }} template.
</h2> </p>
<div class="gap-stack grid"> </div>
<Label for="name">{{ </Label>
t('projectCreation.name') </div>
}}</Label> </div>
<div class="grid gap-2">
<Label for="name">Project name</Label>
<Input <Input
id="name" id="name"
v-model="form.name" v-model="form.name"
name="name"
required required
maxlength="255" autofocus
:disabled="form.processing" placeholder="Kanban foundation"
:placeholder="
t('projectCreation.namePlaceholder')
"
:invalid="!!form.errors.name"
aria-describedby="name-error"
/>
<InputError
id="name-error"
:message="form.errors.name"
/> />
<InputError :message="form.errors.name" />
</div> </div>
<div class="gap-stack grid">
<Label for="key">{{ <div class="grid gap-2">
t('projectCreation.key') <Label for="key">Project key</Label>
}}</Label>
<Input <Input
id="key" id="key"
v-model="form.key" v-model="form.key"
name="key"
required required
maxlength="5" maxlength="5"
:disabled="form.processing" placeholder="CORE"
:placeholder=" class="uppercase"
t('projectCreation.keyPlaceholder')
"
class="uppercase sm:max-w-48"
:invalid="!!form.errors.key"
aria-describedby="key-help key-error"
autocapitalize="characters"
:spellcheck="false"
/> />
<p <p class="text-muted-foreground text-sm">
id="key-help" Example ticket keys will look like
class="text-caption text-muted-foreground" <span class="text-foreground font-medium"
> >CORE-1</span
{{ t('projectCreation.keyHelp') }} >.
</p> </p>
<InputError <InputError :message="form.errors.key" />
id="key-error"
:message="form.errors.key"
/>
</div> </div>
</section>
<section <div
v-if="form.creation_mode === 'duplicate'" v-if="form.creation_mode === 'duplicate'"
class="bg-card gap-column grid min-w-0 rounded-md border p-5" class="space-y-6 rounded-xl border border-dashed p-4"
aria-labelledby="copy-title" >
<div class="grid gap-2">
<Label for="source_project_id"
>Source project</Label
> >
<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"
required 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"
:class="selectClass" :disabled="!hasSourceProjects"
:disabled="form.processing"
:aria-invalid="
!!form.errors.source_project_id
"
aria-describedby="source-project-error"
> >
<option value=""> <option value="">
{{ t('projectCreation.selectProject') }} Select a source project
</option> </option>
<option <option
v-for="project in sourceProjects" v-for="sourceProject in sourceProjects"
:key="project.id" :key="sourceProject.id"
:value="String(project.id)" :value="String(sourceProject.id)"
> >
{{ project.name }} ({{ project.key }}) {{ sourceProject.name }} ({{
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
class="min-w-0" <div class="grid gap-3">
:disabled="form.processing" <div>
aria-describedby="copy-help copy-areas-error" <Label>Copy options</Label>
> <p class="text-muted-foreground text-sm">
<legend class="mb-2 font-semibold"> Choose which settings should be copied
{{ t('projectCreation.copyOptions') }} into the new project.
</legend>
<p
id="copy-help"
class="text-caption text-muted-foreground mb-3"
>
{{ t('projectCreation.copyDefaults') }}
</p> </p>
<div class="gap-stack grid sm:grid-cols-2"> </div>
<label
v-for="area in copyAreaOptions" <div class="grid gap-3">
:key="area.value" <Label
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" v-for="copyAreaOption in copyAreaOptions"
:key="copyAreaOption.value"
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
> >
<input <input
v-model="form.copy_areas" v-model="form.copy_areas"
type="checkbox" type="checkbox"
:value="area.value" :value="copyAreaOption.value"
:aria-labelledby="`copy-${area.value}-label`" class="mt-1"
:aria-describedby="`copy-${area.value}-description copy-areas-error`"
:aria-invalid="!!copyAreasError"
class="accent-primary mt-0.5 size-5 shrink-0"
/> />
<span class="gap-stack grid min-w-0"> <div class="space-y-1">
<span <div class="font-medium">
:id="`copy-${area.value}-label`" {{ copyAreaOption.label }}
class="text-body font-semibold"
>{{
areaText(
area.value,
'label',
)
}}</span
>
<span
:id="`copy-${area.value}-description`"
class="text-caption text-muted-foreground"
>{{
areaText(
area.value,
'description',
)
}}</span
>
</span>
</label>
</div> </div>
<InputError <p
id="copy-areas-error" class="text-muted-foreground text-sm"
class="mt-3"
:message="copyAreasError"
/>
</fieldset>
</section>
<section
v-else-if="form.creation_mode === 'template'"
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
aria-labelledby="template-title"
> >
<h2 id="template-title" class="text-title"> {{ copyAreaOption.description }}
{{ t('projectCreation.chooseTemplate') }} </p>
</h2> </div>
<div class="gap-stack grid min-w-0"> </Label>
<Label for="source_template_id">{{ </div>
t('projectCreation.templateLabel')
}}</Label> <p class="text-muted-foreground text-sm">
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'"
class="space-y-4 rounded-xl border border-dashed p-4"
>
<div class="grid gap-2">
<Label for="source_template_id">Template</Label>
<select <select
id="source_template_id" id="source_template_id"
v-model="form.source_template_id" v-model="form.source_template_id"
required 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"
:class="selectClass" :disabled="!hasSourceTemplates"
:disabled="form.processing"
:aria-invalid="
!!form.errors.source_template_id
"
aria-describedby="template-help source-template-error"
> >
<option value=""> <option value="">Select a template</option>
{{
t('projectCreation.selectTemplate')
}}
</option>
<option <option
v-for="template in sourceTemplates" v-for="sourceTemplate in sourceTemplates"
:key="template.id" :key="sourceTemplate.id"
:value="String(template.id)" :value="String(sourceTemplate.id)"
> >
{{ template.name }} ({{ {{ sourceTemplate.name }} ({{
template.slug sourceTemplate.slug
}}) }})
</option> </option>
</select> </select>
<p <p class="text-muted-foreground text-sm">
id="template-help" Templates apply their saved configuration
class="text-caption text-muted-foreground" groups to the new project.
>
{{ 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
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
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
aria-labelledby="summary-title"
>
<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 <div
v-if="form.creation_mode === 'duplicate'" v-else-if="
class="gap-stack grid" !hasSourceProjects && !hasSourceTemplates
>
<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"
>
{{ t('projectCreation.cleanStart') }}
</p>
</aside>
</div>
<footer
class="flex flex-col-reverse gap-3 border-t pt-5 sm:flex-row sm:items-center sm:justify-end"
>
<Button
as-child
variant="outline"
:disabled="form.processing"
><Link :href="index()">{{
t('common.cancel')
}}</Link></Button
>
<Button
type="submit"
:loading="form.processing"
:aria-label="
t(
form.processing
? 'projectCreation.creating'
: 'projectCreation.submit',
)
" "
:loading-label="t('projectCreation.creating')" class="text-muted-foreground text-sm"
><FolderPlus
v-if="!form.processing"
aria-hidden="true"
/>{{
t(
form.processing
? 'projectCreation.creating'
: 'projectCreation.submit',
)
}}</Button
> >
</footer> No accessible source projects or saved templates are
available yet.
</div>
<div class="flex items-center gap-3">
<Button type="submit" :disabled="form.processing">
Create project
</Button>
<Button as-child variant="outline">
<Link :href="index()">Cancel</Link>
</Button>
</div>
</form> </form>
</CardContent>
</Card>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
+66 -147
View File
@@ -1,184 +1,103 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3'; import { Head, Link } from '@inertiajs/vue3';
import { import { Folder, Plus } from 'lucide-vue-next';
ArrowUpRight, import Heading from '@/components/Heading.vue';
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 { useLocalization } from '@/composables/useLocalization'; import {
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'; import { create, index, show } from '@/routes/projects/index';
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 = computed<BreadcrumbItem[]>(() => [ const breadcrumbs: BreadcrumbItem[] = [
{ title: t('navigation.projects'), href: index() }, {
]); title: 'Projects',
href: index(),
},
];
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="t('navigation.projects')" /> <Head title="Projects" />
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4"> <div class="flex flex-1 flex-col gap-6 p-4">
<header <div
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="gap-stack grid min-w-0"> <Heading
<p class="text-label text-muted-foreground uppercase"> title="Projects"
{{ t('projects.workspace') }} description="Create and browse project spaces that will later host boards, backlog, and settings."
</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
v-if="$page.props.auth.permissions.projectCreate"
as-child
class="self-start"
>
<Link :href="create()"
><Plus aria-hidden="true" />{{
t('projects.create')
}}</Link
>
</Button>
</header>
<FeedbackState
v-if="status"
state="success"
:title="t('projects.updated')"
:description="status"
/> />
<FeedbackState
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 <Button
v-if="$page.props.auth.permissions.projectCreate" v-if="$page.props.auth.permissions.projectCreate"
as-child as-child
variant="outline"
>
<Link :href="create()"
><Plus aria-hidden="true" />{{
t('projects.empty.create')
}}</Link
> >
<Link :href="create()">
<Plus class="size-4" />
New project
</Link>
</Button> </Button>
</FeedbackState> </div>
<section <div
v-else v-if="status"
aria-labelledby="project-list-title" 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"
class="gap-column grid"
> >
<h2 {{ status }}
id="project-list-title" </div>
class="text-caption text-muted-foreground"
<div
v-if="projects.length === 0"
class="text-muted-foreground rounded-xl border border-dashed px-6 py-10 text-center text-sm"
> >
{{ t('projects.available', { count: projects.length }) }} No projects exist yet. Create the first one to start building
</h2> out board, backlog, and settings areas.
<ul class="gap-column grid md:grid-cols-2 xl:grid-cols-3"> </div>
<li
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Link
v-for="project in projects" v-for="project in projects"
:key="project.id" :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"
>
<Link
:href="show(project.id)" :href="show(project.id)"
:aria-label=" class="group block"
t('projects.open', { name: project.name })
"
class="gap-column hover:bg-accent/50 group flex min-w-0 flex-1 items-start rounded-t-md p-5 transition-colors"
> >
<span <Card
class="bg-accent text-primary flex size-11 shrink-0 items-center justify-center rounded-md" class="border-border/80 group-hover:border-primary/40 h-full transition-colors"
>
<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
> >
<CardHeader class="gap-3">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<CardTitle>{{ project.name }}</CardTitle>
<CardDescription>
Project workspace entry point
</CardDescription>
</div> </div>
<ArrowUpRight
class="text-muted-foreground mt-1 size-4 shrink-0" <Badge variant="outline" class="px-2.5 py-1">
aria-hidden="true" {{ project.key }}
/> </Badge>
</Link> </div>
<div </CardHeader>
class="gap-stack p-card grid grid-cols-2 border-t"
> <CardContent
<Button as-child variant="secondary"> class="text-muted-foreground flex items-center gap-2 text-sm"
<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" />{{ <Folder class="size-4" />
t('navigation.backlog') Open board, backlog, and settings hub
}} </CardContent>
</Card>
</Link> </Link>
</Button>
</div> </div>
</article>
</li>
</ul>
</section>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
+156 -177
View File
@@ -1,227 +1,206 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3'; import { Head, Link } from '@inertiajs/vue3';
import { import { FolderGit2, LayoutGrid, ListTodo, Settings2 } from 'lucide-vue-next';
ArrowLeft, import Heading from '@/components/Heading.vue';
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 { useLocalization } from '@/composables/useLocalization'; import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { projectSettingsNavigation } from '@/lib/navigation'; import { access, index, show } from '@/routes/projects/index';
import { index, show } from '@/routes/projects';
import { index as backlogIndex } from '@/routes/projects/backlog';
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 {
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 = computed<BreadcrumbItem[]>(() => [ const breadcrumbs: BreadcrumbItem[] = [
{ title: t('navigation.projects'), href: index() },
{ title: props.project.name, href: show(props.project.id) },
]);
const projectAreas = computed(() => [
{ {
title: t('navigation.board'), title: 'Projects',
description: t('projects.boardDescription'), href: index(),
action: t('projects.boardAction'),
href: boardIndex(props.project.id),
icon: Columns3,
}, },
{ {
title: t('navigation.backlog'), title: props.project.name,
description: t('projects.backlogDescription'), href: show(props.project.id),
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,
}, },
]);
const settingsItems = computed(() =>
projectSettingsNavigation(
{ {
id: props.project.id, title: 'Board',
name: props.project.name, description:
key: props.project.key, 'Track active work across the project-defined Kanban columns and update ticket status.',
canManage: props.canManageAccess, icon: LayoutGrid,
canCreateTickets: false,
}, },
t, {
), title: 'Project settings',
); description:
'Manage board columns, ticket metadata, and the project-specific workflow configuration.',
icon: Settings2,
},
{
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="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4"> <div class="flex flex-1 flex-col gap-6 p-4">
<Link <div
: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="gap-stack grid min-w-0"> <div class="space-y-3">
<Badge <Badge variant="outline" class="px-3 py-1">
variant="outline" {{ project.key }}
class="max-w-full justify-self-start whitespace-normal break-all" </Badge>
>{{ project.key }}</Badge <Heading
> :title="project.name"
<h1 description="Project hub for the upcoming backlog, board, and settings work."
class="text-headline-mobile md:text-headline break-words" />
>
{{ project.name }}
</h1>
<p class="text-muted-foreground max-w-2xl">
{{ t('projects.hubDescription') }}
</p>
</div> </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 <Button
v-if="$page.props.auth.permissions.projectCreate" v-if="$page.props.auth.permissions.projectCreate"
as-child as-child
variant="outline" variant="outline"
class="self-start"
>
<Link :href="projectTemplateCreate(project.id)"
><Layers3 aria-hidden="true" />{{
t('projects.saveTemplate')
}}</Link
> >
<Link :href="projectTemplateCreate(project.id)">
Save as new template
</Link>
</Button> </Button>
</header>
<section <Button as-child variant="outline">
:aria-label="t('projects.workAreas')" <Link :href="index()">Back to projects</Link>
class="gap-column grid md:grid-cols-2" </Button>
> </div>
<Link
v-for="area in projectAreas"
:key="area.href.url"
:href="area.href"
:aria-label="area.action"
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]"
>
<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> </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 <Card v-if="templateState?.inheritsFromTemplate">
v-if="settingsItems.length" <CardHeader>
aria-labelledby="project-settings-title" <CardTitle>
class="gap-column grid" Built from template {{ templateState.templateName }}
> </CardTitle>
<div class="gap-stack grid"> <CardDescription>
<h2 id="project-settings-title" class="text-title"> This project keeps its template link. Inherited areas
{{ t('navigation.projectSettings') }} follow the template baseline; overridden areas were
</h2> customized locally.
<p class="text-muted-foreground"> </CardDescription>
{{ t('projects.settingsDescription') }} </CardHeader>
</p> <CardContent class="flex flex-wrap gap-2">
</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"
> >
{{ t('projects.inheritedArea', { name: area.label }) }} {{ area.label }} · inherited
</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"
> >
{{ t('projects.overriddenArea', { name: area.label }) }} {{ area.label }} · overridden
</Badge> </Badge>
</CardContent>
</Card>
<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>
</section>
</div> </div>
</AppLayout> </AppLayout>
</template> </template>
+23 -90
View File
@@ -1,14 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { import { Form, useForm, Head, Link, usePage } from '@inertiajs/vue3';
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, toRef, unref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { import {
acceptanceCriteriaToEditorState, acceptanceCriteriaToEditorState,
buildAcceptanceCriteria, buildAcceptanceCriteria,
@@ -21,10 +14,6 @@ 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';
@@ -56,7 +45,6 @@ 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);
@@ -134,14 +122,8 @@ const breadcrumbs: BreadcrumbItem[] = [
const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState( const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState(
props.ticket.acceptance_criteria, props.ticket.acceptance_criteria,
); );
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`; const acceptanceCriteriaChecklist = ref(
const acceptanceCriteriaDraft = useRemember( acceptanceCriteriaEditorState.checklist,
{ 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),
@@ -149,7 +131,7 @@ const savedAcceptanceCriteria = ref(
const isPersistingAcceptanceCriteriaToggle = ref(false); const isPersistingAcceptanceCriteriaToggle = ref(false);
const acceptanceCriteriaToggleError = ref(''); const acceptanceCriteriaToggleError = ref('');
const form = useForm(draftKey, { const form = useForm({
acceptance_criteria: buildAcceptanceCriteria( acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value, acceptanceCriteriaChecklist.value,
), ),
@@ -188,12 +170,6 @@ 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 ??
@@ -211,20 +187,10 @@ 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 =>
@@ -241,9 +207,6 @@ 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 =
@@ -385,7 +348,7 @@ const submitBranchCreation = (): void => {
return; return;
} }
branchProtection.submit( branchCreationForm.submit(
ProjectTicketBranchController.store({ ProjectTicketBranchController.store({
project: props.project.id, project: props.project.id,
ticket: props.ticket.id, ticket: props.ticket.id,
@@ -519,21 +482,13 @@ const submit = (): void => {
form.acceptance_criteria = buildAcceptanceCriteriaForSubmit(); form.acceptance_criteria = buildAcceptanceCriteriaForSubmit();
protection.submit( form.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,
);
},
}, },
); );
}; };
@@ -619,25 +574,16 @@ 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 <InputError :message="form.errors.title" />
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">
@@ -1049,10 +995,7 @@ const formatDateTime = (value: string): string => {
</CardDescription> </CardDescription>
</div> </div>
<Dialog <Dialog v-model:open="isCreateBranchDialogOpen">
:open="isCreateBranchDialogOpen"
@update:open="updateBranchDialogOpen"
>
<Button <Button
v-if="canCreateBranches" v-if="canCreateBranches"
type="button" type="button"
@@ -1080,12 +1023,6 @@ 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"
@@ -1216,9 +1153,7 @@ const formatDateTime = (value: string): string => {
type="button" type="button"
variant="secondary" variant="secondary"
@click=" @click="
updateBranchDialogOpen( isCreateBranchDialogOpen = false
false,
)
" "
> >
Cancel Cancel
@@ -1242,14 +1177,13 @@ const formatDateTime = (value: string): string => {
</CardHeader> </CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<FeedbackState <div
v-if="visibleRepositoryBranches.length === 0" v-if="visibleRepositoryBranches.length === 0"
state="empty" class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
:title="t('tickets.noBranches')" >
:description=" No matching branches were found across the
t('tickets.noBranchesDescription') linked repositories you can access.
" </div>
/>
<div v-else class="space-y-3"> <div v-else class="space-y-3">
<div <div
@@ -1500,16 +1434,15 @@ const formatDateTime = (value: string): string => {
</CardHeader> </CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
<FeedbackState <div
v-if=" v-if="
visibleRepositoryPullRequests.length === 0 visibleRepositoryPullRequests.length === 0
" "
state="empty" class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
:title="t('tickets.noPullRequests')" >
:description=" No matching pull requests were found across the
t('tickets.noPullRequestsDescription') linked repositories you can access.
" </div>
/>
<div v-else class="space-y-3"> <div v-else class="space-y-3">
<div <div
+7 -33
View File
@@ -1,11 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link, useForm, useRemember } from '@inertiajs/vue3'; import { Head, Link, useForm } from '@inertiajs/vue3';
import { computed, toRef, unref } from 'vue'; import { computed, ref } 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 {
@@ -64,17 +62,9 @@ const hasTicketMetadata =
props.ticketPriorities.length > 0 && props.ticketPriorities.length > 0 &&
props.ticketStatusOptions.length > 0; props.ticketStatusOptions.length > 0;
const draftKey = `projects:${props.project.id}:tickets:create`; const acceptanceCriteriaChecklist = ref('');
const acceptanceCriteriaDraft = useRemember(
{ checklist: '' },
`${draftKey}:checklist`,
);
const acceptanceCriteriaChecklist = toRef(
unref(acceptanceCriteriaDraft),
'checklist',
);
const form = useForm(draftKey, { const form = useForm({
acceptance_criteria: buildAcceptanceCriteria( acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value, acceptanceCriteriaChecklist.value,
), ),
@@ -90,10 +80,6 @@ const form = useForm(draftKey, {
title: '', title: '',
}); });
const protection = useFormProtection(form, {
isDirty: () => form.isDirty || acceptanceCriteriaChecklist.value !== '',
});
const acceptanceCriteriaPreviewItems = computed(() => const acceptanceCriteriaPreviewItems = computed(() =>
parseChecklistLines(acceptanceCriteriaChecklist.value), parseChecklistLines(acceptanceCriteriaChecklist.value),
); );
@@ -113,12 +99,9 @@ const submit = (): void => {
acceptanceCriteriaChecklist.value, acceptanceCriteriaChecklist.value,
); );
protection.submit( form.submit(ProjectTicketController.store({ project: props.project.id }), {
ProjectTicketController.store({ project: props.project.id }),
{
preserveScroll: true, preserveScroll: true,
}, });
);
}; };
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => { const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
@@ -182,26 +165,17 @@ 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 <InputError :message="form.errors.title" />
id="title-error"
:message="form.errors.title"
/>
</div> </div>
<div <div
+1 -21
View File
@@ -4,12 +4,6 @@ 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';
@@ -25,21 +19,7 @@ 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) }) createSSRApp({ render: () => h(App, props) }).use(plugin),
.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,7 +1,4 @@
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';
@@ -22,11 +19,8 @@ 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
@@ -1,13 +0,0 @@
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,17 +11,4 @@ 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,15 +39,6 @@ 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
@@ -1,12 +0,0 @@
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];
};
+19 -18
View File
@@ -1,33 +1,33 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" data-appearance="{{ $appearance ?? 'system' }}" @class(['dark' => ($appearance ?? 'system') === 'dark'])> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @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">
{{-- Resolve before first paint; the Vue plugin reads the same data attribute. --}} {{-- Inline script to detect system dark mode preference and apply it immediately --}}
<script> <script>
(function() { (function() {
const root = document.documentElement; const appearance = '{{ $appearance ?? "system" }}';
let appearance = root.dataset.appearance;
try { if (appearance === 'system') {
const stored = window.localStorage.getItem('appearance'); const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (stored !== null) {
appearance = stored; if (prefersDark) {
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>
{{-- The same tokens are bundled by Vite, so first paint cannot drift. --}} {{-- Inline style to set the HTML background color based on our theme in app.css --}}
<style> <style>
{!! file_get_contents(resource_path('css/theme.css')) !!} html {
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,7 +36,8 @@
<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="preload" href="{{ asset('fonts/inter/InterVariable.woff2') }}" as="font" type="font/woff2" crossorigin> <link rel="preconnect" href="https://fonts.bunny.net">
<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

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