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(
Ticket::query()
->with([
'assignee',
'ticketPriority',
'ticketStatus.allowedTargetStatuses.boardColumn',
'ticketStatus.boardColumn',
@@ -29,7 +29,6 @@ class BuildProjectBoardPayload
'ticketStatuses',
'tickets' => fn ($query) => $query
->with([
'assignee',
'ticketPriority',
'ticketStatus.allowedTargetStatuses.boardColumn',
'ticketStatus.boardColumn',
@@ -41,8 +41,8 @@ class ListAssignedTicketsByVisibleProject
->where('assignee_user_id', $assignee->id)
->whereIn('project_id', $visibleProjectIds)
->with([
'acceptanceCriteria',
'assignee',
'project',
'ticketPriority',
'ticketStatus.boardColumn',
'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
{
$appearance = $request->cookie('appearance');
$appearance = in_array($appearance, ['light', 'dark', 'system'], true) ? $appearance : 'system';
$request->attributes->set('appearance', $appearance);
View::share('appearance', $appearance);
View::share('appearance', $request->cookie('appearance') ?? 'system');
return $next($request);
}
@@ -4,7 +4,6 @@ namespace App\Http\Middleware;
use App\Actions\Permissions\PermissionService;
use App\Enums\Permission;
use App\Models\Project;
use Illuminate\Http\Request;
use Inertia\Middleware;
@@ -47,11 +46,6 @@ class HandleInertiaRequests extends Middleware
return [
...parent::share($request),
'name' => config('app.name'),
'appearance' => $request->attributes->get('appearance', 'system'),
'localization' => [
'locale' => app()->getLocale(),
'timeZone' => config('app.timezone'),
],
'auth' => [
'permissions' => [
'canManageTeams' => $user !== null && ($this->permissionService->canManageAnyTeam($user) || $this->permissionService->allows($user, Permission::TeamCreate)),
@@ -61,36 +55,6 @@ class HandleInertiaRequests extends Middleware
'user' => $user,
],
'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 'tw-animate-css';
@import './theme.css';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@source not '../../bootstrap/ssr';
@custom-variant dark (&:where(.dark, .dark *));
@font-face {
font-family: Inter;
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/inter/InterVariable.woff2') format('woff2');
}
@font-face {
font-family: Inter;
font-style: italic;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/inter/InterVariable-Italic.woff2') format('woff2');
}
@custom-variant dark (&:is(.dark *));
@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-md: var(--radius);
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--spacing-page: 1.5rem;
--spacing-column: 1rem;
--spacing-card: 0.75rem;
--spacing-stack: 0.5rem;
--spacing-control: 2.75rem;
--text-headline: 1.5rem;
--text-headline--line-height: 2rem;
--text-headline--font-weight: 600;
--text-headline--letter-spacing: -0.02em;
--text-headline-mobile: 1.25rem;
--text-headline-mobile--line-height: 1.75rem;
--text-headline-mobile--font-weight: 600;
--text-headline-mobile--letter-spacing: -0.02em;
--text-title: 1.125rem;
--text-title--line-height: 1.5rem;
--text-title--font-weight: 600;
--text-title--letter-spacing: -0.01em;
--text-body: 0.875rem;
--text-body--line-height: 1.25rem;
--text-caption: 0.8125rem;
--text-caption--line-height: 1.125rem;
--text-label: 0.6875rem;
--text-label--line-height: 1rem;
--text-label--font-weight: 700;
--text-label--letter-spacing: 0.05em;
--shadow-card-hover: 0 4px 12px rgb(0 0 0 / 0.08);
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--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-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar-background);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
@@ -103,35 +61,112 @@
--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 {
* {
@apply border-border;
@apply border-border outline-ring/50;
}
body {
@apply bg-background font-sans text-body text-foreground antialiased;
font-optical-sizing: auto;
}
:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 3px;
}
::selection {
background: var(--brand);
color: var(--brand-foreground);
}
}
/* Preserve completion events while removing visible nonessential motion. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
@apply bg-background text-foreground;
}
}
-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 { createApp, h } from 'vue';
import '../css/app.css';
import { createAppearance } from '@/composables/useAppearance';
import { createLocalization } from '@/lib/localization';
import { initializeTheme } from '@/composables/useAppearance';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
@@ -18,11 +17,12 @@ createInertiaApp({
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization(props.initialPage.props.localization))
.use(createAppearance(props.initialPage.props.appearance))
.mount(el);
},
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 { computed } from 'vue';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { useLocalization } from '@/composables/useLocalization';
type Props = {
errors: string[];
title?: string;
};
const props = defineProps<Props>();
const { t } = useLocalization();
const props = withDefaults(defineProps<Props>(), {
title: 'Something went wrong.',
});
const uniqueErrors = computed(() => Array.from(new Set(props.errors)));
</script>
@@ -18,7 +18,7 @@ const uniqueErrors = computed(() => Array.from(new Set(props.errors)));
<template>
<Alert variant="destructive">
<AlertCircle class="size-4" />
<AlertTitle>{{ title ?? t('common.error') }}</AlertTitle>
<AlertTitle>{{ title }}</AlertTitle>
<AlertDescription>
<ul class="list-inside list-disc text-sm">
<li v-for="(error, index) in uniqueErrors" :key="index">
+10 -11
View File
@@ -1,17 +1,16 @@
<script setup lang="ts">
import { Columns3 } from 'lucide-vue-next';
import AppLogoIcon from '@/components/AppLogoIcon.vue';
</script>
<template>
<span class="flex min-w-0 items-center gap-3">
<span
class="bg-brand text-brand-foreground flex size-8 shrink-0 items-center justify-center rounded-md"
<div
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md"
>
<AppLogoIcon class="size-5 fill-current text-white dark:text-black" />
</div>
<div class="ml-1 grid flex-1 text-left text-sm">
<span class="mb-0.5 truncate font-semibold leading-tight"
>Laravel Starter Kit</span
>
<Columns3 class="size-5" aria-hidden="true" />
</span>
<span
class="text-title truncate font-semibold group-data-[collapsible=icon]:hidden"
>KanbanGit</span
>
</span>
</div>
</template>
+90 -86
View File
@@ -1,10 +1,19 @@
<script setup lang="ts">
import { Link, router } from '@inertiajs/vue3';
import { Settings2, X } from 'lucide-vue-next';
import { computed, onMounted, onUnmounted } from 'vue';
import { Link, usePage } from '@inertiajs/vue3';
import {
BookOpen,
Folder,
FolderGit2,
LayoutGrid,
Layers3,
Shield,
Users,
} from 'lucide-vue-next';
import { computed } from 'vue';
import AppLogo from '@/components/AppLogo.vue';
import NavFooter from '@/components/NavFooter.vue';
import NavMain from '@/components/NavMain.vue';
import { Button } from '@/components/ui/button';
import NavUser from '@/components/NavUser.vue';
import {
Sidebar,
SidebarContent,
@@ -13,98 +22,93 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar';
import { useAppNavigation } from '@/composables/useAppNavigation';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization';
import { dashboard } from '@/routes';
import { edit as profile } from '@/routes/profile';
import { show } from '@/routes/projects';
import { index as adminUsers } from '@/routes/admin/users';
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 { currentUrl } = useCurrentUrl();
const { project, workspaceItems, projectItems, projectSettingsItems } =
useAppNavigation();
const { isMobile, setOpenMobile } = useSidebar();
const accountItems = computed(() => [
{
title: t('navigation.settings'),
href: profile(),
icon: Settings2,
isActive: currentUrl.value.startsWith('/settings/'),
},
]);
let removeNavigationListener: (() => void) | undefined;
onMounted(() => {
removeNavigationListener = router.on('navigate', () =>
setOpenMobile(false),
);
const page = usePage();
const mainNavItems = computed<NavItem[]>(() => {
const items: NavItem[] = [
{
title: 'Dashboard',
href: dashboard(),
icon: LayoutGrid,
},
{
title: 'Projects',
href: projectsIndex(),
icon: Folder,
},
];
if (page.props.auth.permissions.projectCreate) {
items.push({
title: 'Project templates',
href: projectTemplatesIndex(),
icon: Layers3,
});
}
if (page.props.auth.permissions.globalAdmin) {
items.push({
title: 'Users',
href: adminUsers(),
icon: Shield,
});
}
if (page.props.auth.permissions.canManageTeams) {
items.push({
title: 'Teams',
href: teamsIndex(),
icon: Users,
});
}
return items;
});
onUnmounted(() => removeNavigationListener?.());
const footerNavItems: NavItem[] = [
{
title: 'Repository',
href: 'https://github.com/laravel/vue-starter-kit',
icon: FolderGit2,
},
{
title: 'Documentation',
href: 'https://laravel.com/docs/starter-kits#vue',
icon: BookOpen,
},
];
</script>
<template>
<Sidebar id="app-navigation" collapsible="icon" variant="sidebar">
<SidebarHeader class="border-sidebar-border gap-4 border-b p-3">
<div class="flex items-center gap-2">
<SidebarMenu class="min-w-0">
<SidebarMenuItem>
<SidebarMenuButton
size="lg"
as-child
tooltip="KanbanGit"
>
<Link :href="dashboard()" aria-label="KanbanGit"
><AppLogo
/></Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<Button
v-if="isMobile"
variant="ghost"
size="icon"
:aria-label="t('navigation.close')"
@click="setOpenMobile(false)"
>
<X aria-hidden="true" />
</Button>
</div>
<Link
v-if="project"
:href="show(project.id)"
class="bg-sidebar-accent flex min-w-0 items-center gap-3 rounded-md p-3 group-data-[collapsible=icon]:hidden"
>
<span
class="text-label bg-brand/15 text-primary shrink-0 rounded-md px-2 py-1"
>{{ project.key }}</span
>
<span class="truncate font-semibold" :title="project.name">{{
project.name
}}</span>
</Link>
<Sidebar collapsible="icon" variant="inset">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" as-child>
<Link :href="dashboard()">
<AppLogo />
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<nav :aria-label="t('navigation.main')">
<NavMain
:items="workspaceItems"
:label="t('navigation.workspace')"
/>
<NavMain
v-if="projectItems.length"
:items="projectItems"
:label="t('navigation.project')"
/>
<NavMain
v-if="projectSettingsItems.length"
:items="projectSettingsItems"
:label="t('navigation.projectSettings')"
/>
</nav>
<NavMain :items="mainNavItems" />
</SidebarContent>
<SidebarFooter class="border-sidebar-border border-t p-0">
<NavMain :items="accountItems" :label="t('navigation.account')" />
<SidebarFooter>
<NavFooter :items="footerNavItems" />
<NavUser />
</SidebarFooter>
</Sidebar>
<slot />
</template>
+13 -131
View File
@@ -1,145 +1,27 @@
<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 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 { 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';
withDefaults(defineProps<{ breadcrumbs?: BreadcrumbItem[] }>(), {
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],
withDefaults(
defineProps<{
breadcrumbs?: BreadcrumbItem[];
}>(),
{
breadcrumbs: () => [],
},
);
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>
<template>
<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">
<SidebarTrigger class="shrink-0" />
<Breadcrumbs v-if="breadcrumbs.length" :breadcrumbs="breadcrumbs" />
<span v-else class="truncate font-semibold">KanbanGit</span>
</div>
<div class="flex shrink-0 items-center gap-1 sm:gap-2">
<Button
v-if="createAction"
as-child
:aria-label="createAction.title"
class="px-3"
>
<Link :href="createAction.href"
><Plus aria-hidden="true" /><span
class="hidden sm:inline"
>{{ createAction.title }}</span
></Link
>
</Button>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="icon"
:aria-label="t('appearance.label')"
><component :is="themeIcon" aria-hidden="true"
/></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{{
t('appearance.label')
}}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
:model-value="appearance"
@update:model-value="
(value) =>
updateAppearance(normalizeAppearance(value))
"
>
<DropdownMenuRadioItem value="light"
><Sun aria-hidden="true" />{{
t('appearance.light')
}}</DropdownMenuRadioItem
>
<DropdownMenuRadioItem value="dark"
><Moon aria-hidden="true" />{{
t('appearance.dark')
}}</DropdownMenuRadioItem
>
<DropdownMenuRadioItem value="system"
><Monitor aria-hidden="true" />{{
t('appearance.system')
}}</DropdownMenuRadioItem
>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="icon"
:aria-label="
t('navigation.userMenu', {
name: page.props.auth.user.name,
})
"
data-test="sidebar-menu-button"
>
<UserAvatar :user="page.props.auth.user" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="min-w-60">
<UserMenuContent :user="page.props.auth.user" />
</DropdownMenuContent>
</DropdownMenu>
<div class="flex items-center gap-2">
<SidebarTrigger class="-ml-1" />
<template v-if="breadcrumbs && breadcrumbs.length > 0">
<Breadcrumbs :breadcrumbs="breadcrumbs" />
</template>
</div>
</header>
</template>
+9 -15
View File
@@ -1,39 +1,33 @@
<script setup lang="ts">
import { Monitor, Moon, Sun } from 'lucide-vue-next';
import { useAppearance } from '@/composables/useAppearance';
import { useLocalization } from '@/composables/useLocalization';
const { appearance, updateAppearance } = useAppearance();
const { t } = useLocalization();
const tabs = [
{ value: 'light', Icon: Sun, label: 'appearance.light' },
{ value: 'dark', Icon: Moon, label: 'appearance.dark' },
{ value: 'system', Icon: Monitor, label: 'appearance.system' },
{ value: 'light', Icon: Sun, label: 'Light' },
{ value: 'dark', Icon: Moon, label: 'Dark' },
{ value: 'system', Icon: Monitor, label: 'System' },
] as const;
</script>
<template>
<div
role="group"
:aria-label="t('appearance.label')"
class="border-border bg-muted inline-flex max-w-full gap-1 rounded-lg border p-1"
class="inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800"
>
<button
v-for="{ value, Icon, label } in tabs"
:key="value"
type="button"
:aria-pressed="appearance === value"
@click="updateAppearance(value)"
: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
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
? 'shadow-xs bg-white dark:bg-neutral-700 dark:text-neutral-100'
: '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" />
<span>{{ t(label) }}</span>
<component :is="Icon" class="-ml-1 h-4 w-4" />
<span class="ml-1.5 text-sm">{{ label }}</span>
</button>
</div>
</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,
} from '@/components/ui/breadcrumb';
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
import { useLocalization } from '@/composables/useLocalization';
type Props = {
breadcrumbs: BreadcrumbItemType[];
};
defineProps<Props>();
const { t } = useLocalization();
</script>
<template>
<Breadcrumb class="min-w-0" :aria-label="t('navigation.breadcrumbs')">
<BreadcrumbList class="flex-nowrap">
<Breadcrumb>
<BreadcrumbList>
<template v-for="(item, index) in breadcrumbs" :key="index">
<BreadcrumbItem
:class="[
'min-w-0',
index !== breadcrumbs.length - 1
? 'hidden md:inline-flex'
: '',
]"
>
<BreadcrumbItem>
<template v-if="index === breadcrumbs.length - 1">
<BreadcrumbPage class="truncate" :title="item.title">{{
item.title
}}</BreadcrumbPage>
<BreadcrumbPage>{{ item.title }}</BreadcrumbPage>
</template>
<template v-else>
<BreadcrumbLink as-child>
<Link
:href="item.href"
class="max-w-40 truncate"
:title="item.title"
>{{ item.title }}</Link
>
<Link :href="item.href">{{ item.title }}</Link>
</BreadcrumbLink>
</template>
</BreadcrumbItem>
<BreadcrumbSeparator
v-if="index !== breadcrumbs.length - 1"
class="hidden md:block"
/>
<BreadcrumbSeparator v-if="index !== breadcrumbs.length - 1" />
</template>
</BreadcrumbList>
</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>
<template>
<div v-show="message" role="alert">
<p class="text-body text-destructive">
<div v-show="message">
<p class="text-sm text-red-600 dark:text-red-500">
{{ message }}
</p>
</div>
+6 -29
View File
@@ -12,46 +12,23 @@ import type { NavItem } from '@/types';
defineProps<{
items: NavItem[];
label: string;
}>();
const { isCurrentUrl } = useCurrentUrl();
</script>
<template>
<SidebarGroup class="px-3 py-2">
<SidebarGroupLabel class="text-label text-muted-foreground uppercase">{{
label
}}</SidebarGroupLabel>
<SidebarMenu role="list" :aria-label="label">
<SidebarGroup class="px-2 py-0">
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem v-for="item in items" :key="item.title">
<SidebarMenuButton
as-child
:is-active="
item.isActive ??
isCurrentUrl(
item.href,
undefined,
item.match === 'section',
)
"
:is-active="isCurrentUrl(item.href)"
:tooltip="item.title"
class="min-h-control data-[active=true]:border-brand border-l-2 border-transparent px-3 data-[active=true]:font-semibold group-data-[collapsible=icon]:min-h-8"
>
<Link
:href="item.href"
:aria-current="
(item.isActive ??
isCurrentUrl(
item.href,
undefined,
item.match === 'section',
))
? 'page'
: undefined
"
>
<component :is="item.icon" aria-hidden="true" />
<Link :href="item.href">
<component :is="item.icon" />
<span>{{ item.title }}</span>
</Link>
</SidebarMenuButton>
+4 -11
View File
@@ -2,7 +2,6 @@
import { Eye, EyeOff } from 'lucide-vue-next';
import { ref, useTemplateRef } from 'vue';
import type { HTMLAttributes } from 'vue';
import { useLocalization } from '@/composables/useLocalization';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
@@ -10,14 +9,12 @@ defineOptions({ inheritAttrs: false });
const props = defineProps<{
class?: HTMLAttributes['class'];
disabled?: boolean;
}>();
const model = defineModel<string | number | null>();
const showPassword = ref(false);
const inputRef = useTemplateRef('inputRef');
const { t } = useLocalization();
defineExpose({
$el: inputRef,
@@ -31,8 +28,7 @@ defineExpose({
ref="inputRef"
v-model="model"
:type="showPassword ? 'text' : 'password'"
:class="cn('pr-12', props.class)"
:disabled="disabled"
:class="cn('pr-10', props.class)"
v-bind="$attrs"
/>
<button
@@ -40,14 +36,11 @@ defineExpose({
@click="showPassword = !showPassword"
:class="
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="
t(showPassword ? 'auth.password.hide' : 'auth.password.show')
"
:aria-pressed="showPassword"
:disabled="disabled"
:aria-label="showPassword ? 'Hide password' : 'Show password'"
:tabindex="-1"
>
<EyeOff v-if="showPassword" 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>
+23 -12
View File
@@ -1,22 +1,33 @@
<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 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,
});
const { getInitials } = useInitials();
// Compute whether we should show the avatar image
const showAvatar = computed(
() => props.user.avatar && props.user.avatar !== '',
);
</script>
<template>
<div class="flex min-w-0 items-center gap-2">
<UserAvatar :user="user" />
<div class="grid min-w-0 flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{{ user.name }}</span>
<span
v-if="showEmail"
class="text-muted-foreground truncate text-xs"
>{{ user.email }}</span
>
</div>
<UserAvatar />
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{{ user.name }}</span>
<span v-if="showEmail" class="text-muted-foreground truncate text-xs">{{
user.email
}}</span>
</div>
</template>
+2 -4
View File
@@ -11,7 +11,6 @@ import UserInfo from '@/components/UserInfo.vue';
import { logout } from '@/routes/index';
import { edit } from '@/routes/profile/index';
import type { User } from '@/types';
import { useLocalization } from '@/composables/useLocalization';
type Props = {
user: User;
@@ -22,7 +21,6 @@ const handleLogout = () => {
};
defineProps<Props>();
const { t } = useLocalization();
</script>
<template>
@@ -36,7 +34,7 @@ const { t } = useLocalization();
<DropdownMenuItem :as-child="true">
<Link class="block w-full cursor-pointer" :href="edit()" prefetch>
<Settings class="mr-2 h-4 w-4" />
{{ t('navigation.settings') }}
Settings
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
@@ -50,7 +48,7 @@ const { t } = useLocalization();
data-test="logout-button"
>
<LogOut class="mr-2 h-4 w-4" />
{{ t('navigation.logout') }}
Log out
</Link>
</DropdownMenuItem>
</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';
+12 -29
View File
@@ -1,35 +1,18 @@
<script setup lang="ts">
import type { AvatarRootProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import type { AvatarVariants } from ".";
import { reactiveOmit } from "@vueuse/core";
import { AvatarRoot, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
import { avatarVariants } from ".";
import type { HTMLAttributes } from "vue"
import { AvatarRoot } from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(
defineProps<
AvatarRootProps & {
class?: HTMLAttributes["class"];
size?: AvatarVariants["size"];
shape?: AvatarVariants["shape"];
}
>(),
{ size: "default", shape: "circle" },
);
const delegatedProps = reactiveOmit(props, "class", "size", "shape");
const forwardedProps = useForwardProps(delegatedProps);
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<AvatarRoot
data-slot="avatar"
:data-size="size"
:data-shape="shape"
v-bind="forwardedProps"
:class="cn(avatarVariants({ size, shape }), props.class)"
>
<slot />
</AvatarRoot>
<AvatarRoot
data-slot="avatar"
:class="cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', props.class)"
>
<slot />
</AvatarRoot>
</template>
@@ -1,28 +1,21 @@
<script setup lang="ts">
import type { AvatarFallbackProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { AvatarFallback } from "reka-ui";
import { cn } from "@/lib/utils";
import type { AvatarFallbackProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AvatarFallback } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<
AvatarFallbackProps & { class?: HTMLAttributes["class"] }
>();
const props = defineProps<AvatarFallbackProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class");
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AvatarFallback
data-slot="avatar-fallback"
v-bind="delegatedProps"
:class="
cn(
'bg-accent text-accent-foreground flex size-full items-center justify-center font-semibold',
props.class,
)
"
>
<slot />
</AvatarFallback>
<AvatarFallback
data-slot="avatar-fallback"
v-bind="delegatedProps"
:class="cn('bg-muted flex size-full items-center justify-center rounded-full', props.class)"
>
<slot />
</AvatarFallback>
</template>
@@ -1,39 +1,41 @@
<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 {
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(
defineProps<{
user?: UserModel;
rounded?: boolean;
size?: AvatarVariants["size"];
label?: string;
}>(),
{
rounded: true,
size: "default",
const props = defineProps({
user: {
type: Object as PropType<UserModel>,
},
);
rounded: {
type: Boolean,
default: false,
},
})
const page = usePage();
const user = computed(() => props.user ?? page.props.auth.user);
const accessibleLabel = computed(() => props.label ?? user.value.name);
const initials = computed(() => getInitials(user.value.name));
const auth = computed(() => page.props.auth);
const user = computed(() => props.user ?? auth.value.user)
</script>
<template>
<Avatar :size="size" :shape="rounded ? 'circle' : 'rounded'">
<Avatar class="size-8 overflow-hidden" :class="rounded ? 'rounded-full' : 'rounded-lg'">
<AvatarImage
v-if="user.avatar"
:src="user.avatar"
:alt="accessibleLabel"
:alt="user.name"
/>
<AvatarFallback :aria-label="accessibleLabel">
{{ initials }}
<AvatarFallback
class="rounded-lg bg-neutral-200 font-semibold text-black dark:bg-neutral-700 dark:text-white"
>
{{ getInitials(auth.user?.name) }}
</AvatarFallback>
</Avatar>
</template>
+3 -31
View File
@@ -1,31 +1,3 @@
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
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>;
export { default as Avatar } from "./Avatar.vue"
export { default as AvatarFallback } from "./AvatarFallback.vue"
export { default as AvatarImage } from "./AvatarImage.vue"
+19 -21
View File
@@ -1,28 +1,26 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import type { BadgeVariants } from ".";
import { reactiveOmit } from "@vueuse/core";
import { Primitive } from "reka-ui";
import { cn } from "@/lib/utils";
import { badgeVariants } from ".";
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { BadgeVariants } from "."
import { reactiveOmit } from "@vueuse/core"
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { badgeVariants } from "."
const props = defineProps<
PrimitiveProps & {
variant?: BadgeVariants["variant"];
class?: HTMLAttributes["class"];
}
>();
const props = defineProps<PrimitiveProps & {
variant?: BadgeVariants["variant"]
class?: HTMLAttributes["class"]
}>()
const delegatedProps = reactiveOmit(props, "class", "variant");
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<Primitive
data-slot="badge"
:class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps"
>
<slot />
</Primitive>
<Primitive
data-slot="badge"
:class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps"
>
<slot />
</Primitive>
</template>
+22 -27
View File
@@ -1,31 +1,26 @@
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import type { VariantProps } 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(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2.5 py-1 text-label whitespace-nowrap transition-[background-color,border-color,color,box-shadow] outline-none [&>svg]:pointer-events-none [&>svg]:size-3 [&>svg]:shrink-0 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/35",
outline:
"border-border bg-card text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
success:
"border-transparent bg-success text-success-foreground [a&]:hover:brightness-95",
warning:
"border-transparent bg-warning text-warning-foreground [a&]:hover:brightness-95",
info: "border-transparent bg-info text-info-foreground [a&]:hover:brightness-95",
},
},
defaultVariants: {
variant: "default",
},
"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: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
);
export type BadgeVariants = VariantProps<typeof badgeVariants>;
defaultVariants: {
variant: "default",
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>
+19 -37
View File
@@ -1,47 +1,29 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import type { ButtonVariants } from ".";
import { Primitive } from "reka-ui";
import { computed } from "vue";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
import { buttonVariants } from ".";
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from "."
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from "."
interface Props extends PrimitiveProps {
variant?: ButtonVariants["variant"];
size?: ButtonVariants["size"];
class?: HTMLAttributes["class"];
disabled?: boolean;
loading?: boolean;
loadingLabel?: string;
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}
const props = withDefaults(defineProps<Props>(), {
as: "button",
disabled: false,
loading: false,
});
const isDisabled = computed(() => props.disabled || props.loading);
as: "button",
})
</script>
<template>
<Primitive
data-slot="button"
:data-loading="loading ? '' : undefined"
:as="as"
:as-child="asChild"
:disabled="isDisabled"
:aria-disabled="isDisabled ? 'true' : undefined"
:aria-busy="loading ? 'true' : undefined"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<Spinner
v-if="loading"
data-slot="button-spinner"
:label="loadingLabel"
/>
<slot />
</Primitive>
<Primitive
data-slot="button"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>
+34 -33
View File
@@ -1,37 +1,38 @@
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import type { VariantProps } 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(
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-body font-semibold transition-[background-color,border-color,color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[loading]:cursor-wait data-[loading]:opacity-80 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 focus-visible:ring-offset-2 focus-visible:ring-offset-background [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default:
"border border-transparent bg-brand text-brand-foreground shadow-xs hover:brightness-95",
destructive:
"border border-transparent bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/35",
outline:
"border border-input bg-card text-foreground shadow-xs hover:border-primary/45 hover:bg-accent hover:text-accent-foreground",
secondary:
"border border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "border border-transparent hover:bg-accent hover:text-accent-foreground",
link: "min-h-0 border-0 px-0 text-primary underline-offset-4 shadow-none hover:underline",
},
size: {
default: "min-h-control 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",
lg: "min-h-12 px-6 py-3 has-[>svg]:px-4",
icon: "size-control",
"icon-sm": "size-9",
"icon-lg": "size-12",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
"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: {
variant: {
default:
"bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
"icon": "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
);
export type ButtonVariants = VariantProps<typeof buttonVariants>;
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
@@ -1,48 +1,35 @@
<script setup lang="ts">
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { Check } from "lucide-vue-next";
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Check } from "lucide-vue-next"
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(
defineProps<
CheckboxRootProps & {
class?: HTMLAttributes["class"];
invalid?: boolean;
}
>(),
{ invalid: false },
);
const emits = defineEmits<CheckboxRootEmits>();
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>()
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>
<template>
<CheckboxRoot
v-slot="slotProps"
data-slot="checkbox"
:data-invalid="invalid ? '' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
v-bind="forwarded"
:class="
cn(
'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,
)
"
<CheckboxRoot
v-slot="slotProps"
data-slot="checkbox"
v-bind="forwarded"
:class="
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',
props.class)"
>
<CheckboxIndicator
data-slot="checkbox-indicator"
class="grid place-content-center text-current transition-none"
>
<CheckboxIndicator
data-slot="checkbox-indicator"
class="grid place-content-center text-current transition-none"
>
<slot v-bind="slotProps">
<Check class="size-4" stroke-width="2.5" />
</slot>
</CheckboxIndicator>
</CheckboxRoot>
<slot v-bind="slotProps">
<Check class="size-3.5" />
</slot>
</CheckboxIndicator>
</CheckboxRoot>
</template>
@@ -1,67 +1,53 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { X } from "lucide-vue-next";
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import {
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
import DialogOverlay from "./DialogOverlay.vue";
import { useDialogFocus } from "@/composables/useDialogFocus";
import { useLocalization } from "@/composables/useLocalization";
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import DialogOverlay from "./DialogOverlay.vue"
defineOptions({
inheritAttrs: false,
});
inheritAttrs: false,
})
const props = withDefaults(
defineProps<
DialogContentProps & {
class?: HTMLAttributes["class"];
showCloseButton?: boolean;
}
>(),
{
showCloseButton: true,
},
);
const emits = defineEmits<DialogContentEmits>();
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
showCloseButton: true,
})
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class", "showCloseButton");
const { restoreFocus } = useDialogFocus();
const { t } = useLocalization();
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay />
<DialogContent
data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
@close-auto-focus="restoreFocus"
:class="
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',
props.class,
)
"
>
<slot />
<DialogPortal>
<DialogOverlay />
<DialogContent
data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'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,
)"
>
<slot />
<DialogClose
v-if="showCloseButton"
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"
>
<X />
<span class="sr-only">{{ t("common.close") }}</span>
</DialogClose>
</DialogContent>
</DialogPortal>
<DialogClose
v-if="showCloseButton"
data-slot="dialog-close"
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<X />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>
@@ -1,17 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="dialog-header"
:class="cn('flex flex-col gap-stack pr-6 text-left', props.class)"
>
<slot />
</div>
<div
data-slot="dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>
@@ -1,71 +1,59 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { X } from "lucide-vue-next";
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
import { useDialogFocus } from "@/composables/useDialogFocus";
import { useLocalization } from "@/composables/useLocalization";
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
defineOptions({
inheritAttrs: false,
});
inheritAttrs: false,
})
const props = defineProps<
DialogContentProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DialogContentEmits>();
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class");
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const { restoreFocus } = useDialogFocus();
const { t } = useLocalization();
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
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',
props.class,
)
"
v-bind="{ ...$attrs, ...forwarded }"
@close-auto-focus="restoreFocus"
@pointer-down-outside="
(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (
originalEvent.offsetX > target.clientWidth ||
originalEvent.offsetY > target.clientHeight
) {
event.preventDefault();
}
}
"
>
<slot />
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class,
)
"
v-bind="{ ...$attrs, ...forwarded }"
@pointer-down-outside="(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
event.preventDefault();
}
}"
>
<slot />
<DialogClose
class="absolute top-1 right-1 flex size-control items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"
>
<X class="w-4 h-4" />
<span class="sr-only">{{ t("common.close") }}</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
<DialogClose
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
>
<X class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template>
@@ -1,48 +1,39 @@
<script setup lang="ts">
import type {
DropdownMenuCheckboxItemEmits,
DropdownMenuCheckboxItemProps,
} from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { Check } from "lucide-vue-next";
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Check } from "lucide-vue-next"
import {
DropdownMenuCheckboxItem,
DropdownMenuItemIndicator,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
DropdownMenuCheckboxItem,
DropdownMenuItemIndicator,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<
DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DropdownMenuCheckboxItemEmits>();
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DropdownMenuCheckboxItemEmits>()
const delegatedProps = reactiveOmit(props, "class");
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuCheckboxItem
data-slot="dropdown-menu-checkbox-item"
v-bind="forwarded"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground relative flex min-h-control cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)
"
>
<span
class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"
>
<DropdownMenuItemIndicator>
<slot name="indicator-icon">
<Check class="size-4" />
</slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
data-slot="dropdown-menu-checkbox-item"
v-bind="forwarded"
:class=" 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',
props.class,
)"
>
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuItemIndicator>
<slot name="indicator-icon">
<Check class="size-4" />
</slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuCheckboxItem>
</template>
@@ -1,49 +1,39 @@
<script setup lang="ts">
import type {
DropdownMenuContentEmits,
DropdownMenuContentProps,
} from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
DropdownMenuContent,
DropdownMenuPortal,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
DropdownMenuContent,
DropdownMenuPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
defineOptions({
inheritAttrs: false,
});
inheritAttrs: false,
})
const props = withDefaults(
defineProps<
DropdownMenuContentProps & { class?: HTMLAttributes["class"] }
>(),
{
sideOffset: 4,
},
);
const emits = defineEmits<DropdownMenuContentEmits>();
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(),
{
sideOffset: 4,
},
)
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>
<template>
<DropdownMenuPortal>
<DropdownMenuContent
data-slot="dropdown-menu-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] 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 />
</DropdownMenuContent>
</DropdownMenuPortal>
<DropdownMenuPortal>
<DropdownMenuContent
data-slot="dropdown-menu-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', props.class)"
>
<slot />
</DropdownMenuContent>
</DropdownMenuPortal>
</template>
@@ -1,41 +1,31 @@
<script setup lang="ts">
import type { DropdownMenuItemProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { DropdownMenuItem, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
import type { DropdownMenuItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DropdownMenuItem, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(
defineProps<
DropdownMenuItemProps & {
class?: HTMLAttributes["class"];
inset?: boolean;
variant?: "default" | "destructive";
}
>(),
{
variant: "default",
},
);
const props = withDefaults(defineProps<DropdownMenuItemProps & {
class?: HTMLAttributes["class"]
inset?: boolean
variant?: "default" | "destructive"
}>(), {
variant: "default",
})
const delegatedProps = reactiveOmit(props, "inset", "variant", "class");
const delegatedProps = reactiveOmit(props, "inset", "variant", "class")
const forwardedProps = useForwardProps(delegatedProps);
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuItem
data-slot="dropdown-menu-item"
:data-inset="inset ? '' : undefined"
:data-variant="variant"
v-bind="forwardedProps"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex 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 />
</DropdownMenuItem>
<DropdownMenuItem
data-slot="dropdown-menu-item"
:data-inset="inset ? '' : undefined"
:data-variant="variant"
v-bind="forwardedProps"
:class="cn('focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', props.class)"
>
<slot />
</DropdownMenuItem>
</template>
@@ -1,49 +1,40 @@
<script setup lang="ts">
import type {
DropdownMenuRadioItemEmits,
DropdownMenuRadioItemProps,
} from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { Circle } from "lucide-vue-next";
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Circle } from "lucide-vue-next"
import {
DropdownMenuItemIndicator,
DropdownMenuRadioItem,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
DropdownMenuItemIndicator,
DropdownMenuRadioItem,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<
DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }
>();
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DropdownMenuRadioItemEmits>();
const emits = defineEmits<DropdownMenuRadioItemEmits>()
const delegatedProps = reactiveOmit(props, "class");
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuRadioItem
data-slot="dropdown-menu-radio-item"
v-bind="forwarded"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground relative flex min-h-control cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)
"
>
<span
class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"
>
<DropdownMenuItemIndicator>
<slot name="indicator-icon">
<Circle class="size-2 fill-current" />
</slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
data-slot="dropdown-menu-radio-item"
v-bind="forwarded"
:class="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',
props.class,
)"
>
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuItemIndicator>
<slot name="indicator-icon">
<Circle class="size-2 fill-current" />
</slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuRadioItem>
</template>
@@ -1,34 +1,27 @@
<script setup lang="ts">
import type {
DropdownMenuSubContentEmits,
DropdownMenuSubContentProps,
} from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { DropdownMenuSubContent, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
DropdownMenuSubContent,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<
DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DropdownMenuSubContentEmits>();
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DropdownMenuSubContentEmits>()
const delegatedProps = reactiveOmit(props, "class");
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuSubContent
data-slot="dropdown-menu-sub-content"
v-bind="forwarded"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 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 />
</DropdownMenuSubContent>
<DropdownMenuSubContent
data-slot="dropdown-menu-sub-content"
v-bind="forwarded"
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', props.class)"
>
<slot />
</DropdownMenuSubContent>
</template>
@@ -1,34 +1,30 @@
<script setup lang="ts">
import type { DropdownMenuSubTriggerProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { ChevronRight } from "lucide-vue-next";
import { DropdownMenuSubTrigger, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
import type { DropdownMenuSubTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronRight } from "lucide-vue-next"
import {
DropdownMenuSubTrigger,
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<
DropdownMenuSubTriggerProps & {
class?: HTMLAttributes["class"];
inset?: boolean;
}
>();
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
const delegatedProps = reactiveOmit(props, "class", "inset");
const forwardedProps = useForwardProps(delegatedProps);
const delegatedProps = reactiveOmit(props, "class", "inset")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuSubTrigger
data-slot="dropdown-menu-sub-trigger"
v-bind="forwardedProps"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex min-h-control cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
props.class,
)
"
>
<slot />
<ChevronRight class="ml-auto size-4" />
</DropdownMenuSubTrigger>
<DropdownMenuSubTrigger
data-slot="dropdown-menu-sub-trigger"
v-bind="forwardedProps"
:class="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',
props.class,
)"
>
<slot />
<ChevronRight class="ml-auto size-4" />
</DropdownMenuSubTrigger>
</template>
+22 -27
View File
@@ -1,38 +1,33 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { useVModel } from "@vueuse/core";
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "vue"
import { useVModel } from "@vueuse/core"
import { cn } from "@/lib/utils"
const props = defineProps<{
defaultValue?: string | number;
modelValue?: string | number | null;
class?: HTMLAttributes["class"];
invalid?: boolean;
}>();
defaultValue?: string | number
modelValue?: string | number | null
class?: HTMLAttributes["class"]
}>()
const emits = defineEmits<{
(e: "update:modelValue", payload: string | number): void;
}>();
(e: "update:modelValue", payload: string | number): void
}>()
const modelValue = useVModel(props, "modelValue", emits, {
passive: true,
defaultValue: props.defaultValue,
});
passive: true,
defaultValue: props.defaultValue,
})
</script>
<template>
<input
v-model="modelValue"
data-slot="input"
:data-invalid="invalid ? '' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:class="
cn(
'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,
)
"
/>
<input
v-model="modelValue"
data-slot="input"
:class="cn(
'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',
'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',
props.class,
)"
>
</template>
@@ -1,60 +1,51 @@
<script setup lang="ts">
import type { SelectContentEmits, SelectContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import type { SelectContentEmits, SelectContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
SelectContent,
SelectPortal,
SelectViewport,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
import { SelectScrollDownButton, SelectScrollUpButton } from ".";
SelectContent,
SelectPortal,
SelectViewport,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import { SelectScrollDownButton, SelectScrollUpButton } from "."
defineOptions({
inheritAttrs: false,
});
inheritAttrs: false,
})
const props = withDefaults(
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
{
position: "popper",
},
);
const emits = defineEmits<SelectContentEmits>();
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
{
position: "popper",
},
)
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>
<template>
<SelectPortal>
<SelectContent
data-slot="select-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'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' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class,
)
"
>
<SelectScrollUpButton />
<SelectViewport
:class="
cn(
'p-1',
position === 'popper' &&
'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1',
)
"
>
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
<SelectPortal>
<SelectContent
data-slot="select-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
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',
props.class,
)
"
>
<SelectScrollUpButton />
<SelectViewport :class="cn('p-1', position === 'popper' && 'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1')">
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template>
@@ -1,48 +1,44 @@
<script setup lang="ts">
import type { SelectItemProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { Check } from "lucide-vue-next";
import type { SelectItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Check } from "lucide-vue-next"
import {
SelectItem,
SelectItemIndicator,
SelectItemText,
useForwardProps,
} from "reka-ui";
import { cn } from "@/lib/utils";
SelectItem,
SelectItemIndicator,
SelectItemText,
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<
SelectItemProps & { class?: HTMLAttributes["class"] }
>();
const props = defineProps<SelectItemProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class");
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps);
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectItem
data-slot="select-item"
v-bind="forwardedProps"
:class="
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',
props.class,
)
"
>
<span
class="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectItemIndicator>
<slot name="indicator-icon">
<Check class="size-4" />
</slot>
</SelectItemIndicator>
</span>
<SelectItem
data-slot="select-item"
v-bind="forwardedProps"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
props.class,
)
"
>
<span class="absolute right-2 flex size-3.5 items-center justify-center">
<SelectItemIndicator>
<slot name="indicator-icon">
<Check class="size-4" />
</slot>
</SelectItemIndicator>
</span>
<SelectItemText>
<slot />
</SelectItemText>
</SelectItem>
<SelectItemText>
<slot />
</SelectItemText>
</SelectItem>
</template>
@@ -1,45 +1,33 @@
<script setup lang="ts">
import type { SelectTriggerProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { ChevronDown } from "lucide-vue-next";
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
import type { SelectTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronDown } from "lucide-vue-next"
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(
defineProps<
SelectTriggerProps & {
class?: HTMLAttributes["class"];
size?: "sm" | "default";
invalid?: boolean;
}
>(),
{ size: "default", invalid: false },
);
defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"], size?: "sm" | "default" }>(),
{ size: "default" },
)
const delegatedProps = reactiveOmit(props, "class", "size", "invalid");
const forwardedProps = useForwardProps(delegatedProps);
const delegatedProps = reactiveOmit(props, "class", "size")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectTrigger
data-slot="select-trigger"
:data-size="size"
:data-invalid="invalid ? '' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
v-bind="forwardedProps"
:class="
cn(
'group/select-trigger border-input bg-card data-[placeholder]:text-muted-foreground flex w-fit items-center justify-between gap-2 rounded-md border px-3 py-2 text-body whitespace-nowrap shadow-xs transition-[border-color,box-shadow] outline-none hover:border-primary/45 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 data-[state=open]:border-primary data-[size=default]:min-h-control data-[size=sm]:min-h-9 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 [&_svg:not([class*=\'text-\'])]:text-muted-foreground',
props.class,
)
"
>
<slot />
<SelectIcon as-child>
<ChevronDown
class="size-4 transition-transform duration-150 group-data-[state=open]/select-trigger:rotate-180"
/>
</SelectIcon>
</SelectTrigger>
<SelectTrigger
data-slot="select-trigger"
:data-size="size"
v-bind="forwardedProps"
:class="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',
props.class,
)"
>
<slot />
<SelectIcon as-child>
<ChevronDown class="size-4 opacity-50" />
</SelectIcon>
</SelectTrigger>
</template>
@@ -1,70 +1,62 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { X } from "lucide-vue-next";
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import {
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
import SheetOverlay from "./SheetOverlay.vue";
import { useDialogFocus } from "@/composables/useDialogFocus";
import { useLocalization } from "@/composables/useLocalization";
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import SheetOverlay from "./SheetOverlay.vue"
interface SheetContentProps extends DialogContentProps {
class?: HTMLAttributes["class"];
side?: "top" | "right" | "bottom" | "left";
class?: HTMLAttributes["class"]
side?: "top" | "right" | "bottom" | "left"
}
defineOptions({
inheritAttrs: false,
});
inheritAttrs: false,
})
const props = withDefaults(defineProps<SheetContentProps>(), {
side: "right",
});
const emits = defineEmits<DialogContentEmits>();
side: "right",
})
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class", "side");
const delegatedProps = reactiveOmit(props, "class", "side")
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const { restoreFocus } = useDialogFocus();
const { t } = useLocalization();
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<SheetOverlay />
<DialogContent
data-slot="sheet-content"
:class="
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',
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',
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',
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',
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',
props.class,
)
"
v-bind="{ ...$attrs, ...forwarded }"
@close-auto-focus="restoreFocus"
>
<slot />
<DialogPortal>
<SheetOverlay />
<DialogContent
data-slot="sheet-content"
:class="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',
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',
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',
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',
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',
props.class)"
v-bind="{ ...$attrs, ...forwarded }"
>
<slot />
<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"
>
<X class="size-4" />
<span class="sr-only">{{ t("common.close") }}</span>
</DialogClose>
</DialogContent>
</DialogPortal>
<DialogClose
class="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
>
<X class="size-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>
+80 -102
View File
@@ -1,118 +1,96 @@
<script setup lang="ts">
import type { SidebarProps } from ".";
import { cn } from "@/lib/utils";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import SheetDescription from "@/components/ui/sheet/SheetDescription.vue";
import SheetHeader from "@/components/ui/sheet/SheetHeader.vue";
import SheetTitle from "@/components/ui/sheet/SheetTitle.vue";
import { SIDEBAR_WIDTH_MOBILE, useSidebar } from "./utils";
import { useLocalization } from "@/composables/useLocalization";
import type { SidebarProps } from "."
import { cn } from "@/lib/utils"
import { Sheet, SheetContent } from '@/components/ui/sheet'
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue'
import SheetHeader from '@/components/ui/sheet/SheetHeader.vue'
import SheetTitle from '@/components/ui/sheet/SheetTitle.vue'
import { SIDEBAR_WIDTH_MOBILE, useSidebar } from "./utils"
defineOptions({
inheritAttrs: false,
});
inheritAttrs: false,
})
const props = withDefaults(defineProps<SidebarProps>(), {
side: "left",
variant: "sidebar",
collapsible: "offcanvas",
});
side: "left",
variant: "sidebar",
collapsible: "offcanvas",
})
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
const { t } = useLocalization();
function restoreTriggerFocus(event: Event) {
event.preventDefault();
document
.querySelector<HTMLButtonElement>('[data-sidebar="trigger"]')
?.focus();
}
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
</script>
<template>
<div
v-if="collapsible === 'none'"
data-slot="sidebar"
:class="
cn(
'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',
props.class,
)
"
v-bind="$attrs"
<div
v-if="collapsible === 'none'"
data-slot="sidebar"
:class="cn('bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col', props.class)"
v-bind="$attrs"
>
<slot />
</div>
<Sheet v-else-if="isMobile" :open="openMobile" v-bind="$attrs" @update:open="setOpenMobile">
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
:side="side"
class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
:style="{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
}"
>
<SheetHeader class="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div class="flex h-full w-full flex-col">
<slot />
</div>
<Sheet v-else-if="isMobile" :open="openMobile" @update:open="setOpenMobile">
<SheetContent
v-bind="$attrs"
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
:side="side"
class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) max-w-[calc(100vw-2rem)] gap-0 p-0 [&>button]:hidden"
@close-auto-focus="restoreTriggerFocus"
:style="{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
}"
>
<SheetHeader class="sr-only">
<SheetTitle>{{ t("navigation.main") }}</SheetTitle>
<SheetDescription>{{
t("navigation.mobileDescription")
}}</SheetDescription>
</SheetHeader>
<div class="flex min-h-0 h-full w-full flex-col">
<slot />
</div>
</SheetContent>
</Sheet>
</div>
</SheetContent>
</Sheet>
<div
v-else
class="group peer text-sidebar-foreground hidden md:block"
data-slot="sidebar"
:data-state="state"
:data-collapsible="state === 'collapsed' ? collapsible : ''"
:data-variant="variant"
:data-side="side"
>
<!-- This is what handles the sidebar gap on desktop -->
<div
v-else
class="group peer text-sidebar-foreground hidden md:block"
data-slot="sidebar"
:data-state="state"
:data-collapsible="state === 'collapsed' ? collapsible : ''"
:data-variant="variant"
:data-side="side"
:class="cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
)"
/>
<div
:class="cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
props.class,
)"
v-bind="$attrs"
>
<!-- This is what handles the sidebar gap on desktop -->
<div
:class="
cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
)
"
/>
<div
:class="
cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
props.class,
)
"
v-bind="$attrs"
>
<div
data-sidebar="sidebar"
class="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
<slot />
</div>
</div>
<div
data-sidebar="sidebar"
class="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
<slot />
</div>
</div>
</div>
</template>
@@ -1,23 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="sidebar-content"
data-sidebar="content"
:class="
cn(
'flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto',
props.class,
)
"
>
<slot />
</div>
<div
data-slot="sidebar-content"
data-sidebar="content"
:class="cn('flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden', props.class)"
>
<slot />
</div>
</template>
@@ -1,23 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<main
data-slot="sidebar-inset"
:class="
cn(
'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',
props.class,
)
"
>
<slot />
</main>
<main
data-slot="sidebar-inset"
:class="cn(
'bg-background relative flex 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',
props.class,
)"
>
<slot />
</main>
</template>
@@ -1,60 +1,48 @@
<script setup lang="ts">
import type { Component } from "vue";
import type { SidebarMenuButtonProps } from "./SidebarMenuButtonChild.vue";
import { reactiveOmit } from "@vueuse/core";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import SidebarMenuButtonChild from "./SidebarMenuButtonChild.vue";
import { useSidebar } from "./utils";
import type { Component } from "vue"
import type { SidebarMenuButtonProps } from "./SidebarMenuButtonChild.vue"
import { reactiveOmit } from "@vueuse/core"
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import SidebarMenuButtonChild from "./SidebarMenuButtonChild.vue"
import { useSidebar } from "./utils"
defineOptions({
inheritAttrs: false,
});
inheritAttrs: false,
})
const props = withDefaults(
defineProps<
SidebarMenuButtonProps & {
tooltip?: string | Component;
}
>(),
{
as: "button",
variant: "default",
size: "default",
},
);
const props = withDefaults(defineProps<SidebarMenuButtonProps & {
tooltip?: string | Component
}>(), {
as: "button",
variant: "default",
size: "default",
})
const { isMobile, state } = useSidebar();
const { isMobile, state } = useSidebar()
const delegatedProps = reactiveOmit(props, "tooltip");
const delegatedProps = reactiveOmit(props, "tooltip")
</script>
<template>
<SidebarMenuButtonChild
v-if="!tooltip || isMobile || state !== 'collapsed'"
v-bind="{ ...delegatedProps, ...$attrs }"
>
<slot />
</SidebarMenuButtonChild>
<SidebarMenuButtonChild v-if="!tooltip" v-bind="{ ...delegatedProps, ...$attrs }">
<slot />
</SidebarMenuButtonChild>
<Tooltip v-else>
<TooltipTrigger as-child>
<SidebarMenuButtonChild v-bind="{ ...delegatedProps, ...$attrs }">
<slot />
</SidebarMenuButtonChild>
</TooltipTrigger>
<TooltipContent
side="right"
align="center"
:hidden="state !== 'collapsed' || isMobile"
>
<template v-if="typeof tooltip === 'string'">
{{ tooltip }}
</template>
<component :is="tooltip" v-else />
</TooltipContent>
</Tooltip>
<Tooltip v-else>
<TooltipTrigger as-child>
<SidebarMenuButtonChild v-bind="{ ...delegatedProps, ...$attrs }">
<slot />
</SidebarMenuButtonChild>
</TooltipTrigger>
<TooltipContent
side="right"
align="center"
:hidden="state !== 'collapsed' || isMobile"
>
<template v-if="typeof tooltip === 'string'">
{{ tooltip }}
</template>
<component :is="tooltip" v-else />
</TooltipContent>
</Tooltip>
</template>
@@ -1,112 +1,82 @@
<script setup lang="ts">
import type { HTMLAttributes, Ref } from "vue";
import {
defaultDocument,
useEventListener,
useMediaQuery,
useVModel,
} from "@vueuse/core";
import { TooltipProvider } from "reka-ui";
import { computed, ref, watch } from "vue";
import { cn } from "@/lib/utils";
import {
provideSidebarContext,
SIDEBAR_COOKIE_MAX_AGE,
SIDEBAR_COOKIE_NAME,
SIDEBAR_KEYBOARD_SHORTCUT,
SIDEBAR_WIDTH,
SIDEBAR_WIDTH_ICON,
} from "./utils";
import type { HTMLAttributes, Ref } from "vue"
import { defaultDocument, useEventListener, useMediaQuery, useVModel } from "@vueuse/core"
import { TooltipProvider } from "reka-ui"
import { computed, ref } from "vue"
import { cn } from "@/lib/utils"
import { provideSidebarContext, SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, SIDEBAR_KEYBOARD_SHORTCUT, SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON } from "./utils"
const props = withDefaults(
defineProps<{
defaultOpen?: boolean;
open?: boolean;
class?: HTMLAttributes["class"];
}>(),
{
defaultOpen: !defaultDocument?.cookie.includes(
`${SIDEBAR_COOKIE_NAME}=false`,
),
open: undefined,
},
);
const props = withDefaults(defineProps<{
defaultOpen?: boolean
open?: boolean
class?: HTMLAttributes["class"]
}>(), {
defaultOpen: !defaultDocument?.cookie.includes(`${SIDEBAR_COOKIE_NAME}=false`),
open: undefined,
})
const emits = defineEmits<{
"update:open": [open: boolean];
}>();
"update:open": [open: boolean]
}>()
const isMobile = useMediaQuery("(width < 768px)");
const openMobile = ref(false);
watch(isMobile, () => {
openMobile.value = false;
});
const isMobile = useMediaQuery("(max-width: 768px)")
const openMobile = ref(false)
const open = useVModel(props, "open", emits, {
defaultValue: props.defaultOpen ?? false,
passive: (props.open === undefined) as false,
}) as Ref<boolean>;
defaultValue: props.defaultOpen ?? false,
passive: (props.open === undefined) as false,
}) as Ref<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.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
}
function setOpenMobile(value: boolean) {
openMobile.value = value;
openMobile.value = value
}
// Helper to toggle the sidebar.
function toggleSidebar() {
return isMobile.value
? setOpenMobile(!openMobile.value)
: setOpen(!open.value);
return isMobile.value ? setOpenMobile(!openMobile.value) : setOpen(!open.value)
}
useEventListener("keydown", (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
});
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
toggleSidebar()
}
})
// 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.
const state = computed(() => (open.value ? "expanded" : "collapsed"));
const state = computed(() => open.value ? "expanded" : "collapsed")
provideSidebarContext({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
});
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
})
</script>
<template>
<TooltipProvider :delay-duration="0">
<div
data-slot="sidebar-wrapper"
:style="{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
}"
:class="
cn(
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
props.class,
)
"
v-bind="$attrs"
>
<slot />
</div>
</TooltipProvider>
<TooltipProvider :delay-duration="0">
<div
data-slot="sidebar-wrapper"
:style="{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
}"
:class="cn('group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full', props.class)"
v-bind="$attrs"
>
<slot />
</div>
</TooltipProvider>
</template>
@@ -1,44 +1,28 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { PanelLeftClose, PanelLeftOpen } from "lucide-vue-next";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useSidebar } from "./utils";
import { useLocalization } from "@/composables/useLocalization";
import type { HTMLAttributes } from "vue"
import { PanelLeftClose, PanelLeftOpen } from "lucide-vue-next"
import { cn } from "@/lib/utils"
import { Button } from '@/components/ui/button'
import { useSidebar } from "./utils"
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
class?: HTMLAttributes["class"]
}>()
const { isMobile, state, openMobile, toggleSidebar } = useSidebar();
const { t } = useLocalization();
const { isMobile, state, toggleSidebar } = useSidebar()
</script>
<template>
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
type="button"
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"
>
<PanelLeftOpen
v-if="isMobile || state === 'collapsed'"
aria-hidden="true"
/>
<PanelLeftClose v-else aria-hidden="true" />
</Button>
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
:class="cn('h-7 w-7', props.class)"
@click="toggleSidebar"
>
<PanelLeftOpen v-if="isMobile || state === 'collapsed'" />
<PanelLeftClose v-else />
<span class="sr-only">Toggle sidebar</span>
</Button>
</template>
+10 -11
View File
@@ -1,8 +1,8 @@
import { createContext } from "reka-ui";
import type { ComputedRef, Ref } from "vue";
import { createContext } from "reka-ui"
import type { ComputedRef, Ref } from 'vue';
export type SidebarContext = {
state: ComputedRef<"expanded" | "collapsed">;
state: ComputedRef<'expanded' | 'collapsed'>;
open: Ref<boolean>;
setOpen: (value: boolean) => void;
isMobile: Ref<boolean>;
@@ -11,12 +11,11 @@ export type SidebarContext = {
toggleSidebar: () => void;
};
export const SIDEBAR_COOKIE_NAME = "sidebar_state";
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
export const SIDEBAR_WIDTH = "16rem";
export const SIDEBAR_WIDTH_MOBILE = "18rem";
export const SIDEBAR_WIDTH_ICON = "4rem";
export const SIDEBAR_KEYBOARD_SHORTCUT = "b";
export const SIDEBAR_COOKIE_NAME = "sidebar_state"
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
export const SIDEBAR_WIDTH = "16rem"
export const SIDEBAR_WIDTH_MOBILE = "18rem"
export const SIDEBAR_WIDTH_ICON = "3rem"
export const SIDEBAR_KEYBOARD_SHORTCUT = "b"
export const [useSidebar, provideSidebarContext] =
createContext<SidebarContext>("Sidebar");
export const [useSidebar, provideSidebarContext] = createContext<SidebarContext>("Sidebar")
+10 -16
View File
@@ -1,23 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { Loader2Icon } from "lucide-vue-next";
import { computed } from "vue";
import { useLocalization } from "@/composables/useLocalization";
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "vue"
import { Loader2Icon } from "lucide-vue-next"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"];
label?: string;
}>();
const { t } = useLocalization();
const accessibleLabel = computed(() => props.label ?? t("common.loading"));
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<Loader2Icon
role="status"
:aria-label="accessibleLabel"
:class="cn('size-4 animate-spin', props.class)"
/>
<Loader2Icon
role="status"
aria-label="Loading"
:class="cn('size-4 animate-spin', props.class)"
/>
</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),
),
};
}
+95 -78
View File
@@ -1,107 +1,124 @@
import type { App, ComputedRef, InjectionKey, Ref } from 'vue';
import { computed, inject, readonly, ref } from 'vue';
import type { ComputedRef, Ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import type { Appearance, ResolvedAppearance } from '@/types';
export type { Appearance, ResolvedAppearance };
export type UseAppearanceReturn = {
appearance: Readonly<Ref<Appearance>>;
appearance: Ref<Appearance>;
resolvedAppearance: ComputedRef<ResolvedAppearance>;
updateAppearance: (value: Appearance) => void;
};
const appearanceKey: InjectionKey<UseAppearanceReturn> = Symbol('appearance');
export function updateTheme(value: Appearance): void {
if (typeof window === 'undefined') {
return;
}
export function normalizeAppearance(value: unknown): Appearance {
return value === 'light' || value === 'dark' ? value : 'system';
}
if (value === 'system') {
const mediaQueryList = window.matchMedia(
'(prefers-color-scheme: dark)',
);
const systemTheme = mediaQueryList.matches ? 'dark' : 'light';
/** Each Vue app owns its state, including each server-rendered request. */
export function createAppearance(initialAppearance: Appearance = 'system') {
const appearance = ref<Appearance>(normalizeAppearance(initialAppearance));
const systemDark = ref(false);
const resolvedAppearance = computed<ResolvedAppearance>(() =>
appearance.value === 'system'
? systemDark.value
? 'dark'
: 'light'
: appearance.value,
);
function applyTheme(): void {
document.documentElement.classList.toggle(
'dark',
resolvedAppearance.value === 'dark',
systemTheme === 'dark',
);
document.documentElement.style.colorScheme = resolvedAppearance.value;
document.documentElement.dataset.appearance = appearance.value;
} else {
document.documentElement.classList.toggle('dark', value === 'dark');
}
}
const setCookie = (name: string, value: string, days = 365) => {
if (typeof document === 'undefined') {
return;
}
function updateAppearance(value: Appearance): void {
appearance.value = normalizeAppearance(value);
if (typeof window === 'undefined') {
return;
}
const maxAge = days * 24 * 60 * 60;
applyTheme();
try {
window.localStorage.setItem('appearance', appearance.value);
} catch {
// Private or embedded browsers may deny storage; the current theme still works.
}
document.cookie = `appearance=${appearance.value};path=/;max-age=31536000;SameSite=Lax`;
document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`;
};
const mediaQuery = () => {
if (typeof window === 'undefined') {
return null;
}
function handleSystemChange(event: MediaQueryListEvent): void {
systemDark.value = event.matches;
applyTheme();
return window.matchMedia('(prefers-color-scheme: dark)');
};
const getStoredAppearance = () => {
if (typeof window === 'undefined') {
return null;
}
function handleStorageChange(event: StorageEvent): void {
if (event.key === 'appearance' || event.key === null) {
updateAppearance(normalizeAppearance(event.newValue));
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 {
appearance: readonly(appearance),
appearance,
resolvedAppearance,
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 };
}
+32 -14
View File
@@ -3,7 +3,6 @@ import { usePage } from '@inertiajs/vue3';
import type { ComputedRef, DeepReadonly } from 'vue';
import { computed, readonly } from 'vue';
import { toUrl } from '@/lib/utils';
import { matchesNavigationUrl } from '@/lib/navigation';
export type UseCurrentUrlReturn = {
currentUrl: DeepReadonly<ComputedRef<string>>;
@@ -23,21 +22,40 @@ export type UseCurrentUrlReturn = {
) => T | F;
};
const page = usePage();
const currentUrlReactive = computed(
() =>
new URL(
page.url,
typeof window !== 'undefined'
? window.location.origin
: 'http://localhost',
).pathname,
);
export function useCurrentUrl(): UseCurrentUrlReturn {
const page = usePage();
const currentUrlReactive = computed(
() => new URL(page.url, 'http://localhost').pathname,
);
function isCurrentUrl(
urlToCheck: NonNullable<InertiaLinkProps['href']>,
currentUrl?: string,
startsWith: boolean = false,
) {
return matchesNavigationUrl(
toUrl(urlToCheck),
currentUrl ?? currentUrlReactive.value,
startsWith,
);
const urlToCompare = currentUrl ?? currentUrlReactive.value;
const urlString = toUrl(urlToCheck);
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(
@@ -47,11 +65,11 @@ export function useCurrentUrl(): UseCurrentUrlReturn {
return isCurrentUrl(urlToCheck, currentUrl, true);
}
function whenCurrentUrl<T, F = null>(
function whenCurrentUrl(
urlToCheck: NonNullable<InertiaLinkProps['href']>,
ifTrue: T,
ifFalse: F = null as F,
): T | F {
ifTrue: any,
ifFalse: any = null,
) {
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 };
}
+2 -17
View File
@@ -3,8 +3,6 @@ import AppContent from '@/components/AppContent.vue';
import AppShell from '@/components/AppShell.vue';
import AppSidebar from '@/components/AppSidebar.vue';
import AppSidebarHeader from '@/components/AppSidebarHeader.vue';
import ProjectNavigation from '@/components/ProjectNavigation.vue';
import { useLocalization } from '@/composables/useLocalization';
import type { BreadcrumbItem } from '@/types';
type Props = {
@@ -14,27 +12,14 @@ type Props = {
withDefaults(defineProps<Props>(), {
breadcrumbs: () => [],
});
const { t } = useLocalization();
</script>
<template>
<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 />
<AppContent variant="sidebar" class="min-w-0">
<AppContent variant="sidebar" class="overflow-x-hidden">
<AppSidebarHeader :breadcrumbs="breadcrumbs" />
<ProjectNavigation />
<div
id="main-content"
tabindex="-1"
class="flex min-w-0 flex-1 flex-col"
>
<slot />
</div>
<slot />
</AppContent>
</AppShell>
</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">
import { Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import Heading from '@/components/Heading.vue';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization';
import { toUrl } from '@/lib/utils';
import { edit as editAppearance } from '@/routes/appearance/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 type { NavItem } from '@/types';
const { t } = useLocalization();
const sidebarNavItems = computed<NavItem[]>(() => [
const sidebarNavItems: NavItem[] = [
{
title: t('navigation.profile'),
title: 'Profile',
href: editProfile(),
},
{
title: t('navigation.security'),
title: 'Security',
href: editSecurity(),
},
{
title: t('navigation.gitConnections'),
title: 'Git connections',
href: editGitConnections(),
},
{
title: t('navigation.applications'),
title: 'Applications',
href: editApplications(),
},
{
title: t('navigation.appearance'),
title: 'Appearance',
href: editAppearance(),
},
]);
];
const { isCurrentOrParentUrl } = useCurrentUrl();
</script>
@@ -44,34 +41,27 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
<template>
<div class="px-4 py-6">
<Heading
:title="t('navigation.settings')"
:description="t('navigation.settingsDescription')"
title="Settings"
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">
<aside class="w-full min-w-0 lg:w-52 lg:shrink-0">
<div class="flex flex-col lg:flex-row lg:space-x-12">
<aside class="w-full max-w-xl lg:w-48">
<nav
class="flex gap-1 overflow-x-auto lg:flex-col"
:aria-label="t('navigation.settings')"
class="flex flex-col space-x-0 space-y-1"
aria-label="Settings"
>
<Button
v-for="item in sidebarNavItems"
:key="toUrl(item.href)"
variant="ghost"
:class="[
'shrink-0 justify-start lg:w-full',
'w-full justify-start',
{ 'bg-muted': isCurrentOrParentUrl(item.href) },
]"
as-child
>
<Link
:href="item.href"
:aria-current="
isCurrentOrParentUrl(item.href)
? 'page'
: undefined
"
>
<Link :href="item.href">
<component :is="item.icon" class="h-4 w-4" />
{{ item.title }}
</Link>
@@ -79,9 +69,9 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
</nav>
</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">
<slot />
</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;
+32 -196
View File
@@ -1,211 +1,47 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import {
ArrowUpRight,
FolderGit2,
ListTodo,
OctagonAlert,
} from 'lucide-vue-next';
import { computed } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { TicketCard } from '@/components/tickets';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useLocalization } from '@/composables/useLocalization';
import { Head } from '@inertiajs/vue3';
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import { dashboard } from '@/routes/index';
import { index as projectsIndex, show as showProject } from '@/routes/projects';
import type { BreadcrumbItem, DashboardPageProps } from '@/types';
import type { BreadcrumbItem } from '@/types';
const props = defineProps<DashboardPageProps>();
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.dashboard'), href: dashboard() },
]);
const assignedTickets = computed(() =>
props.assignedTicketsByProject.flatMap((group) => group.tickets),
);
const summaryItems = computed(() => [
const breadcrumbs: BreadcrumbItem[] = [
{
label: t('dashboard.summary.assignedTickets'),
value: assignedTickets.value.length,
icon: ListTodo,
title: 'Dashboard',
href: dashboard(),
},
{
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>
<template>
<Head title="Dashboard" />
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="t('navigation.dashboard')" />
<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">
<CardContent
class="flex min-w-0 items-center justify-between gap-4 p-4"
>
<div class="min-w-0">
<p
class="text-caption text-muted-foreground break-words"
>
{{ item.label }}
</p>
<p class="text-title mt-1 tabular-nums">
{{ item.value }}
</p>
</div>
<span
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-md"
>
<component
:is="item.icon"
class="size-5"
aria-hidden="true"
/>
</span>
</CardContent>
</Card>
</li>
</ul>
</section>
<section
aria-labelledby="assigned-work-title"
class="gap-column grid min-w-0"
>
<header class="gap-stack grid min-w-0">
<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')"
<div
class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4"
>
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
<div
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
>
<Button as-child variant="outline">
<Link :href="projectsIndex()">
<FolderGit2 aria-hidden="true" />
{{ t('dashboard.empty.action') }}
</Link>
</Button>
</FeedbackState>
<div v-else class="gap-page grid min-w-0">
<section
v-for="group in assignedTicketsByProject"
:key="group.project.id"
:aria-labelledby="`dashboard-project-${group.project.id}`"
class="gap-column grid min-w-0"
>
<header
class="flex min-w-0 flex-col gap-3 border-b pb-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="flex min-w-0 items-center gap-3">
<Badge
variant="outline"
class="max-w-full shrink-0 whitespace-normal break-all"
>
{{ group.project.key }}
</Badge>
<div class="min-w-0">
<h3
:id="`dashboard-project-${group.project.id}`"
class="text-body break-words font-semibold"
>
{{ group.project.name }}
</h3>
<p
class="text-caption text-muted-foreground"
>
{{
t('tickets.count', {
count: group.tickets.length,
})
}}
</p>
</div>
</div>
<Button
as-child
variant="link"
class="self-start sm:self-auto"
>
<Link
:href="showProject(group.project.id)"
:aria-label="
t('dashboard.openProject', {
name: group.project.name,
})
"
>
{{ t('dashboard.openProjectAction') }}
<ArrowUpRight aria-hidden="true" />
</Link>
</Button>
</header>
<ul
:aria-label="
t('dashboard.projectTickets', {
name: group.project.name,
})
"
class="gap-column grid min-w-0 md:grid-cols-2 2xl:grid-cols-3"
>
<li
v-for="ticket in group.tickets"
:key="ticket.id"
class="min-w-0"
>
<TicketCard
:project-id="group.project.id"
:ticket="ticket"
/>
</li>
</ul>
</section>
<PlaceholderPattern />
</div>
</section>
<div
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
>
<PlaceholderPattern />
</div>
<div
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
>
<PlaceholderPattern />
</div>
</div>
<div
class="border-sidebar-border/70 dark:border-sidebar-border relative min-h-[100vh] flex-1 rounded-xl border md:min-h-min"
>
<PlaceholderPattern />
</div>
</div>
</AppLayout>
</template>
+39 -64
View File
@@ -1,77 +1,52 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { ShieldCheck } from 'lucide-vue-next';
import { onUnmounted } from 'vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import { Form, Head } from '@inertiajs/vue3';
import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/AuthLayout.vue';
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>
<template>
<AuthFlowLayout
:title="t('auth.confirm.title')"
:description="t('auth.confirm.description')"
:icon="ShieldCheck"
<AuthLayout
title="Confirm your password"
description="This is a secure area of the application. Please confirm your password before continuing."
>
<Head :title="t('auth.confirm.title')" />
<form
:action="store.url()"
method="post"
class="grid gap-5"
@submit.prevent="submit"
<Head title="Confirm password" />
<Form
v-bind="store.form()"
reset-on-success
v-slot="{ errors, processing }"
>
<AuthFormFeedback
:state="protection.state.value"
:response-status="protection.responseStatus.value"
:loading-title="t('auth.confirm.pending')"
@retry="submit"
/>
<div class="gap-stack grid">
<Label for="password">{{ t('auth.confirm.password') }}</Label>
<PasswordInput
id="password"
v-model="form.password"
name="password"
required
autocomplete="current-password"
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"
/>
<div class="space-y-6">
<div class="grid gap-2">
<Label htmlFor="password">Password</Label>
<PasswordInput
id="password"
name="password"
class="mt-1 block w-full"
required
autocomplete="current-password"
autofocus
/>
<InputError :message="errors.password" />
</div>
<div class="flex items-center">
<Button
class="w-full"
:disabled="processing"
data-test="confirm-password-button"
>
<Spinner v-if="processing" />
Confirm password
</Button>
</div>
</div>
<Button
type="submit"
class="w-full"
:loading="form.processing"
:loading-label="t('auth.confirm.pending')"
data-test="confirm-password-button"
>{{ t('auth.confirm.submit') }}</Button
>
</form>
<template #footer>{{ t('auth.confirm.footer') }}</template>
</AuthFlowLayout>
</Form>
</AuthLayout>
</template>
+102 -157
View File
@@ -1,170 +1,115 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { KeyRound } from 'lucide-vue-next';
import { computed, onUnmounted } from 'vue';
import { Form, Head, Link } from '@inertiajs/vue3';
import { LogOut } from 'lucide-vue-next';
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 PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { logout } from '@/routes/index';
import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { logout } from '@/routes';
withDefaults(defineProps<{ status?: string | null }>(), { status: null });
const { t } = useLocalization();
const form = useForm({
current_password: '',
password: '',
password_confirmation: '',
}).dontRemember('current_password', 'password', 'password_confirmation');
const logoutForm = useForm({});
const protection = useFormProtection(form, { isDirty: () => false });
const logoutProtection = useFormProtection(logoutForm);
const busy = computed(() => form.processing || logoutForm.processing);
const submit = (): void =>
protection.submit(SecurityController.update(), {
onSuccess: () => form.reset(),
});
const signOut = (): void => {
if (busy.value) {
return;
}
form.reset();
logoutProtection.submit(logout());
};
onUnmounted(() => form.reset());
withDefaults(
defineProps<{
status?: string | null;
}>(),
{
status: null,
},
);
</script>
<template>
<AuthFlowLayout
:title="t('auth.forceChange.title')"
:description="t('auth.forceChange.description')"
:icon="KeyRound"
<AuthLayout
title="Change your password"
description="You must update your password before you can continue using the application."
>
<Head :title="t('auth.forceChange.title')" />
<FeedbackState
v-if="status"
state="success"
:title="t('auth.forceChange.statusTitle')"
:description="status"
/>
<form
:action="SecurityController.update.url()"
method="post"
class="grid gap-5"
@submit.prevent="submit"
>
<AuthFormFeedback
:state="protection.state.value"
:response-status="protection.responseStatus.value"
:loading-title="t('auth.forceChange.pending')"
@retry="submit"
/>
<div class="gap-stack grid">
<Label for="current_password">{{
t('auth.forceChange.currentPassword')
}}</Label>
<PasswordInput
id="current_password"
v-model="form.current_password"
name="current_password"
required
autocomplete="current-password"
autofocus
:disabled="busy"
:invalid="Boolean(form.errors.current_password)"
aria-describedby="current-password-error"
/>
<InputError
id="current-password-error"
:message="form.errors.current_password"
/>
</div>
<div class="gap-stack grid">
<Label for="password">{{
t('auth.forceChange.password')
}}</Label>
<PasswordInput
id="password"
v-model="form.password"
name="password"
required
autocomplete="new-password"
:disabled="busy"
:invalid="Boolean(form.errors.password)"
aria-describedby="new-password-hint new-password-error"
/>
<p
id="new-password-hint"
class="text-caption text-muted-foreground"
>
{{ t('auth.forceChange.passwordHint') }}
</p>
<InputError
id="new-password-error"
:message="form.errors.password"
/>
</div>
<div class="gap-stack grid">
<Label for="password_confirmation">{{
t('auth.forceChange.passwordConfirmation')
}}</Label>
<PasswordInput
id="password_confirmation"
v-model="form.password_confirmation"
name="password_confirmation"
required
autocomplete="new-password"
:disabled="busy"
:invalid="Boolean(form.errors.password_confirmation)"
aria-describedby="new-password-confirmation-error"
/>
<InputError
id="new-password-confirmation-error"
:message="form.errors.password_confirmation"
/>
</div>
<Button
type="submit"
class="w-full"
:disabled="logoutForm.processing"
:loading="form.processing"
:loading-label="t('auth.forceChange.pending')"
data-test="force-password-change-button"
>{{ t('auth.forceChange.submit') }}</Button
>
</form>
<div class="gap-stack pt-column grid border-t">
<p class="text-body text-muted-foreground">
{{ t('auth.forceChange.wrongAccount') }}
</p>
<form
:action="logout.url()"
method="post"
class="gap-card grid"
@submit.prevent="signOut"
>
<AuthFormFeedback
:state="logoutProtection.state.value"
:response-status="logoutProtection.responseStatus.value"
:loading-title="t('auth.signingOut')"
@retry="signOut"
/>
<Button
type="submit"
variant="outline"
class="w-full"
:disabled="form.processing"
:loading="logoutForm.processing"
:loading-label="t('auth.signingOut')"
>{{ t('auth.signOut') }}</Button
>
</form>
<Head title="Change your password" />
<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>
<template #footer>{{ t('auth.forceChange.footer') }}</template>
</AuthFlowLayout>
<div
v-if="status"
class="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900"
>
{{ status }}
</div>
<Form
v-bind="SecurityController.update.form()"
reset-on-success
:reset-on-error="[
'password',
'password_confirmation',
'current_password',
]"
v-slot="{ errors, processing }"
>
<div class="space-y-6">
<div class="grid gap-2">
<Label htmlFor="current_password">Current password</Label>
<PasswordInput
id="current_password"
name="current_password"
class="mt-1 block w-full"
required
autocomplete="current-password"
autofocus
placeholder="Current password"
/>
<InputError :message="errors.current_password" />
</div>
<div class="grid gap-2">
<Label htmlFor="password">New password</Label>
<PasswordInput
id="password"
name="password"
class="mt-1 block w-full"
required
autocomplete="new-password"
placeholder="New password"
/>
<InputError :message="errors.password" />
</div>
<div class="grid gap-2">
<Label htmlFor="password_confirmation">
Confirm new password
</Label>
<PasswordInput
id="password_confirmation"
name="password_confirmation"
class="mt-1 block w-full"
required
autocomplete="new-password"
placeholder="Confirm new password"
/>
<InputError :message="errors.password_confirmation" />
</div>
<div class="flex items-center">
<Button
class="w-full"
:disabled="processing"
data-test="force-password-change-button"
>
<Spinner v-if="processing" />
Save new password
</Button>
</div>
</div>
</Form>
</AuthLayout>
</template>
+48 -72
View File
@@ -1,89 +1,65 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { Mail } from 'lucide-vue-next';
import { onUnmounted } from 'vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { Form, Head } from '@inertiajs/vue3';
import InputError from '@/components/InputError.vue';
import TextLink from '@/components/TextLink.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { login } from '@/routes/index';
import { email } from '@/routes/password';
defineProps<{ status?: string }>();
const { t } = useLocalization();
const form = useForm({ email: '' }).dontRemember('email');
const protection = useFormProtection(form, { isDirty: () => false });
const submit = (): void => protection.submit(email());
onUnmounted(() => form.reset());
defineProps<{
status?: string;
}>();
</script>
<template>
<AuthFlowLayout
:title="t('auth.forgot.title')"
:description="t('auth.forgot.description')"
:icon="Mail"
<AuthLayout
title="Forgot password"
description="Enter your email to receive a password reset link"
>
<Head :title="t('auth.forgot.title')" />
<FeedbackState
<Head title="Forgot password" />
<div
v-if="status"
state="success"
:title="t('auth.forgot.sentTitle')"
:description="status"
/>
<form
:action="email.url()"
method="post"
class="grid gap-5"
@submit.prevent="submit"
class="mb-4 text-center text-sm font-medium text-green-600"
>
<AuthFormFeedback
:state="protection.state.value"
:response-status="protection.responseStatus.value"
:loading-title="t('auth.forgot.pending')"
@retry="submit"
/>
<div class="gap-stack grid">
<Label for="email">{{ t('auth.forgot.email') }}</Label>
<Input
id="email"
v-model="form.email"
name="email"
type="email"
inputmode="email"
autocomplete="email"
autocapitalize="none"
:spellcheck="false"
required
autofocus
:disabled="form.processing"
: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">
{{ t('auth.forgot.emailHint') }}
</p>
<InputError id="email-error" :message="form.errors.email" />
{{ status }}
</div>
<div class="space-y-6">
<Form v-bind="email.form()" v-slot="{ errors, processing }">
<div class="grid gap-2">
<Label for="email">Email address</Label>
<Input
id="email"
type="email"
name="email"
autocomplete="off"
autofocus
placeholder="email@example.com"
/>
<InputError :message="errors.email" />
</div>
<div class="my-6 flex items-center justify-start">
<Button
class="w-full"
:disabled="processing"
data-test="email-password-reset-link-button"
>
<Spinner v-if="processing" />
Email password reset link
</Button>
</div>
</Form>
<div class="text-muted-foreground space-x-1 text-center text-sm">
<span>Or, return to</span>
<TextLink :href="login()">log in</TextLink>
</div>
<Button
type="submit"
class="w-full"
:loading="form.processing"
:loading-label="t('auth.forgot.pending')"
data-test="email-password-reset-link-button"
>{{ t('auth.forgot.submit') }}</Button
>
</form>
<template #footer
><TextLink :href="login()">{{
t('auth.backToLogin')
}}</TextLink></template
>
</AuthFlowLayout>
</div>
</AuthLayout>
</template>
+79 -103
View File
@@ -1,9 +1,5 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { LogIn } from 'lucide-vue-next';
import { onUnmounted } from 'vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { Form, Head } from '@inertiajs/vue3';
import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue';
import TextLink from '@/components/TextLink.vue';
@@ -11,114 +7,94 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { Spinner } from '@/components/ui/spinner';
import AuthBase from '@/layouts/AuthLayout.vue';
import { store } from '@/routes/login';
import { request } from '@/routes/password';
defineProps<{ status?: string; canResetPassword: boolean }>();
const { t } = useLocalization();
// Credentials stay in memory only; no remembered form key or browser storage.
const form = useForm({ email: '', password: '', remember: false }).dontRemember(
'password',
);
const protection = useFormProtection(form, { isDirty: () => false });
const submit = (): void =>
protection.submit(store(), {
onSuccess: () => form.reset('password'),
});
onUnmounted(() => form.reset('password'));
defineProps<{
status?: string;
canResetPassword: boolean;
}>();
</script>
<template>
<AuthFlowLayout
:title="t('auth.login.title')"
:description="t('auth.login.description')"
:icon="LogIn"
<AuthBase
title="Log in to your account"
description="Enter your email and password below to log in"
>
<Head :title="t('auth.login.submit')" />
<FeedbackState
<Head title="Log in" />
<div
v-if="status"
state="success"
:title="t('auth.login.statusTitle')"
:description="status"
/>
<form
:action="store.url()"
method="post"
class="grid gap-5"
@submit.prevent="submit"
class="mb-4 text-center text-sm font-medium text-green-600"
>
<AuthFormFeedback
:state="protection.state.value"
:response-status="protection.responseStatus.value"
:loading-title="t('auth.login.pending')"
@retry="submit"
/>
<div class="gap-stack grid">
<Label for="email">{{ t('auth.login.email') }}</Label>
<Input
id="email"
v-model="form.email"
name="email"
type="email"
inputmode="email"
autocomplete="username"
autocapitalize="none"
:spellcheck="false"
required
autofocus
:disabled="form.processing"
:placeholder="t('auth.login.emailPlaceholder')"
:invalid="Boolean(form.errors.email)"
aria-describedby="email-error"
/>
<InputError id="email-error" :message="form.errors.email" />
</div>
<div class="gap-stack grid">
<Label for="password">{{ t('auth.login.password') }}</Label>
<PasswordInput
id="password"
v-model="form.password"
name="password"
autocomplete="current-password"
required
:disabled="form.processing"
:invalid="Boolean(form.errors.password)"
aria-describedby="password-error"
/>
<InputError
id="password-error"
:message="form.errors.password"
/>
</div>
<div class="gap-card flex flex-wrap items-center justify-between">
<Label for="remember" class="min-h-control gap-stack">
<Checkbox
id="remember"
v-model="form.remember"
name="remember"
:disabled="form.processing"
{{ status }}
</div>
<Form
v-bind="store.form()"
:reset-on-success="['password']"
v-slot="{ errors, processing }"
class="flex flex-col gap-6"
>
<div class="grid gap-6">
<div class="grid gap-2">
<Label for="email">Email address</Label>
<Input
id="email"
type="email"
name="email"
required
autofocus
:tabindex="1"
autocomplete="email"
placeholder="email@example.com"
/>
{{ t('auth.login.remember') }}
</Label>
<TextLink
v-if="canResetPassword"
:href="request()"
class="min-h-control text-body inline-flex items-center"
>{{ t('auth.login.forgot') }}</TextLink
<InputError :message="errors.email" />
</div>
<div class="grid gap-2">
<div class="flex items-center justify-between">
<Label for="password">Password</Label>
<TextLink
v-if="canResetPassword"
:href="request()"
class="text-sm"
:tabindex="5"
>
Forgot password?
</TextLink>
</div>
<PasswordInput
id="password"
name="password"
required
:tabindex="2"
autocomplete="current-password"
placeholder="Password"
/>
<InputError :message="errors.password" />
</div>
<div class="flex items-center justify-between">
<Label for="remember" class="flex items-center space-x-3">
<Checkbox id="remember" name="remember" :tabindex="3" />
<span>Remember me</span>
</Label>
</div>
<Button
type="submit"
class="mt-4 w-full"
:tabindex="4"
:disabled="processing"
data-test="login-button"
>
<Spinner v-if="processing" />
Log in
</Button>
</div>
<Button
type="submit"
class="w-full"
:loading="form.processing"
:loading-label="t('auth.login.pending')"
data-test="login-button"
>{{ t('auth.login.submit') }}</Button
>
</form>
<template #footer>{{ t('auth.login.help') }}</template>
</AuthFlowLayout>
</Form>
</AuthBase>
</template>
+73 -121
View File
@@ -1,136 +1,88 @@
<script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3';
import { KeyRound } from 'lucide-vue-next';
import { computed, onUnmounted } from 'vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { Form, Head } from '@inertiajs/vue3';
import { ref } from 'vue';
import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { request, update } from '@/routes/password';
import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { update } from '@/routes/password';
const props = defineProps<{ token: string; email: string }>();
const { t } = useLocalization();
const form = useForm({
token: props.token,
email: props.email,
password: '',
password_confirmation: '',
}).dontRemember('token', 'email', 'password', 'password_confirmation');
const protection = useFormProtection(form, { isDirty: () => false });
const invalidLink = computed(() => Boolean(form.errors.email));
const feedbackState = computed(() =>
invalidLink.value ? 'idle' : protection.state.value,
);
const submit = (): void =>
protection.submit(update(), {
onSuccess: () => form.reset('password', 'password_confirmation'),
});
onUnmounted(() => form.reset());
const props = defineProps<{
token: string;
email: string;
}>();
const inputEmail = ref(props.email);
</script>
<template>
<AuthFlowLayout
:title="t('auth.reset.title')"
:description="t('auth.reset.description')"
:icon="KeyRound"
<AuthLayout
title="Reset password"
description="Please enter your new password below"
>
<Head :title="t('auth.reset.title')" />
<FeedbackState
v-if="invalidLink"
state="error"
:title="t('auth.reset.invalidTitle')"
:description="form.errors.email"
<Head title="Reset password" />
<Form
v-bind="update.form()"
:transform="(data) => ({ ...data, token, email })"
:reset-on-success="['password', 'password_confirmation']"
v-slot="{ errors, processing }"
>
<Button as-child variant="outline" class="w-full">
<Link :href="request()">{{ t('auth.reset.requestNew') }}</Link>
</Button>
</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
id="email"
v-model="form.email"
name="email"
type="email"
autocomplete="username"
readonly
:disabled="form.processing"
:invalid="Boolean(form.errors.email)"
aria-describedby="email-error"
/>
<InputError id="email-error" :message="form.errors.email" />
</div>
<div class="gap-stack grid">
<Label for="password">{{ t('auth.reset.password') }}</Label>
<PasswordInput
id="password"
v-model="form.password"
name="password"
autocomplete="new-password"
required
autofocus
:disabled="form.processing"
:invalid="Boolean(form.errors.password)"
aria-describedby="password-hint password-error"
/>
<p
id="password-hint"
class="text-caption text-muted-foreground"
<div class="grid gap-6">
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input
id="email"
type="email"
name="email"
autocomplete="email"
v-model="inputEmail"
class="mt-1 block w-full"
readonly
/>
<InputError :message="errors.email" class="mt-2" />
</div>
<div class="grid gap-2">
<Label for="password">Password</Label>
<PasswordInput
id="password"
name="password"
autocomplete="new-password"
class="mt-1 block w-full"
autofocus
placeholder="Password"
/>
<InputError :message="errors.password" />
</div>
<div class="grid gap-2">
<Label for="password_confirmation">
Confirm password
</Label>
<PasswordInput
id="password_confirmation"
name="password_confirmation"
autocomplete="new-password"
class="mt-1 block w-full"
placeholder="Confirm password"
/>
<InputError :message="errors.password_confirmation" />
</div>
<Button
type="submit"
class="mt-4 w-full"
:disabled="processing"
data-test="reset-password-button"
>
{{ t('auth.reset.passwordHint') }}
</p>
<InputError
id="password-error"
:message="form.errors.password"
/>
<Spinner v-if="processing" />
Reset password
</Button>
</div>
<div class="gap-stack grid">
<Label for="password_confirmation">{{
t('auth.reset.passwordConfirmation')
}}</Label>
<PasswordInput
id="password_confirmation"
v-model="form.password_confirmation"
name="password_confirmation"
autocomplete="new-password"
required
:disabled="form.processing"
:invalid="Boolean(form.errors.password_confirmation)"
aria-describedby="password-confirmation-error"
/>
<InputError
id="password-confirmation-error"
:message="form.errors.password_confirmation"
/>
</div>
<Button
type="submit"
class="w-full"
:loading="form.processing"
:loading-label="t('auth.reset.pending')"
data-test="reset-password-button"
>{{ t('auth.reset.submit') }}</Button
>
</form>
<template #footer>{{ t('auth.reset.footer') }}</template>
</AuthFlowLayout>
</Form>
</AuthLayout>
</template>
+119 -159
View File
@@ -1,173 +1,133 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { KeyRound, ShieldCheck } from 'lucide-vue-next';
import { computed, nextTick, onUnmounted, ref, useTemplateRef } from 'vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import { Form, Head } from '@inertiajs/vue3';
import { computed, ref } from 'vue';
import InputError from '@/components/InputError.vue';
import TextLink from '@/components/TextLink.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { login } from '@/routes/index';
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from '@/components/ui/input-otp';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { store } from '@/routes/two-factor/login';
import type { TwoFactorConfigContent } from '@/types';
const { t } = useLocalization();
const recovery = ref(false);
const form = useForm({ code: '', recovery_code: '' }).dontRemember(
'code',
'recovery_code',
);
const protection = useFormProtection(form, { isDirty: () => false });
const codeInput = useTemplateRef('codeInput');
const recoveryInput = useTemplateRef('recoveryInput');
const fieldError = computed(() =>
recovery.value
? (form.errors.recovery_code ?? form.errors.code)
: form.errors.code,
);
const toggleMode = async (): Promise<void> => {
if (form.processing) {
return;
const authConfigContent = computed<TwoFactorConfigContent>(() => {
if (showRecoveryInput.value) {
return {
title: 'Recovery code',
description:
'Please confirm access to your account by entering one of your emergency recovery codes.',
buttonText: 'login using an authentication code',
};
}
recovery.value = !recovery.value;
form.resetAndClearErrors();
protection.clearFeedback();
await nextTick();
(recovery.value ? recoveryInput.value : codeInput.value)?.$el?.focus();
return {
title: 'Authentication code',
description:
'Enter the authentication code provided by your authenticator application.',
buttonText: 'login using a recovery code',
};
});
const showRecoveryInput = ref<boolean>(false);
const toggleRecoveryMode = (clearErrors: () => void): void => {
showRecoveryInput.value = !showRecoveryInput.value;
clearErrors();
code.value = '';
};
const pasteCode = (event: ClipboardEvent): void => {
const code = event.clipboardData?.getData('text').replace(/\s/g, '') ?? '';
if (/^[0-9]{6}$/.test(code)) {
event.preventDefault();
form.code = code;
}
};
const submit = (): void => {
form.transform((data) =>
recovery.value
? { recovery_code: data.recovery_code.trim() }
: { code: data.code },
);
protection.submit(store(), { onSuccess: () => form.reset() });
};
onUnmounted(() => form.reset());
const code = ref<string>('');
</script>
<template>
<AuthFlowLayout
:title="
t(
recovery
? 'auth.twoFactor.recoveryTitle'
: 'auth.twoFactor.title',
)
"
:description="
t(
recovery
? 'auth.twoFactor.recoveryDescription'
: 'auth.twoFactor.description',
)
"
:icon="recovery ? KeyRound : ShieldCheck"
<AuthLayout
:title="authConfigContent.title"
:description="authConfigContent.description"
>
<Head :title="t('auth.twoFactor.title')" />
<form
:action="store.url()"
method="post"
class="grid gap-5"
@submit.prevent="submit"
>
<AuthFormFeedback
:state="protection.state.value"
:response-status="protection.responseStatus.value"
:loading-title="t('auth.twoFactor.pending')"
@retry="submit"
/>
<div v-if="!recovery" class="gap-stack grid">
<Label for="code">{{ t('auth.twoFactor.code') }}</Label>
<Input
id="code"
key="code"
ref="codeInput"
v-model="form.code"
name="code"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
pattern="[0-9]{6}"
minlength="6"
maxlength="6"
required
autofocus
:disabled="form.processing"
:invalid="Boolean(fieldError)"
aria-describedby="code-hint code-error"
class="h-14 text-center font-mono text-2xl tracking-[0.35em] md:text-2xl"
@paste="pasteCode"
/>
<p id="code-hint" class="text-caption text-muted-foreground">
{{ t('auth.twoFactor.codeHint') }}
</p>
<InputError id="code-error" :message="fieldError" />
</div>
<div v-else class="gap-stack grid">
<Label for="recovery_code">{{
t('auth.twoFactor.recoveryCode')
}}</Label>
<Input
id="recovery_code"
key="recovery"
ref="recoveryInput"
v-model="form.recovery_code"
name="recovery_code"
type="text"
autocomplete="one-time-code"
autocapitalize="none"
:spellcheck="false"
required
:disabled="form.processing"
:invalid="Boolean(fieldError)"
aria-describedby="recovery-hint recovery-error"
class="font-mono"
/>
<p
id="recovery-hint"
class="text-caption text-muted-foreground"
<Head title="Two-factor authentication" />
<div class="space-y-6">
<template v-if="!showRecoveryInput">
<Form
v-bind="store.form()"
class="space-y-4"
reset-on-error
@error="code = ''"
#default="{ errors, processing, clearErrors }"
>
{{ t('auth.twoFactor.recoveryHint') }}
</p>
<InputError id="recovery-error" :message="fieldError" />
</div>
<Button
type="submit"
class="w-full"
:loading="form.processing"
:loading-label="t('auth.twoFactor.pending')"
>{{ t('auth.twoFactor.submit') }}</Button
>
<Button
type="button"
variant="outline"
class="w-full whitespace-normal"
:disabled="form.processing"
@click="toggleMode"
>{{
t(
recovery
? 'auth.twoFactor.useCode'
: 'auth.twoFactor.useRecovery',
)
}}</Button
>
</form>
<template #footer
><TextLink :href="login()">{{
t('auth.backToLogin')
}}</TextLink></template
>
</AuthFlowLayout>
<input type="hidden" name="code" :value="code" />
<div
class="flex flex-col items-center justify-center space-y-3 text-center"
>
<div class="flex w-full items-center justify-center">
<InputOTP
id="otp"
v-model="code"
:maxlength="6"
:disabled="processing"
autofocus
>
<InputOTPGroup>
<InputOTPSlot
v-for="index in 6"
:key="index"
:index="index - 1"
/>
</InputOTPGroup>
</InputOTP>
</div>
<InputError :message="errors.code" />
</div>
<Button type="submit" class="w-full" :disabled="processing"
>Continue</Button
>
<div class="text-muted-foreground text-center text-sm">
<span>or you can </span>
<button
type="button"
class="text-foreground hover:decoration-current! underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out dark:decoration-neutral-500"
@click="() => toggleRecoveryMode(clearErrors)"
>
{{ authConfigContent.buttonText }}
</button>
</div>
</Form>
</template>
<template v-else>
<Form
v-bind="store.form()"
class="space-y-4"
reset-on-error
#default="{ errors, processing, clearErrors }"
>
<Input
name="recovery_code"
type="text"
placeholder="Enter recovery code"
:autofocus="showRecoveryInput"
required
/>
<InputError :message="errors.recovery_code" />
<Button type="submit" class="w-full" :disabled="processing"
>Continue</Button
>
<div class="text-muted-foreground text-center text-sm">
<span>or you can </span>
<button
type="button"
class="text-foreground hover:decoration-current! underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out dark:decoration-neutral-500"
@click="() => toggleRecoveryMode(clearErrors)"
>
{{ authConfigContent.buttonText }}
</button>
</div>
</Form>
</template>
</div>
</AuthLayout>
</template>
+35 -110
View File
@@ -1,124 +1,49 @@
<script setup lang="ts">
import { Head, router, useForm, usePage } from '@inertiajs/vue3';
import { Mail } from 'lucide-vue-next';
import { computed } from 'vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { Form, Head } from '@inertiajs/vue3';
import TextLink from '@/components/TextLink.vue';
import { Button } from '@/components/ui/button';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { dashboard, logout } from '@/routes/index';
import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { logout } from '@/routes/index';
import { send } from '@/routes/verification';
const props = defineProps<{ status?: string }>();
const { t } = useLocalization();
const page = usePage();
const email = computed(() => page.props.auth.user?.email);
const resendForm = useForm({});
const logoutForm = useForm({});
const resendProtection = useFormProtection(resendForm);
const logoutProtection = useFormProtection(logoutForm);
const busy = computed(() => resendForm.processing || logoutForm.processing);
const sent = computed(
() =>
resendProtection.state.value === 'success' ||
(resendProtection.state.value === 'idle' &&
props.status === 'verification-link-sent'),
);
const resend = (): void => {
if (!busy.value) {
resendProtection.submit(send(), { preserveScroll: true });
}
};
const signOut = (): void => {
if (!busy.value) {
logoutProtection.submit(logout());
}
};
defineProps<{
status?: string;
}>();
</script>
<template>
<AuthFlowLayout
:title="t('auth.verify.title')"
:description="t('auth.verify.description')"
:icon="Mail"
<AuthLayout
title="Verify email"
description="Please verify your email address by clicking on the link we just emailed to you."
>
<Head :title="t('auth.verify.title')" />
<Head title="Email verification" />
<div
v-if="email"
class="gap-stack bg-muted p-card grid rounded-md border"
v-if="status === 'verification-link-sent'"
class="mb-4 text-center text-sm font-medium text-green-600"
>
<p class="text-label text-muted-foreground uppercase tracking-wide">
{{ t('auth.verify.emailLabel') }}
</p>
<p class="text-body break-all font-medium">{{ email }}</p>
A new verification link has been sent to the email address you
provided during registration.
</div>
<FeedbackState
v-if="sent"
state="success"
:title="t('auth.verify.sentTitle')"
:description="t('auth.verify.sentDescription')"
/>
<form
:action="send.url()"
method="post"
class="gap-column grid"
@submit.prevent="resend"
<Form
v-bind="send.form()"
class="space-y-6 text-center"
v-slot="{ processing }"
>
<AuthFormFeedback
:state="
resendProtection.state.value === 'success'
? 'idle'
: resendProtection.state.value
"
:response-status="resendProtection.responseStatus.value"
:loading-title="t('auth.verify.pending')"
@retry="resend"
/>
<Button
type="submit"
class="w-full"
:disabled="busy"
:loading="resendForm.processing"
:loading-label="t('auth.verify.pending')"
>{{ t('auth.verify.resend') }}</Button
<Button :disabled="processing" variant="secondary">
<Spinner v-if="processing" />
Resend verification email
</Button>
<TextLink
:href="logout()"
as="button"
class="mx-auto block text-sm"
>
</form>
<div class="gap-stack pt-column grid border-t">
<p class="text-body text-muted-foreground">
{{ t('auth.verify.help') }}
</p>
<Button
type="button"
variant="outline"
:disabled="busy"
@click="router.visit(dashboard())"
>{{ t('auth.verify.continue') }}</Button
>
</div>
<form
:action="logout.url()"
method="post"
class="gap-card grid"
@submit.prevent="signOut"
>
<AuthFormFeedback
:state="logoutProtection.state.value"
:response-status="logoutProtection.responseStatus.value"
:loading-title="t('auth.signingOut')"
@retry="signOut"
/>
<Button
type="submit"
variant="ghost"
class="w-full"
:disabled="busy"
:loading="logoutForm.processing"
:loading-label="t('auth.signingOut')"
>{{ t('auth.signOut') }}</Button
>
</form>
<template #footer>{{ t('auth.verify.wrongAccount') }}</template>
</AuthFlowLayout>
Log out
</TextLink>
</Form>
</AuthLayout>
</template>
+169 -84
View File
@@ -2,7 +2,6 @@
import { Form, Head, Link } from '@inertiajs/vue3';
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
import Heading from '@/components/Heading.vue';
import { TicketListItem } from '@/components/tickets';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -13,11 +12,13 @@ import {
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { ticketStatusLabel } from '@/lib/tickets';
import { index as boardIndex } from '@/routes/projects/board';
import { index as backlogIndex } from '@/routes/projects/backlog';
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 { TicketModel } from '@/types/models/ticket-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[] => {
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
@@ -112,98 +132,163 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
No backlog tickets exist yet.
</div>
<ul v-else class="gap-column grid p-4">
<TicketListItem
v-for="ticket in tickets"
:key="ticket.id"
:project-id="project.id"
:ticket="ticket"
<div v-else class="overflow-x-auto">
<table
class="divide-border min-w-full divide-y text-sm"
>
<template v-if="canEditTickets" #actions>
<Form
v-bind="
ProjectBoardController.move.form({
project: project.id,
ticket: ticket.id,
})
"
class="flex w-full flex-col gap-2"
v-slot="{ processing }"
>
<input
type="hidden"
name="redirect_to"
value="backlog"
/>
<label
:for="`backlog-ticket-status-${ticket.id}`"
class="text-label text-muted-foreground uppercase"
<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
</label>
<div
class="flex flex-col gap-2 sm:flex-row"
>
<select
:id="`backlog-ticket-status-${ticket.id}`"
name="ticket_status_id"
:disabled="
allowedTargetStatuses(ticket)
.length === 0 || processing
</th>
</tr>
</thead>
<tbody class="divide-border divide-y">
<tr
v-for="ticket in tickets"
:key="ticket.id"
class="bg-background"
>
<td class="px-4 py-4">
<Link
:href="
showTicket({
project: project.id,
ticket: ticket.id,
})
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-h-control min-w-0 flex-1 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
<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 }"
>
<input
type="hidden"
name="redirect_to"
value="backlog"
/>
<select
name="ticket_status_id"
:disabled="
allowedTargetStatuses(
ticket,
).length === 0 || processing
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-w-44 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-if="
allowedTargetStatuses(
ticket,
).length === 0
"
value=""
>
No allowed moves
</option>
<option
v-for="ticketStatus in allowedTargetStatuses(
ticket,
)"
:key="ticketStatus.id"
:value="ticketStatus.id"
>
{{
ticketStatusLabel(
ticketStatus,
)
}}
</option>
</select>
<Button
type="submit"
size="sm"
:disabled="
processing ||
allowedTargetStatuses(
ticket,
).length === 0
"
>
Move
</Button>
<p
v-if="
allowedTargetStatuses(
ticket,
).length === 0
"
value=""
class="text-muted-foreground text-xs sm:basis-full"
>
No allowed moves
</option>
<option
v-for="ticketStatus in allowedTargetStatuses(
ticket,
)"
:key="ticketStatus.id"
:value="ticketStatus.id"
>
{{
ticketStatusLabel(
ticketStatus,
'Unknown status',
)
}}
</option>
</select>
<Button
type="submit"
size="sm"
:disabled="
processing ||
allowedTargetStatuses(ticket)
.length === 0
"
>
Move
</Button>
</div>
<p
v-if="
allowedTargetStatuses(ticket)
.length === 0
"
class="text-caption text-muted-foreground"
>
This status does not allow moving
anywhere else.
</p>
</Form>
</template>
</TicketListItem>
</ul>
This status does not allow
moving anywhere else.
</p>
</Form>
</td>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
+276 -615
View File
@@ -1,248 +1,91 @@
<script setup lang="ts">
import { Form, Head, Link, useForm } from '@inertiajs/vue3';
import { ArrowLeft, GripVertical, ListTodo, Plus } from 'lucide-vue-next';
import { computed, nextTick, ref, watch } from '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 { Form, Head, Link } from '@inertiajs/vue3';
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
import Heading from '@/components/Heading.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useBoardDrag } from '@/composables/useBoardDrag';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { ticketStatusLabel } from '@/lib/tickets';
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 { 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 { TicketModel } from '@/types/models/ticket-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
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 feedback = useFormProtection(moveForm, { isDirty: () => false });
const menuPending = ref<number | null>(null);
const busy = computed(() => moveForm.processing || menuPending.value !== null);
const choice = ref<{ ticketId: number; columnId: number } | null>(null);
const lastMove = ref<{ ticketId: number; statusId: number } | null>(null);
const findTicket = (id: number) =>
props.boardColumns
.flatMap((column) => column.tickets ?? [])
.find((ticket) => ticket.id === id);
const targetsForColumn = (ticket: TicketModel, columnId: number) => {
const statusIds =
props.boardColumns
.find((column) => column.id === columnId)
?.ticket_statuses?.map((status) => status.id) ?? [];
return allowedTargetStatuses(ticket).filter((status) =>
statusIds.includes(status.id),
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: projectIndex(),
},
{
title: props.project.name,
href: show(props.project.id),
},
{
title: 'Board',
href: boardIndex(props.project.id),
},
];
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[] => {
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>
<template>
<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">
<header
class="flex min-w-0 flex-col gap-4 xl:flex-row xl:items-start xl:justify-between"
<div class="flex flex-1 flex-col gap-6 p-4">
<div
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="flex min-w-0 flex-wrap items-center gap-3">
<Badge
variant="outline"
class="wrap-anywhere max-w-full whitespace-normal"
>{{ project.key }}</Badge
>
<p class="text-caption text-muted-foreground">
{{ t('tickets.count', { count: ticketCount }) }}
</p>
</div>
<h1
class="text-headline-mobile md:text-headline wrap-anywhere"
>
{{ t('board.title', { name: project.name }) }}
</h1>
<p class="text-caption text-muted-foreground max-w-2xl">
{{ t('board.description') }}
</p>
<div class="space-y-3">
<Badge variant="outline" class="px-3 py-1">
{{ project.key }}
</Badge>
<Heading
:title="`${project.name} board`"
description="Tickets only appear here once they are assigned to one of the project's workflow columns."
/>
</div>
<div class="flex flex-wrap gap-2">
<div class="flex flex-wrap gap-3">
<Button v-if="canCreateTickets" as-child>
<Link
:href="
@@ -252,427 +95,245 @@ watch(
)
"
>
<Plus aria-hidden="true" />{{
t('navigation.createTicket')
}}
Create ticket
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="backlogIndex(project.id)"
><ListTodo aria-hidden="true" />{{
t('board.openBacklog')
}}</Link
>Open backlog</Link
>
</Button>
<Button as-child variant="ghost">
<Link :href="show(project.id)"
><ArrowLeft aria-hidden="true" />{{
t('board.backToProject')
}}</Link
>
<Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link>
</Button>
</div>
</header>
</div>
<FeedbackState v-if="status" state="success" :title="status" />
<FormFeedback
:state="feedback.state.value"
:description="moveForm.errors.ticket_status_id"
@retry="
lastMove && submitMove(lastMove.ticketId, lastMove.statusId)
"
/>
<FeedbackState
v-if="columns.length === 0"
state="empty"
:title="t('board.empty.title')"
:description="t('board.empty.description')"
/>
<div
v-if="status"
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
>
{{ status }}
</div>
<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
v-for="column in columns"
:key="column.id"
:value="column.id"
>
{{
t('board.columnOption', {
name: column.name,
count: column.count,
})
}}
</option>
</select>
</div>
<p
id="board-scroll-hint"
class="text-caption text-muted-foreground hidden md:block"
<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"
>
{{ t('board.scrollHint') }}
</p>
<p
v-if="canEditTickets"
id="board-drag-hint"
class="text-caption text-muted-foreground"
>
{{ 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="[
'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',
selectedColumnId === column.id ? 'flex' : 'hidden',
dropClasses(column.id),
]"
>
<header
class="border-primary/25 flex min-w-0 items-start justify-between gap-3 border-b-2 pb-3"
>
<h2
:id="`board-column-title-${column.id}`"
class="text-title wrap-anywhere"
>
{{ column.name }}
</h2>
<Badge
variant="secondary"
class="shrink-0 tabular-nums"
: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,
})
<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
"
>{{ group.tickets.length }}</span
class="mt-3 flex flex-wrap gap-2"
>
</div>
<p
v-if="group.tickets.length === 0"
class="text-caption text-muted-foreground border-border rounded-md border border-dashed p-3"
>
{{ 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"
<Badge
v-for="ticketStatus in boardColumn.ticket_statuses"
:key="ticketStatus.id"
variant="outline"
>
<template
v-if="canEditTickets"
#actions
{{ ticketStatus.name }}
</Badge>
</div>
</div>
<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"
>
No tickets are currently in this column.
</div>
<div v-else class="flex flex-col gap-4">
<Card
v-for="ticket in boardColumn.tickets"
:key="ticket.id"
:class="[
'gap-0',
ticket.is_blocked
? 'border-amber-300 bg-amber-50/70 dark:border-amber-700/60 dark:bg-amber-950/30'
: '',
]"
>
<CardHeader class="pb-4">
<div class="flex flex-wrap gap-2">
<Badge variant="outline">
{{ ticket.ticket_key }}
</Badge>
<Badge
v-if="ticket.is_blocked"
variant="destructive"
>
<Button
type="button"
variant="ghost"
class="mb-2 w-full cursor-grab touch-none select-none active:cursor-grabbing"
Blocked
</Badge>
<Badge variant="secondary">
{{ ticket.ticket_type.name }}
</Badge>
<Badge variant="secondary">
{{ ticket.ticket_priority.name }}
</Badge>
<Badge variant="outline">
{{
ticketStatusLabel(
ticket.ticket_status,
)
}}
</Badge>
</div>
<CardTitle class="text-base">
<Link
:href="
showTicket({
project: project.id,
ticket: ticket.id,
})
"
class="hover:text-primary transition"
>
{{ ticket.title }}
</Link>
</CardTitle>
<CardDescription
v-if="ticket.description"
class="line-clamp-3"
>
{{ ticket.description }}
</CardDescription>
<CardDescription
v-if="
ticket.is_blocked &&
ticket.blocked_comment
"
class="mt-2 text-amber-900 dark:text-amber-200"
>
{{ ticket.blocked_comment }}
</CardDescription>
</CardHeader>
<CardContent v-if="canEditTickets" class="pt-0">
<Form
v-bind="
ProjectBoardController.move.form({
project: project.id,
ticket: ticket.id,
})
"
class="flex flex-col gap-2"
v-slot="{ processing }"
>
<input
type="hidden"
name="redirect_to"
value="board"
/>
<label
:for="`ticket-status-${ticket.id}`"
class="text-muted-foreground text-xs font-medium uppercase tracking-wide"
>
Move ticket
</label>
<div
class="flex flex-col gap-2 sm:flex-row"
>
<select
:id="`ticket-status-${ticket.id}`"
name="ticket_status_id"
:disabled="
busy ||
allowedTargetStatuses(
ticket,
).length === 0
).length === 0 || processing
"
: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
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"
>
<Form
v-bind="
move.form({
project: project.id,
ticket: ticket.id,
})
"
v-slot="{ processing, errors }"
:error-bag="`board-ticket-${ticket.id}`"
:options="{
preserveScroll: true,
}"
:on-before="
() =>
!busy && !draggedTicket
"
@start="menuPending = ticket.id"
@finish="menuPending = null"
class="grid min-w-0 gap-2"
>
<input
type="hidden"
name="redirect_to"
value="board"
/>
<label
:for="`ticket-status-${ticket.id}`"
class="text-caption text-muted-foreground font-semibold"
>{{
t('board.moveTicket', {
key: ticket.ticket_key,
})
}}</label
>
<select
:id="`ticket-status-${ticket.id}`"
name="ticket_status_id"
:disabled="
busy ||
processing ||
allowedTargetStatuses(
ticket,
).length === 0
"
:aria-invalid="
!!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
v-if="
allowedTargetStatuses(
ticket,
).length === 0
"
value=""
>
{{ t('board.noMoves') }}
</option>
<option
v-for="target in allowedTargetStatuses(
ticket,
)"
:key="target.id"
:value="target.id"
>
{{
ticketStatusLabel(
target,
t(
'tickets.noStatus',
),
)
}}
</option>
</select>
<InputError
:id="`ticket-status-error-${ticket.id}`"
:message="
errors.ticket_status_id
"
/>
<Button
type="submit"
variant="outline"
:disabled="
busy ||
processing ||
allowedTargetStatuses(
ticket,
).length === 0
"
>{{
t(
processing
? 'board.updatingStatus'
: 'board.updateStatus',
)
}}</Button
>
<p
<option
v-if="
allowedTargetStatuses(
ticket,
).length === 0
"
class="text-caption text-muted-foreground"
value=""
>
No allowed moves
</option>
<option
v-for="ticketStatus in allowedTargetStatuses(
ticket,
)"
:key="ticketStatus.id"
:value="ticketStatus.id"
>
{{
t(
'board.noMovesDescription',
ticketStatusLabel(
ticketStatus,
)
}}
</p>
</Form>
</template>
</TicketCard>
</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>
</option>
</select>
<Button
type="submit"
size="sm"
:disabled="
processing ||
allowedTargetStatuses(
ticket,
).length === 0
"
>
Update status
</Button>
</div>
<p
v-if="
allowedTargetStatuses(ticket)
.length === 0
"
class="text-muted-foreground text-xs"
>
This status does not allow moving
anywhere else.
</p>
</Form>
</CardContent>
</Card>
</div>
</CardContent>
</Card>
</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>
</template>
+278 -562
View File
@@ -1,633 +1,349 @@
<script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3';
import {
ArrowLeft,
Check,
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 { computed } from 'vue';
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
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';
const props = defineProps<ProjectsCreatePageProps>();
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: index() },
{ title: t('projects.create'), href: create() },
]);
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: index(),
},
{
title: 'New project',
href: create(),
},
];
const defaultTemplateId =
props.selectedSourceTemplateId ?? props.sourceTemplates[0]?.id ?? null;
const form = useForm(
`ProjectsCreate:${props.selectedSourceTemplateId ?? 'default'}`,
{
creation_mode: defaultTemplateId !== null ? 'template' : 'scratch',
name: '',
key: '',
source_project_id: props.sourceProjects[0]?.id
? String(props.sourceProjects[0].id)
: '',
source_template_id:
defaultTemplateId !== null ? String(defaultTemplateId) : '',
copy_areas: [] as string[],
},
);
const protection = useFormProtection(form);
const modes = computed(() => [
{
value: 'scratch',
icon: FolderPlus,
title: t('projectCreation.scratch'),
description: t('projectCreation.scratchDescription'),
disabled: false,
},
{
value: 'duplicate',
icon: Copy,
title: t('projectCreation.duplicate'),
description: t(
props.sourceProjects.length
? 'projectCreation.duplicateDescription'
: 'projectCreation.noProjects',
),
disabled: props.sourceProjects.length === 0,
},
{
value: 'template',
icon: LayoutTemplate,
title: t('projectCreation.template'),
description: t(
props.sourceTemplates.length
? 'projectCreation.templateDescription'
: 'projectCreation.noTemplates',
),
disabled: props.sourceTemplates.length === 0,
},
]);
const areaKeys = {
project_settings: 'projectCreation.areas.projectSettings',
board_columns: 'projectCreation.areas.boardColumns',
ticket_statuses: 'projectCreation.areas.ticketStatuses',
status_transitions: 'projectCreation.areas.statusTransitions',
ticket_types: 'projectCreation.areas.ticketTypes',
ticket_priorities: 'projectCreation.areas.ticketPriorities',
ticket_fields: 'projectCreation.areas.ticketFields',
ticket_link_types: 'projectCreation.areas.ticketLinkTypes',
repository_connections: 'projectCreation.areas.repositories',
notification_actions: 'projectCreation.areas.notifications',
} as const;
function areaText(value: string, part: 'label' | 'description'): string {
const key = areaKeys[value as keyof typeof areaKeys];
return key
? t(`${key}.${part}`)
: (props.copyAreaOptions.find((area) => area.value === value)?.[part] ??
value);
}
const selectedProject = computed(() =>
props.sourceProjects.find(
(project) => String(project.id) === form.source_project_id,
),
);
const selectedTemplate = computed(() =>
props.sourceTemplates.find(
(template) => String(template.id) === form.source_template_id,
),
);
const selectedMode = computed(() =>
modes.value.find((mode) => mode.value === form.creation_mode),
);
const ticketKey = computed(
() => `${form.key.trim().toUpperCase() || 'CORE'}-1`,
);
const copyAreasError = computed(
() =>
const form = useForm({
copy_areas: [] as string[],
creation_mode: defaultTemplateId !== null ? 'template' : 'scratch',
key: '',
name: '',
source_project_id: props.sourceProjects[0]?.id
? String(props.sourceProjects[0].id)
: '',
source_template_id:
defaultTemplateId !== null ? String(defaultTemplateId) : '',
});
const hasSourceProjects = computed(() => props.sourceProjects.length > 0);
const hasSourceTemplates = computed(() => props.sourceTemplates.length > 0);
const copyAreasError = computed(() => {
return (
form.errors.copy_areas ??
Object.entries(form.errors).find(([key]) =>
key.startsWith('copy_areas.'),
)?.[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';
)?.[1] ??
''
);
});
watch(
() => form.creation_mode,
() => {
form.clearErrors(
'creation_mode',
'source_project_id',
'source_template_id',
'copy_areas',
);
for (const key of Object.keys(form.errors)) {
if (key.startsWith('copy_areas.')) {
form.clearErrors(key as 'copy_areas');
}
}
protection.clearFeedback();
},
);
const submit = (): void => {
if (form.creation_mode !== 'duplicate') {
form.source_project_id = '';
form.copy_areas = [];
}
function submit(): void {
form.transform((data) => ({
...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 });
}
if (form.creation_mode !== 'template') {
form.source_template_id = '';
}
form.post(ProjectController.store.url(), {
preserveScroll: true,
});
};
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="t('projects.create')" />
<div
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-6xl flex-1 flex-col p-4"
>
<header class="gap-stack grid">
<Link
:href="index()"
class="text-caption text-muted-foreground hover:text-primary mb-2 inline-flex min-h-10 items-center gap-2 justify-self-start"
>
<ArrowLeft class="size-4" aria-hidden="true" />{{
t('projects.back')
}}
</Link>
<p class="text-label text-muted-foreground uppercase">
{{ t('projects.workspace') }}
</p>
<h1 class="text-headline-mobile md:text-headline">
{{ t('projectCreation.title') }}
</h1>
<p class="text-muted-foreground max-w-2xl">
{{ t('projectCreation.description') }}
</p>
</header>
<Head title="New project" />
<form
class="gap-page grid min-w-0"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<FormFeedback
:state="protection.state.value"
:title="
form.processing
? t('projectCreation.creating')
: undefined
"
@retry="submit"
/>
<fieldset
class="gap-column grid min-w-0"
:disabled="form.processing"
aria-describedby="creation-mode-error"
>
<legend class="text-title mb-3">
{{ t('projectCreation.start') }}
</legend>
<div class="gap-column grid md:grid-cols-3">
<label
v-for="mode in modes"
:key="mode.value"
class="relative min-w-0"
>
<input
v-model="form.creation_mode"
type="radio"
name="creation_mode"
:value="mode.value"
:disabled="mode.disabled"
:aria-invalid="!!form.errors.creation_mode"
:aria-labelledby="`mode-${mode.value}-title`"
:aria-describedby="`mode-${mode.value}-description`"
class="peer sr-only"
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
<Heading
title="Create project"
description="Create a fresh project, duplicate selected settings from an existing project, or start from a saved template."
/>
<Card>
<CardHeader>
<CardTitle>Project details</CardTitle>
<CardDescription>
The key is uppercased automatically, must be unique, and
can contain up to 5 characters.
</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-6" @submit.prevent="submit">
<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
v-model="form.creation_mode"
type="radio"
value="scratch"
class="mt-1"
/>
<div class="space-y-1">
<div class="font-medium">
Create from scratch
</div>
<p
class="text-muted-foreground text-sm"
>
Start with the default project
setup.
</p>
</div>
</Label>
<Label
class="flex items-start gap-3 rounded-xl border p-4"
:class="
hasSourceProjects
? 'cursor-pointer'
: 'cursor-not-allowed opacity-60'
"
>
<input
v-model="form.creation_mode"
type="radio"
value="duplicate"
class="mt-1"
:disabled="!hasSourceProjects"
/>
<div class="space-y-1">
<div class="font-medium">
Create from existing project
</div>
<p
class="text-muted-foreground text-sm"
>
Copy only the settings you
explicitly select. Tickets are never
duplicated.
</p>
</div>
</Label>
<Label
class="flex items-start gap-3 rounded-xl border p-4"
:class="
hasSourceTemplates
? 'cursor-pointer'
: 'cursor-not-allowed opacity-60'
"
>
<input
v-model="form.creation_mode"
type="radio"
value="template"
class="mt-1"
:disabled="!hasSourceTemplates"
/>
<div class="space-y-1">
<div class="font-medium">
Create from template
</div>
<p
class="text-muted-foreground text-sm"
>
Reuse a saved configuration
template.
</p>
</div>
</Label>
</div>
</div>
<div class="grid gap-2">
<Label for="name">Project name</Label>
<Input
id="name"
v-model="form.name"
required
autofocus
placeholder="Kanban foundation"
/>
<span
class="bg-card peer-checked:border-primary peer-checked:bg-accent/50 peer-focus-visible:ring-ring/40 gap-stack flex h-full cursor-pointer flex-col rounded-md border p-4 transition-colors peer-focus-visible:ring-2 peer-disabled:cursor-not-allowed peer-disabled:opacity-60"
:class="
!mode.disabled
? 'hover:border-primary/50'
: ''
"
>
<span
class="flex items-center justify-between gap-3"
>
<component
:is="mode.icon"
class="text-primary size-5"
aria-hidden="true"
/>
<Check
v-if="form.creation_mode === mode.value"
class="text-primary size-4"
aria-hidden="true"
/>
</span>
<span
:id="`mode-${mode.value}-title`"
class="font-semibold"
>{{ mode.title }}</span
>
<span
:id="`mode-${mode.value}-description`"
class="text-caption text-muted-foreground"
>{{ mode.description }}</span
>
</span>
</label>
</div>
<InputError
id="creation-mode-error"
:message="form.errors.creation_mode"
/>
</fieldset>
<InputError :message="form.errors.name" />
</div>
<div
class="gap-page grid min-w-0 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)] lg:items-start"
>
<div class="gap-page grid min-w-0">
<section
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
aria-labelledby="project-details-title"
>
<h2 id="project-details-title" class="text-title">
{{ t('projectCreation.details') }}
</h2>
<div class="gap-stack grid">
<Label for="name">{{
t('projectCreation.name')
}}</Label>
<Input
id="name"
v-model="form.name"
name="name"
required
maxlength="255"
:disabled="form.processing"
:placeholder="
t('projectCreation.namePlaceholder')
"
:invalid="!!form.errors.name"
aria-describedby="name-error"
/>
<InputError
id="name-error"
:message="form.errors.name"
/>
</div>
<div class="gap-stack grid">
<Label for="key">{{
t('projectCreation.key')
}}</Label>
<Input
id="key"
v-model="form.key"
name="key"
required
maxlength="5"
:disabled="form.processing"
:placeholder="
t('projectCreation.keyPlaceholder')
"
class="uppercase sm:max-w-48"
:invalid="!!form.errors.key"
aria-describedby="key-help key-error"
autocapitalize="characters"
:spellcheck="false"
/>
<p
id="key-help"
class="text-caption text-muted-foreground"
>
{{ t('projectCreation.keyHelp') }}
</p>
<InputError
id="key-error"
:message="form.errors.key"
/>
</div>
</section>
<div class="grid gap-2">
<Label for="key">Project key</Label>
<Input
id="key"
v-model="form.key"
required
maxlength="5"
placeholder="CORE"
class="uppercase"
/>
<p class="text-muted-foreground text-sm">
Example ticket keys will look like
<span class="text-foreground font-medium"
>CORE-1</span
>.
</p>
<InputError :message="form.errors.key" />
</div>
<section
<div
v-if="form.creation_mode === 'duplicate'"
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
aria-labelledby="copy-title"
class="space-y-6 rounded-xl border border-dashed p-4"
>
<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>
<div class="grid gap-2">
<Label for="source_project_id"
>Source project</Label
>
<select
id="source_project_id"
v-model="form.source_project_id"
required
:class="selectClass"
:disabled="form.processing"
:aria-invalid="
!!form.errors.source_project_id
"
aria-describedby="source-project-error"
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"
:disabled="!hasSourceProjects"
>
<option value="">
{{ t('projectCreation.selectProject') }}
Select a source project
</option>
<option
v-for="project in sourceProjects"
:key="project.id"
:value="String(project.id)"
v-for="sourceProject in sourceProjects"
:key="sourceProject.id"
:value="String(sourceProject.id)"
>
{{ project.name }} ({{ project.key }})
{{ sourceProject.name }} ({{
sourceProject.key
}})
</option>
</select>
<p class="text-muted-foreground text-sm">
The new project keeps its own identity, key,
and future ticket numbering.
</p>
<InputError
id="source-project-error"
:message="form.errors.source_project_id"
/>
</div>
<fieldset
class="min-w-0"
:disabled="form.processing"
aria-describedby="copy-help copy-areas-error"
>
<legend class="mb-2 font-semibold">
{{ t('projectCreation.copyOptions') }}
</legend>
<p
id="copy-help"
class="text-caption text-muted-foreground mb-3"
>
{{ t('projectCreation.copyDefaults') }}
</p>
<div class="gap-stack grid sm:grid-cols-2">
<label
v-for="area in copyAreaOptions"
:key="area.value"
class="hover:border-primary/45 has-checked:border-primary has-checked:bg-accent/50 flex min-h-11 cursor-pointer items-start gap-3 rounded-md border p-3 transition-colors"
<div class="grid gap-3">
<div>
<Label>Copy options</Label>
<p class="text-muted-foreground text-sm">
Choose which settings should be copied
into the new project.
</p>
</div>
<div class="grid gap-3">
<Label
v-for="copyAreaOption in copyAreaOptions"
:key="copyAreaOption.value"
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
>
<input
v-model="form.copy_areas"
type="checkbox"
:value="area.value"
:aria-labelledby="`copy-${area.value}-label`"
:aria-describedby="`copy-${area.value}-description copy-areas-error`"
:aria-invalid="!!copyAreasError"
class="accent-primary mt-0.5 size-5 shrink-0"
:value="copyAreaOption.value"
class="mt-1"
/>
<span class="gap-stack grid min-w-0">
<span
:id="`copy-${area.value}-label`"
class="text-body font-semibold"
>{{
areaText(
area.value,
'label',
)
}}</span
<div class="space-y-1">
<div class="font-medium">
{{ copyAreaOption.label }}
</div>
<p
class="text-muted-foreground text-sm"
>
<span
:id="`copy-${area.value}-description`"
class="text-caption text-muted-foreground"
>{{
areaText(
area.value,
'description',
)
}}</span
>
</span>
</label>
{{ copyAreaOption.description }}
</p>
</div>
</Label>
</div>
<InputError
id="copy-areas-error"
class="mt-3"
:message="copyAreasError"
/>
</fieldset>
</section>
<section
<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="bg-card gap-column grid min-w-0 rounded-md border p-5"
aria-labelledby="template-title"
class="space-y-4 rounded-xl border border-dashed p-4"
>
<h2 id="template-title" class="text-title">
{{ t('projectCreation.chooseTemplate') }}
</h2>
<div class="gap-stack grid min-w-0">
<Label for="source_template_id">{{
t('projectCreation.templateLabel')
}}</Label>
<div class="grid gap-2">
<Label for="source_template_id">Template</Label>
<select
id="source_template_id"
v-model="form.source_template_id"
required
:class="selectClass"
:disabled="form.processing"
:aria-invalid="
!!form.errors.source_template_id
"
aria-describedby="template-help source-template-error"
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"
:disabled="!hasSourceTemplates"
>
<option value="">
{{
t('projectCreation.selectTemplate')
}}
</option>
<option value="">Select a template</option>
<option
v-for="template in sourceTemplates"
:key="template.id"
:value="String(template.id)"
v-for="sourceTemplate in sourceTemplates"
:key="sourceTemplate.id"
:value="String(sourceTemplate.id)"
>
{{ template.name }} ({{
template.slug
{{ sourceTemplate.name }} ({{
sourceTemplate.slug
}})
</option>
</select>
<p
id="template-help"
class="text-caption text-muted-foreground"
>
{{ t('projectCreation.templateHelp') }}
<p class="text-muted-foreground text-sm">
Templates apply their saved configuration
groups to the new project.
</p>
<InputError
id="source-template-error"
:message="form.errors.source_template_id"
/>
</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
v-if="form.creation_mode === 'duplicate'"
class="gap-stack grid"
>
<dt class="text-caption text-muted-foreground">
{{ t('projectCreation.sourceProject') }}
</dt>
<dd class="break-words">
{{
selectedProject?.name ||
t('projectCreation.selectProject')
}}
</dd>
<dd class="text-caption text-muted-foreground">
{{
t('projectCreation.selectedCount', {
count: form.copy_areas.length,
})
}}
</dd>
</div>
<div
v-else-if="form.creation_mode === 'template'"
class="gap-stack grid"
>
<dt class="text-caption text-muted-foreground">
{{ t('projectCreation.templateLabel') }}
</dt>
<dd class="break-words">
{{
selectedTemplate?.name ||
t('projectCreation.selectTemplate')
}}
</dd>
</div>
</dl>
<p
class="text-caption text-muted-foreground border-t pt-4"
<div
v-else-if="
!hasSourceProjects && !hasSourceTemplates
"
class="text-muted-foreground text-sm"
>
{{ t('projectCreation.cleanStart') }}
</p>
</aside>
</div>
No accessible source projects or saved templates are
available yet.
</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')"
><FolderPlus
v-if="!form.processing"
aria-hidden="true"
/>{{
t(
form.processing
? 'projectCreation.creating'
: 'projectCreation.submit',
)
}}</Button
>
</footer>
</form>
<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>
</CardContent>
</Card>
</div>
</AppLayout>
</template>
+70 -151
View File
@@ -1,184 +1,103 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import {
ArrowUpRight,
Columns3,
FolderGit2,
ListTodo,
Plus,
} from 'lucide-vue-next';
import { computed } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { Folder, Plus } from 'lucide-vue-next';
import Heading from '@/components/Heading.vue';
import { Badge } from '@/components/ui/badge';
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 { create, index, show } from '@/routes/projects';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as boardIndex } from '@/routes/projects/board';
import { create, index, show } from '@/routes/projects/index';
import type { BreadcrumbItem, ProjectsIndexPageProps } from '@/types';
defineProps<ProjectsIndexPageProps>();
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: index() },
]);
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: index(),
},
];
</script>
<template>
<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">
<header
<div class="flex flex-1 flex-col gap-6 p-4">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="gap-stack grid min-w-0">
<p class="text-label text-muted-foreground uppercase">
{{ t('projects.workspace') }}
</p>
<h1 class="text-headline-mobile md:text-headline">
{{ t('navigation.projects') }}
</h1>
<p class="text-muted-foreground max-w-2xl">
{{ t('projects.description') }}
</p>
</div>
<Heading
title="Projects"
description="Create and browse project spaces that will later host boards, backlog, and settings."
/>
<Button
v-if="$page.props.auth.permissions.projectCreate"
as-child
class="self-start"
>
<Link :href="create()"
><Plus aria-hidden="true" />{{
t('projects.create')
}}</Link
>
<Link :href="create()">
<Plus class="size-4" />
New project
</Link>
</Button>
</header>
</div>
<FeedbackState
<div
v-if="status"
state="success"
:title="t('projects.updated')"
:description="status"
/>
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
>
{{ status }}
</div>
<FeedbackState
<div
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')
"
class="text-muted-foreground rounded-xl border border-dashed px-6 py-10 text-center text-sm"
>
<Button
v-if="$page.props.auth.permissions.projectCreate"
as-child
variant="outline"
>
<Link :href="create()"
><Plus aria-hidden="true" />{{
t('projects.empty.create')
}}</Link
>
</Button>
</FeedbackState>
No projects exist yet. Create the first one to start building
out board, backlog, and settings areas.
</div>
<section
v-else
aria-labelledby="project-list-title"
class="gap-column grid"
>
<h2
id="project-list-title"
class="text-caption text-muted-foreground"
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Link
v-for="project in projects"
:key="project.id"
:href="show(project.id)"
class="group block"
>
{{ t('projects.available', { count: projects.length }) }}
</h2>
<ul class="gap-column grid md:grid-cols-2 xl:grid-cols-3">
<li
v-for="project in projects"
:key="project.id"
class="min-w-0"
<Card
class="border-border/80 group-hover:border-primary/40 h-full transition-colors"
>
<article
class="bg-card text-card-foreground flex h-full min-w-0 flex-col rounded-md border"
>
<Link
:href="show(project.id)"
:aria-label="
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
class="bg-accent text-primary flex size-11 shrink-0 items-center justify-center rounded-md"
>
<FolderGit2
class="size-5"
aria-hidden="true"
/>
</span>
<div class="gap-stack grid min-w-0 flex-1">
<Badge
variant="outline"
class="max-w-full justify-self-start whitespace-normal break-all"
>{{ project.key }}</Badge
>
<h3
class="text-title group-hover:text-primary break-words"
>
{{ project.name }}
</h3>
<span
class="text-caption text-muted-foreground"
>{{ t('projects.overview') }}</span
>
<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>
<ArrowUpRight
class="text-muted-foreground mt-1 size-4 shrink-0"
aria-hidden="true"
/>
</Link>
<div
class="gap-stack p-card grid grid-cols-2 border-t"
>
<Button as-child variant="secondary">
<Link
:href="boardIndex(project.id)"
:aria-label="
t('projects.openBoard', {
name: project.name,
})
"
>
<Columns3 aria-hidden="true" />{{
t('navigation.board')
}}
</Link>
</Button>
<Button as-child variant="ghost">
<Link
:href="backlogIndex(project.id)"
:aria-label="
t('projects.openBacklog', {
name: project.name,
})
"
>
<ListTodo aria-hidden="true" />{{
t('navigation.backlog')
}}
</Link>
</Button>
<Badge variant="outline" class="px-2.5 py-1">
{{ project.key }}
</Badge>
</div>
</article>
</li>
</ul>
</section>
</CardHeader>
<CardContent
class="text-muted-foreground flex items-center gap-2 text-sm"
>
<Folder class="size-4" />
Open board, backlog, and settings hub
</CardContent>
</Card>
</Link>
</div>
</div>
</AppLayout>
</template>
+167 -188
View File
@@ -1,227 +1,206 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import {
ArrowLeft,
ArrowRight,
Columns3,
Layers3,
ListTodo,
} from 'lucide-vue-next';
import { computed } from 'vue';
import { FolderGit2, LayoutGrid, ListTodo, Settings2 } from 'lucide-vue-next';
import Heading from '@/components/Heading.vue';
import { Badge } from '@/components/ui/badge';
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 { projectSettingsNavigation } from '@/lib/navigation';
import { index, show } from '@/routes/projects';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { access, index, show } from '@/routes/projects/index';
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 type { BreadcrumbItem, ProjectShowPageProps } from '@/types';
const props = defineProps<ProjectShowPageProps>();
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: index() },
{ title: props.project.name, href: show(props.project.id) },
]);
const projectAreas = computed(() => [
const breadcrumbs: BreadcrumbItem[] = [
{
title: t('navigation.board'),
description: t('projects.boardDescription'),
action: t('projects.boardAction'),
href: boardIndex(props.project.id),
icon: Columns3,
title: 'Projects',
href: index(),
},
{
title: t('navigation.backlog'),
description: t('projects.backlogDescription'),
action: t('projects.backlogAction'),
href: backlogIndex(props.project.id),
title: props.project.name,
href: show(props.project.id),
},
];
const projectAreas = [
{
title: 'Backlog',
description:
'Create tickets, review incoming work, and move ready items into the board workflow.',
icon: ListTodo,
},
]);
const settingsItems = computed(() =>
projectSettingsNavigation(
{
id: props.project.id,
name: props.project.name,
key: props.project.key,
canManage: props.canManageAccess,
canCreateTickets: false,
},
t,
),
);
{
title: 'Board',
description:
'Track active work across the project-defined Kanban columns and update ticket status.',
icon: LayoutGrid,
},
{
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>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="project.name" />
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
<Link
:href="index()"
class="text-caption text-muted-foreground hover:text-primary min-h-control inline-flex items-center gap-2 self-start rounded-md"
>
<ArrowLeft class="size-4" aria-hidden="true" />{{
t('projects.back')
}}
</Link>
<header
<div class="flex flex-1 flex-col gap-6 p-4">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="gap-stack grid min-w-0">
<Badge
variant="outline"
class="max-w-full justify-self-start whitespace-normal break-all"
>{{ project.key }}</Badge
>
<h1
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>
<Button
v-if="$page.props.auth.permissions.projectCreate"
as-child
variant="outline"
class="self-start"
>
<Link :href="projectTemplateCreate(project.id)"
><Layers3 aria-hidden="true" />{{
t('projects.saveTemplate')
}}</Link
>
</Button>
</header>
<section
:aria-label="t('projects.workAreas')"
class="gap-column grid md:grid-cols-2"
>
<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>
<span
class="text-primary mt-auto flex items-center gap-2 pt-2 font-semibold"
>
{{ area.action
}}<ArrowRight class="size-4" aria-hidden="true" />
</span>
</Link>
</section>
<section
v-if="settingsItems.length"
aria-labelledby="project-settings-title"
class="gap-column grid"
>
<div class="gap-stack grid">
<h2 id="project-settings-title" class="text-title">
{{ t('navigation.projectSettings') }}
</h2>
<p class="text-muted-foreground">
{{ t('projects.settingsDescription') }}
</p>
</div>
<ul class="gap-stack grid sm:grid-cols-2 xl:grid-cols-3">
<li
v-for="item in settingsItems"
:key="item.title"
class="min-w-0"
>
<Link
:href="item.href"
class="gap-card bg-card hover:bg-accent hover:text-accent-foreground min-h-control flex h-full items-center rounded-md border p-4 transition-colors"
>
<component
:is="item.icon"
class="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
<span
class="min-w-0 flex-1 break-words font-medium"
>{{ item.title }}</span
>
<ArrowRight
class="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
</Link>
</li>
</ul>
</section>
<section
v-if="templateState?.inheritsFromTemplate"
aria-labelledby="project-template-title"
class="gap-column bg-card flex min-w-0 flex-col rounded-md border p-5"
>
<div class="gap-card flex items-start">
<Layers3
class="text-muted-foreground mt-0.5 size-5 shrink-0"
aria-hidden="true"
<div class="space-y-3">
<Badge variant="outline" class="px-3 py-1">
{{ project.key }}
</Badge>
<Heading
:title="project.name"
description="Project hub for the upcoming backlog, board, and settings work."
/>
<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">
<div class="flex flex-wrap gap-3">
<Button as-child variant="outline">
<Link :href="boardIndex(project.id)">Open board</Link>
</Button>
<Button as-child variant="outline">
<Link :href="backlogIndex(project.id)">
Open backlog
</Link>
</Button>
<Button
v-if="$page.props.auth.permissions.projectCreate"
as-child
variant="outline"
>
<Link :href="projectTemplateCreate(project.id)">
Save as new template
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="index()">Back to projects</Link>
</Button>
</div>
</div>
<Card v-if="templateState?.inheritsFromTemplate">
<CardHeader>
<CardTitle>
Built from template {{ templateState.templateName }}
</CardTitle>
<CardDescription>
This project keeps its template link. Inherited areas
follow the template baseline; overridden areas were
customized locally.
</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
<Badge
v-for="area in templateState.inheritedAreas"
:key="`inherited-${area.value}`"
variant="outline"
class="max-w-full whitespace-normal break-words"
>
{{ t('projects.inheritedArea', { name: area.label }) }}
{{ area.label }} · inherited
</Badge>
<Badge
v-for="area in templateState.overriddenAreas"
:key="`overridden-${area.value}`"
variant="secondary"
class="max-w-full whitespace-normal break-words"
>
{{ t('projects.overriddenArea', { name: area.label }) }}
{{ area.label }} · overridden
</Badge>
</div>
</section>
</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>
</AppLayout>
</template>
+23 -90
View File
@@ -1,14 +1,7 @@
<script setup lang="ts">
import {
Form,
useForm,
useRemember,
Head,
Link,
usePage,
} from '@inertiajs/vue3';
import { Form, useForm, Head, Link, usePage } from '@inertiajs/vue3';
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
import { computed, ref, toRef, unref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import {
acceptanceCriteriaToEditorState,
buildAcceptanceCriteria,
@@ -21,10 +14,6 @@ import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectT
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import Heading from '@/components/Heading.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 { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -56,7 +45,6 @@ import type { RepositoryConnectionModel } from '@/types/models/repository-connec
import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types';
const props = defineProps<ProjectTicketPageProps>();
const { t } = useLocalization();
const page = usePage();
const appName = String(page.props.name);
@@ -134,14 +122,8 @@ const breadcrumbs: BreadcrumbItem[] = [
const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState(
props.ticket.acceptance_criteria,
);
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
const acceptanceCriteriaDraft = useRemember(
{ checklist: acceptanceCriteriaEditorState.checklist },
`${draftKey}:checklist`,
);
const acceptanceCriteriaChecklist = toRef(
unref(acceptanceCriteriaDraft),
'checklist',
const acceptanceCriteriaChecklist = ref(
acceptanceCriteriaEditorState.checklist,
);
const savedAcceptanceCriteria = ref(
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
@@ -149,7 +131,7 @@ const savedAcceptanceCriteria = ref(
const isPersistingAcceptanceCriteriaToggle = ref(false);
const acceptanceCriteriaToggleError = ref('');
const form = useForm(draftKey, {
const form = useForm({
acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
),
@@ -188,12 +170,6 @@ const hasUnsavedAcceptanceCriteriaDraft = computed(
formatChecklistLines(savedAcceptanceCriteria.value),
);
const protection = useFormProtection(form, {
isDirty: () =>
props.canEditTicket &&
(form.isDirty || hasUnsavedAcceptanceCriteriaDraft.value),
});
const acceptanceCriteriaError = computed(() => {
return (
form.errors.acceptance_criteria ??
@@ -211,20 +187,10 @@ const branchNameValidation = ref<BranchNameValidationModel | null>(null);
const branchCreationForm = useForm<{
branch_name: string;
repository_connection_id: string;
}>(`${draftKey}:branch`, {
}>({
branch_name: props.suggestedBranchName,
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(
(): RepositoryConnectionModel | null =>
@@ -241,9 +207,6 @@ const selectedBranchRepositoryHasPersonalAccess = computed(
);
const initializeBranchCreationForm = (): void => {
if (branchCreationForm.isDirty) {
return;
}
branchCreationForm.clearErrors();
branchCreationForm.branch_name = props.suggestedBranchName;
branchCreationForm.repository_connection_id =
@@ -385,7 +348,7 @@ const submitBranchCreation = (): void => {
return;
}
branchProtection.submit(
branchCreationForm.submit(
ProjectTicketBranchController.store({
project: props.project.id,
ticket: props.ticket.id,
@@ -519,21 +482,13 @@ const submit = (): void => {
form.acceptance_criteria = buildAcceptanceCriteriaForSubmit();
protection.submit(
form.submit(
ProjectTicketController.update({
project: props.project.id,
ticket: props.ticket.id,
}),
{
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>
<form class="grid gap-6" @submit.prevent="submit">
<FormFeedback
:state="protection.state.value"
@retry="submit"
/>
<div class="grid gap-2">
<Label for="title">Title</Label>
<Input
id="title"
v-model="form.title"
:invalid="Boolean(form.errors.title)"
aria-describedby="title-error"
:disabled="
!canEditTicket || form.processing
"
/>
<InputError
id="title-error"
:message="form.errors.title"
/>
<InputError :message="form.errors.title" />
</div>
<div class="grid gap-2 md:grid-cols-2 md:gap-6">
@@ -1049,10 +995,7 @@ const formatDateTime = (value: string): string => {
</CardDescription>
</div>
<Dialog
:open="isCreateBranchDialogOpen"
@update:open="updateBranchDialogOpen"
>
<Dialog v-model:open="isCreateBranchDialogOpen">
<Button
v-if="canCreateBranches"
type="button"
@@ -1080,12 +1023,6 @@ const formatDateTime = (value: string): string => {
class="grid gap-4"
@submit.prevent="submitBranchCreation"
>
<FormFeedback
:state="
branchProtection.state.value
"
@retry="submitBranchCreation"
/>
<div class="grid gap-2">
<Label
for="repository_connection_id"
@@ -1216,9 +1153,7 @@ const formatDateTime = (value: string): string => {
type="button"
variant="secondary"
@click="
updateBranchDialogOpen(
false,
)
isCreateBranchDialogOpen = false
"
>
Cancel
@@ -1242,14 +1177,13 @@ const formatDateTime = (value: string): string => {
</CardHeader>
<CardContent class="space-y-4">
<FeedbackState
<div
v-if="visibleRepositoryBranches.length === 0"
state="empty"
:title="t('tickets.noBranches')"
:description="
t('tickets.noBranchesDescription')
"
/>
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
>
No matching branches were found across the
linked repositories you can access.
</div>
<div v-else class="space-y-3">
<div
@@ -1500,16 +1434,15 @@ const formatDateTime = (value: string): string => {
</CardHeader>
<CardContent class="space-y-4">
<FeedbackState
<div
v-if="
visibleRepositoryPullRequests.length === 0
"
state="empty"
:title="t('tickets.noPullRequests')"
:description="
t('tickets.noPullRequestsDescription')
"
/>
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
>
No matching pull requests were found across the
linked repositories you can access.
</div>
<div v-else class="space-y-3">
<div
+8 -34
View File
@@ -1,11 +1,9 @@
<script setup lang="ts">
import { Head, Link, useForm, useRemember } from '@inertiajs/vue3';
import { computed, toRef, unref } from 'vue';
import { Head, Link, useForm } from '@inertiajs/vue3';
import { computed, ref } from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import Heading from '@/components/Heading.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 { Button } from '@/components/ui/button';
import {
@@ -64,17 +62,9 @@ const hasTicketMetadata =
props.ticketPriorities.length > 0 &&
props.ticketStatusOptions.length > 0;
const draftKey = `projects:${props.project.id}:tickets:create`;
const acceptanceCriteriaDraft = useRemember(
{ checklist: '' },
`${draftKey}:checklist`,
);
const acceptanceCriteriaChecklist = toRef(
unref(acceptanceCriteriaDraft),
'checklist',
);
const acceptanceCriteriaChecklist = ref('');
const form = useForm(draftKey, {
const form = useForm({
acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
),
@@ -90,10 +80,6 @@ const form = useForm(draftKey, {
title: '',
});
const protection = useFormProtection(form, {
isDirty: () => form.isDirty || acceptanceCriteriaChecklist.value !== '',
});
const acceptanceCriteriaPreviewItems = computed(() =>
parseChecklistLines(acceptanceCriteriaChecklist.value),
);
@@ -113,12 +99,9 @@ const submit = (): void => {
acceptanceCriteriaChecklist.value,
);
protection.submit(
ProjectTicketController.store({ project: props.project.id }),
{
preserveScroll: true,
},
);
form.submit(ProjectTicketController.store({ project: props.project.id }), {
preserveScroll: true,
});
};
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
@@ -182,26 +165,17 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
<CardContent>
<form class="grid gap-6" @submit.prevent="submit">
<FormFeedback
:state="protection.state.value"
@retry="submit"
/>
<div class="grid gap-2">
<Label for="title">Title</Label>
<Input
id="title"
v-model="form.title"
:invalid="Boolean(form.errors.title)"
aria-describedby="title-error"
required
autofocus
:disabled="form.processing"
placeholder="Implement repository sync"
/>
<InputError
id="title-error"
:message="form.errors.title"
/>
<InputError :message="form.errors.title" />
</div>
<div
+1 -21
View File
@@ -4,12 +4,6 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import type { DefineComponent } from 'vue';
import { createSSRApp, h } from 'vue';
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';
@@ -25,21 +19,7 @@ createServer(
import.meta.glob<DefineComponent>('./pages/**/*.vue'),
),
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(
createAppearance(
normalizeAppearance(
props.initialPage.props.appearance,
),
),
)
.use(
createLocalization(
props.initialPage.props
.localization as LocalizationSettings,
),
),
createSSRApp({ render: () => h(App, props) }).use(plugin),
}),
{ cluster: true },
);
-6
View File
@@ -1,7 +1,4 @@
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 { createHeadManager } from '@inertiajs/vue3';
@@ -22,11 +19,8 @@ declare module '@inertiajs/core' {
export interface InertiaConfig {
sharedPageProps: {
name: string;
localization: LocalizationSettings;
appearance: Appearance;
auth: Auth;
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']>;
icon?: LucideIcon;
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;
};
export type AssignedTicketsProjectGroup = {
project: ProjectModel;
tickets: TicketModel[];
};
export type DashboardPageProps = {
assignedTicketsByProject: AssignedTicketsProjectGroup[];
};
export type ProjectsIndexPageProps = {
projects: ProjectModel[];
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>
<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>
<meta charset="utf-8">
<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>
(function() {
const root = document.documentElement;
let appearance = root.dataset.appearance;
try {
const stored = window.localStorage.getItem('appearance');
if (stored !== null) {
appearance = stored;
const appearance = '{{ $appearance ?? "system" }}';
if (appearance === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
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>
{{-- 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>
{!! 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>
<title inertia>{{ config('app.name', 'Laravel') }}</title>
@@ -36,7 +36,8 @@
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<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"])
@inertiaHead

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