Compare commits
35
Commits
9895fc36db
...
v0.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e29fc4526 | ||
|
|
fab1e9c7a8 | ||
|
|
ccd199a695 | ||
|
|
f7b50e8715 | ||
|
|
5147c8ed8d | ||
|
|
3914fd5432 | ||
|
|
4d6da2c5f5 | ||
|
|
313e2d2396 | ||
|
|
810d1850ae | ||
|
|
cd7fae3841 | ||
|
|
32e3496248 | ||
|
|
0e78be0eae | ||
|
|
17caabbb3e | ||
|
|
3f4945c20d | ||
|
|
65bf75ed77 | ||
|
|
802294d6a2 | ||
|
|
5e52e4fe78 | ||
|
|
f4a8bdd134 | ||
|
|
850ae337ca | ||
|
|
4adda6e53b | ||
|
|
de6f4e5b2f | ||
|
|
2e2ddd5b74 | ||
|
|
c58196293f | ||
|
|
8657d75682 | ||
|
|
71d9d1b63e | ||
|
|
68995a9f4c | ||
|
|
3e998a483e | ||
|
|
ceffe4f4a4 | ||
|
|
ce01b8ec55 | ||
|
|
bd774e20f6 | ||
|
|
d9cb8d0903 | ||
|
|
19e8ba89bf | ||
|
|
642e49d7b0 | ||
|
|
1d8fff1d51 | ||
|
|
bd7be4e41e |
@@ -47,6 +47,7 @@ class BuildProjectBacklogPayload
|
||||
'tickets' => TicketResource::collection(
|
||||
Ticket::query()
|
||||
->with([
|
||||
'assignee',
|
||||
'ticketPriority',
|
||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||
'ticketStatus.boardColumn',
|
||||
|
||||
@@ -29,6 +29,7 @@ class BuildProjectBoardPayload
|
||||
'ticketStatuses',
|
||||
'tickets' => fn ($query) => $query
|
||||
->with([
|
||||
'assignee',
|
||||
'ticketPriority',
|
||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||
'ticketStatus.boardColumn',
|
||||
|
||||
@@ -19,6 +19,7 @@ class CreateProjectTicket
|
||||
public function __construct(
|
||||
private readonly BuildTicketCreatedPayload $buildTicketCreatedPayload,
|
||||
private readonly CreateTicketWorklogEntry $createTicketWorklogEntry,
|
||||
private readonly SyncProjectTicketFieldValues $syncProjectTicketFieldValues,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -29,7 +30,15 @@ class CreateProjectTicket
|
||||
* text: string
|
||||
* }>,
|
||||
* assignee_user_id?: int|null,
|
||||
* author_user_id?: int|null,
|
||||
* blocked_comment?: string|null,
|
||||
* description: string|null,
|
||||
* field_values?: array<int, array{
|
||||
* ticket_field_definition_id: int,
|
||||
* value_number?: float|int|string|null,
|
||||
* value_user_id?: int|string|null
|
||||
* }>,
|
||||
* is_blocked?: bool,
|
||||
* parent_ticket_id?: int|null,
|
||||
* story_points?: int|null,
|
||||
* ticket_priority_id: int,
|
||||
@@ -50,11 +59,14 @@ class CreateProjectTicket
|
||||
$ticketStatusQuery = TicketStatus::query()->where('project_id', $project->id);
|
||||
|
||||
$ticketStatus = $ticketStatusQuery->findOrFail(Arr::get($attributes, 'ticket_status_id'));
|
||||
$isBlocked = (bool) Arr::get($attributes, 'is_blocked', false);
|
||||
|
||||
$ticket = Ticket::query()->create([
|
||||
'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'),
|
||||
'author_user_id' => $actor?->id,
|
||||
'author_user_id' => Arr::get($attributes, 'author_user_id', $actor?->id),
|
||||
'blocked_comment' => $isBlocked ? Arr::get($attributes, 'blocked_comment') : null,
|
||||
'description' => Arr::get($attributes, 'description'),
|
||||
'is_blocked' => $isBlocked,
|
||||
'parent_ticket_id' => Arr::get($attributes, 'parent_ticket_id'),
|
||||
'project_id' => $project->id,
|
||||
'sequence_number' => $nextSequenceNumber,
|
||||
@@ -68,6 +80,17 @@ class CreateProjectTicket
|
||||
|
||||
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $acceptanceCriteria);
|
||||
|
||||
$fieldValues = Arr::get($attributes, 'field_values', []);
|
||||
|
||||
if ($fieldValues !== []) {
|
||||
$this->syncProjectTicketFieldValues->handle(
|
||||
$ticket,
|
||||
$project->ticketFieldDefinitions()->get()->keyBy('id'),
|
||||
collect(),
|
||||
$fieldValues,
|
||||
);
|
||||
}
|
||||
|
||||
$occurredAt = CarbonImmutable::now();
|
||||
|
||||
if ($actor !== null) {
|
||||
|
||||
@@ -41,8 +41,8 @@ class ListAssignedTicketsByVisibleProject
|
||||
->where('assignee_user_id', $assignee->id)
|
||||
->whereIn('project_id', $visibleProjectIds)
|
||||
->with([
|
||||
'acceptanceCriteria',
|
||||
'assignee',
|
||||
'project',
|
||||
'ticketPriority',
|
||||
'ticketStatus.boardColumn',
|
||||
'ticketType',
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Tickets\ListAssignedTicketsByVisibleProject;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ListAssignedTicketsByVisibleProject $listAssignedTicketsByVisibleProject,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
return Inertia::render('Dashboard', [
|
||||
'assignedTicketsByProject' => $this->listAssignedTicketsByVisibleProject->handle($user, $user),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,11 @@ class HandleAppearance
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
View::share('appearance', $request->cookie('appearance') ?? 'system');
|
||||
$appearance = $request->cookie('appearance');
|
||||
$appearance = in_array($appearance, ['light', 'dark', 'system'], true) ? $appearance : 'system';
|
||||
|
||||
$request->attributes->set('appearance', $appearance);
|
||||
View::share('appearance', $appearance);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Middleware;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Middleware;
|
||||
|
||||
@@ -46,6 +47,11 @@ 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)),
|
||||
@@ -55,6 +61,36 @@ 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
namespace App\Mcp\Servers;
|
||||
|
||||
use App\Mcp\Tools\CreateTicket;
|
||||
use App\Mcp\Tools\GetTicket;
|
||||
use App\Mcp\Tools\ListMyAssignedTickets;
|
||||
use App\Mcp\Tools\ListTicketsByStatus;
|
||||
use App\Mcp\Tools\ListTicketTypes;
|
||||
use App\Mcp\Tools\MoveTicketStatus;
|
||||
use App\Mcp\Tools\UpdateTicketAssignee;
|
||||
use App\Mcp\Tools\UpdateTicketDescription;
|
||||
@@ -15,7 +17,7 @@ use Laravel\Mcp\Server\Attributes\Version;
|
||||
|
||||
#[Name('KanbanGit MCP')]
|
||||
#[Version('0.1.0')]
|
||||
#[Instructions('Use these tools to read and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially ticket_id and ticket_status_id.')]
|
||||
#[Instructions('Use these tools to read, create, and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially project_id, ticket_id, ticket_type_id, and ticket_status_id. Use list_ticket_types to discover the available ticket types in a project. Use create_ticket with parent_ticket_id to create a child ticket.')]
|
||||
class KanbanGitServer extends Server
|
||||
{
|
||||
protected array $tools = [
|
||||
@@ -25,6 +27,8 @@ class KanbanGitServer extends Server
|
||||
UpdateTicketDescription::class,
|
||||
UpdateTicketAssignee::class,
|
||||
MoveTicketStatus::class,
|
||||
CreateTicket::class,
|
||||
ListTicketTypes::class,
|
||||
];
|
||||
|
||||
protected array $resources = [
|
||||
|
||||
@@ -120,6 +120,24 @@ class KanbanGitMcp
|
||||
return $project;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function projectForWrite(User $user, int $projectId): Project
|
||||
{
|
||||
$project = Project::query()->find($projectId);
|
||||
|
||||
if ($project === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'project_id' => 'Project was not found.',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->ensureCanWrite($user, $project);
|
||||
|
||||
return $project;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $validated
|
||||
* @return Collection<int, TicketStatus>|Response
|
||||
@@ -189,10 +207,12 @@ class KanbanGitMcp
|
||||
public function ticketPayload(Ticket $ticket): array
|
||||
{
|
||||
$ticket->load([
|
||||
'acceptanceCriteria',
|
||||
'assignee',
|
||||
'author',
|
||||
'fieldValues.ticketFieldDefinition',
|
||||
'fieldValues.userValue',
|
||||
'parentTicket',
|
||||
'project',
|
||||
'ticketPriority',
|
||||
'ticketStatus.boardColumn',
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Actions\Tickets\CreateProjectTicket;
|
||||
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
||||
use App\Enums\Permission;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\ResponseFactory;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Attributes\Name;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[Name('create_ticket')]
|
||||
#[Description('Create a ticket with all editable ticket fields, including its type, initial status, priority, assignee, author, description, story points, acceptance criteria, blocked state, and custom field values. Set parent_ticket_id to create a child of an existing ticket in the same project whose type allows children. Requires project write permission; overriding the author requires project admin permission. Ticket IDs, keys, sequence numbers, timestamps, and worklog entries are generated automatically.')]
|
||||
#[IsDestructive(false)]
|
||||
class CreateTicket extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||
private readonly CreateProjectTicket $createProjectTicket,
|
||||
private readonly PermissionService $permissionService,
|
||||
) {}
|
||||
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$projectInput = $request->validate([
|
||||
'project_id' => ['required', 'integer'],
|
||||
]);
|
||||
|
||||
$user = $this->kanbanGitMcp->user($request);
|
||||
$project = $this->kanbanGitMcp->projectForWrite($user, (int) Arr::get($projectInput, 'project_id'));
|
||||
$validated = $request->validate([
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'ticket_type_id' => ['required', 'integer', Rule::exists('ticket_types', 'id')->where('project_id', $project->id)],
|
||||
'ticket_status_id' => ['required', 'integer', Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id)],
|
||||
'ticket_priority_id' => ['required', 'integer', Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id)],
|
||||
'parent_ticket_id' => ['nullable', 'integer', Rule::exists('tickets', 'id')->where('project_id', $project->id)],
|
||||
'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
||||
'author_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
||||
'story_points' => ['nullable', 'integer', 'min:0'],
|
||||
'is_blocked' => ['sometimes', 'boolean'],
|
||||
'blocked_comment' => ['nullable', 'string'],
|
||||
'acceptance_criteria' => ['nullable', 'array', 'list'],
|
||||
'acceptance_criteria.*' => ['array:text,checked,indent'],
|
||||
'acceptance_criteria.*.text' => ['required', 'string'],
|
||||
'acceptance_criteria.*.checked' => ['sometimes', 'boolean'],
|
||||
'acceptance_criteria.*.indent' => ['sometimes', 'integer', 'min:0', 'max:'.AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL],
|
||||
'field_values' => ['sometimes', 'array', 'list'],
|
||||
'field_values.*' => ['array:ticket_field_definition_id,value_number,value_user_id'],
|
||||
'field_values.*.ticket_field_definition_id' => [
|
||||
'required', 'integer', 'distinct',
|
||||
Rule::exists('ticket_field_definitions', 'id')->where('project_id', $project->id),
|
||||
],
|
||||
'field_values.*.value_number' => ['nullable', 'numeric'],
|
||||
'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
||||
]);
|
||||
|
||||
$this->validateTicketConstraints($project, $user, $validated);
|
||||
|
||||
$ticket = $this->createProjectTicket->handle($project, $validated, $user);
|
||||
|
||||
return Response::structured([
|
||||
'data' => $this->kanbanGitMcp->ticketPayload($ticket->fresh()),
|
||||
'meta' => [
|
||||
'message' => "{$ticket->ticket_key} was created.",
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $validated
|
||||
*/
|
||||
private function validateTicketConstraints(Project $project, User $user, array $validated): void
|
||||
{
|
||||
$errors = [];
|
||||
$parentTicketId = Arr::get($validated, 'parent_ticket_id');
|
||||
|
||||
if ($parentTicketId !== null) {
|
||||
$parentTicket = Ticket::query()
|
||||
->where('project_id', $project->id)
|
||||
->with('ticketType')
|
||||
->find($parentTicketId);
|
||||
|
||||
if (! $parentTicket?->ticketType?->is_parent) {
|
||||
$errors['parent_ticket_id'] = 'Only tickets with a parent-enabled type can be selected as a parent.';
|
||||
}
|
||||
}
|
||||
|
||||
if (Arr::get($validated, 'is_blocked', false) && blank(Arr::get($validated, 'blocked_comment'))) {
|
||||
$errors['blocked_comment'] = 'Blocked tickets require a blocker comment.';
|
||||
}
|
||||
|
||||
if (
|
||||
Arr::exists($validated, 'author_user_id')
|
||||
&& (int) Arr::get($validated, 'author_user_id') !== $user->id
|
||||
&& ! $this->permissionService->allows($user, Permission::ProjectAdmin, $project)
|
||||
) {
|
||||
$errors['author_user_id'] = 'Only project admins can change the author.';
|
||||
}
|
||||
|
||||
$fieldValues = Arr::get($validated, 'field_values', []);
|
||||
|
||||
if ($fieldValues !== []) {
|
||||
$fieldDefinitions = $project->ticketFieldDefinitions()->get()->keyBy('id');
|
||||
|
||||
foreach ($fieldValues as $index => $fieldValue) {
|
||||
$fieldDefinition = $fieldDefinitions->get((int) Arr::get($fieldValue, 'ticket_field_definition_id'));
|
||||
$incompatibleValue = $fieldDefinition->field_type === 'user' ? 'value_number' : 'value_user_id';
|
||||
|
||||
if (Arr::get($fieldValue, $incompatibleValue) !== null) {
|
||||
$errors["field_values.{$index}.{$incompatibleValue}"] = 'The value must match the custom field type.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
throw ValidationException::withMessages($errors);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, Type>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'project_id' => $schema->integer()->description('Project ID.')->required(),
|
||||
'title' => $schema->string()->max(255)->description('Ticket title.')->required(),
|
||||
'description' => $schema->string()->nullable()->description('Ticket description.'),
|
||||
'ticket_type_id' => $schema->integer()->description('Ticket type ID belonging to the project. Explicitly selects the type, including types that allow child tickets.')->required(),
|
||||
'ticket_status_id' => $schema->integer()->description('Initial ticket status ID belonging to the project.')->required(),
|
||||
'ticket_priority_id' => $schema->integer()->description('Ticket priority ID belonging to the project.')->required(),
|
||||
'parent_ticket_id' => $schema->integer()->nullable()->description('Existing parent ticket ID in the same project. Its type must allow children. Omit or pass null for a top-level ticket.'),
|
||||
'assignee_user_id' => $schema->integer()->nullable()->description('User ID to assign. Omit or pass null to leave unassigned.'),
|
||||
'author_user_id' => $schema->integer()->nullable()->description('Defaults to the application key owner when omitted. Only project admins may choose another author or pass null for no author.'),
|
||||
'story_points' => $schema->integer()->min(0)->nullable()->description('Non-negative story point estimate.'),
|
||||
'is_blocked' => $schema->boolean()->default(false)->description('Whether the ticket is blocked. Requires a nonblank blocked_comment when true.'),
|
||||
'blocked_comment' => $schema->string()->nullable()->description('Blocker reason. Stored only when is_blocked is true.'),
|
||||
'acceptance_criteria' => $schema->array()->nullable()
|
||||
->items($schema->object([
|
||||
'text' => $schema->string()->description('Checklist item text.')->required(),
|
||||
'checked' => $schema->boolean()->default(false),
|
||||
'indent' => $schema->integer()->min(0)->max(AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL)->default(0),
|
||||
])->withoutAdditionalProperties())
|
||||
->description('Ordered acceptance checklist. Indent 0 is a root item; larger values nest under preceding items.'),
|
||||
'field_values' => $schema->array()
|
||||
->items($schema->object([
|
||||
'ticket_field_definition_id' => $schema->integer()->description('Custom field definition ID belonging to the project. Each definition may appear once.')->required(),
|
||||
'value_number' => $schema->number()->nullable()->description('Value for a numeric field.'),
|
||||
'value_user_id' => $schema->integer()->nullable()->description('User ID for a user field.'),
|
||||
])->withoutAdditionalProperties())
|
||||
->description('Custom field values. Supply the value matching each definition type; omit or pass null for an empty value.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Http\Resources\TicketTypeResource;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Illuminate\Support\Arr;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\ResponseFactory;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Attributes\Name;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[Name('list_ticket_types')]
|
||||
#[Description('List all ticket types in a visible project in their configured order, including IDs, names, and is_parent. Use a returned ID as ticket_type_id when creating a ticket. Types with is_parent=true allow tickets of that type to have child tickets; set parent_ticket_id on create_ticket to attach a child to an existing parent ticket.')]
|
||||
#[IsReadOnly]
|
||||
class ListTicketTypes extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||
) {}
|
||||
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'project_id' => ['required', 'integer'],
|
||||
]);
|
||||
|
||||
$user = $this->kanbanGitMcp->user($request);
|
||||
$project = $this->kanbanGitMcp->projectForRead($user, (int) Arr::get($validated, 'project_id'));
|
||||
|
||||
return Response::structured([
|
||||
'data' => [
|
||||
'project' => ProjectResource::make($project)->resolve(),
|
||||
'ticket_types' => TicketTypeResource::collection($project->ticketTypes()->get())->resolve(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, Type>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'project_id' => $schema->integer()
|
||||
->description('Project ID. Requires permission to view this project.')
|
||||
->required(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ class BoardColumn extends Model
|
||||
{
|
||||
/** @use HasFactory<BoardColumnFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
protected static function booted(): void
|
||||
|
||||
@@ -31,6 +31,7 @@ class NotificationAction extends Model
|
||||
{
|
||||
/** @use HasFactory<NotificationActionFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,6 +26,7 @@ class RepositoryConnection extends Model
|
||||
{
|
||||
/** @use HasFactory<RepositoryConnectionFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,7 @@ class TicketFieldDefinition extends Model
|
||||
|
||||
/** @use HasFactory<TicketFieldDefinitionFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
protected static function booted(): void
|
||||
|
||||
@@ -15,6 +15,7 @@ class TicketLinkType extends Model
|
||||
{
|
||||
/** @use HasFactory<TicketLinkTypeFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
protected static function booted(): void
|
||||
|
||||
@@ -17,6 +17,7 @@ class TicketStatus extends Model
|
||||
{
|
||||
/** @use HasFactory<TicketStatusFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
protected static function booted(): void
|
||||
|
||||
@@ -15,6 +15,7 @@ class TicketType extends Model
|
||||
{
|
||||
/** @use HasFactory<TicketTypeFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
protected static function booted(): void
|
||||
|
||||
Generated
+30
-29
@@ -418,29 +418,43 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@humanfs/types": "^0.15.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/node": {
|
||||
"version": "0.16.7",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
|
||||
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
|
||||
"version": "0.16.8",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
|
||||
"integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@humanfs/core": "^0.19.1",
|
||||
"@humanfs/core": "^0.19.2",
|
||||
"@humanfs/types": "^0.15.0",
|
||||
"@humanwhocodes/retry": "^0.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/types": {
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
|
||||
"integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/module-importer": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
|
||||
@@ -1303,7 +1317,6 @@
|
||||
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -1320,7 +1333,6 @@
|
||||
"integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.58.1",
|
||||
@@ -1360,7 +1372,6 @@
|
||||
"integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.58.1",
|
||||
"@typescript-eslint/types": "8.58.1",
|
||||
@@ -2111,7 +2122,6 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3020,7 +3030,6 @@
|
||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -3081,7 +3090,6 @@
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
@@ -3208,7 +3216,6 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -3351,7 +3358,6 @@
|
||||
"integrity": "sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.4.0",
|
||||
"globals": "^13.24.0",
|
||||
@@ -4597,9 +4603,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -5454,9 +5460,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-selector-parser": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
||||
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz",
|
||||
"integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5483,7 +5489,6 @@
|
||||
"integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -5614,9 +5619,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
|
||||
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.1",
|
||||
@@ -6525,7 +6530,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -6591,7 +6595,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"napi-postinstall": "^0.3.0"
|
||||
},
|
||||
@@ -6642,7 +6645,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
|
||||
"integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.5",
|
||||
@@ -6998,7 +7000,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
|
||||
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.30",
|
||||
"@vue/compiler-sfc": "3.5.30",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,92 @@
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
+87
-122
@@ -1,56 +1,98 @@
|
||||
@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 (&:is(.dark *));
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@font-face {
|
||||
font-family: Inter;
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('/fonts/inter/InterVariable.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Inter;
|
||||
font-style: italic;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('/fonts/inter/InterVariable-Italic.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans:
|
||||
Instrument Sans, ui-sans-serif, system-ui, sans-serif,
|
||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
--font-sans: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
|
||||
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--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);
|
||||
|
||||
--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);
|
||||
@@ -61,112 +103,35 @@
|
||||
--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 outline-ring/50;
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background font-sans text-body text-foreground antialiased;
|
||||
font-optical-sizing: auto;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--brand);
|
||||
color: var(--brand-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
/* Preserve completion events while removing visible nonessential motion. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/* Fluid Emerald / Kinetic Kanban: shared by the first paint and the app bundle. */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--radius: 0.25rem;
|
||||
--background: #f7f9fb;
|
||||
--foreground: #191c1e;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #191c1e;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #191c1e;
|
||||
--primary: #006e2f;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #d0e1fb;
|
||||
--secondary-foreground: #38485d;
|
||||
--muted: #f2f4f6;
|
||||
--muted-foreground: #505f76;
|
||||
--accent: #dcfce7;
|
||||
--accent-foreground: #005321;
|
||||
--destructive: #ba1a1a;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #e2e8f0;
|
||||
--input: #6d7b6c;
|
||||
--ring: #006e2f;
|
||||
--brand: #22c55e;
|
||||
--brand-foreground: #002109;
|
||||
--success: #dcfce7;
|
||||
--success-foreground: #005321;
|
||||
--warning: #fef3c7;
|
||||
--warning-foreground: #78350f;
|
||||
--info: #dbeafe;
|
||||
--info-foreground: #1e40af;
|
||||
--chart-1: #006e2f;
|
||||
--chart-2: #505f76;
|
||||
--chart-3: #9e4036;
|
||||
--chart-4: #92400e;
|
||||
--chart-5: #1d4ed8;
|
||||
--sidebar-background: #f2f4f6;
|
||||
--sidebar-foreground: #191c1e;
|
||||
--sidebar-primary: #006e2f;
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: #dcfce7;
|
||||
--sidebar-accent-foreground: #005321;
|
||||
--sidebar-border: #e2e8f0;
|
||||
--sidebar-ring: #006e2f;
|
||||
--sidebar: #f2f4f6;
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
--background: #0f172a;
|
||||
--foreground: #f1f5f9;
|
||||
--card: #1e293b;
|
||||
--card-foreground: #f1f5f9;
|
||||
--popover: #1e293b;
|
||||
--popover-foreground: #f1f5f9;
|
||||
--primary: #4ae176;
|
||||
--primary-foreground: #002109;
|
||||
--secondary: #334155;
|
||||
--secondary-foreground: #e2e8f0;
|
||||
--muted: #1e293b;
|
||||
--muted-foreground: #b7c8e1;
|
||||
--accent: #173b2b;
|
||||
--accent-foreground: #6bff8f;
|
||||
--destructive: #fca5a5;
|
||||
--destructive-foreground: #450a0a;
|
||||
--border: #334155;
|
||||
--input: #8293aa;
|
||||
--ring: #4ae176;
|
||||
--brand: #22c55e;
|
||||
--brand-foreground: #002109;
|
||||
--success: #173b2b;
|
||||
--success-foreground: #6bff8f;
|
||||
--warning: #422006;
|
||||
--warning-foreground: #fde68a;
|
||||
--info: #172554;
|
||||
--info-foreground: #bfdbfe;
|
||||
--chart-1: #4ae176;
|
||||
--chart-2: #b7c8e1;
|
||||
--chart-3: #ffb4a9;
|
||||
--chart-4: #fbbf24;
|
||||
--chart-5: #60a5fa;
|
||||
--sidebar-background: #111c30;
|
||||
--sidebar-foreground: #f1f5f9;
|
||||
--sidebar-primary: #4ae176;
|
||||
--sidebar-primary-foreground: #002109;
|
||||
--sidebar-accent: #173b2b;
|
||||
--sidebar-accent-foreground: #6bff8f;
|
||||
--sidebar-border: #334155;
|
||||
--sidebar-ring: #4ae176;
|
||||
--sidebar: #111c30;
|
||||
}
|
||||
|
||||
html {
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
+5
-5
@@ -3,7 +3,8 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
||||
import type { DefineComponent } from 'vue';
|
||||
import { createApp, h } from 'vue';
|
||||
import '../css/app.css';
|
||||
import { initializeTheme } from '@/composables/useAppearance';
|
||||
import { createAppearance } from '@/composables/useAppearance';
|
||||
import { createLocalization } from '@/lib/localization';
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||
|
||||
@@ -17,12 +18,11 @@ 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: '#4B5563',
|
||||
color: 'var(--primary)',
|
||||
},
|
||||
});
|
||||
|
||||
// This will set light / dark mode on page load...
|
||||
initializeTheme();
|
||||
|
||||
@@ -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 = withDefaults(defineProps<Props>(), {
|
||||
title: 'Something went wrong.',
|
||||
});
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useLocalization();
|
||||
|
||||
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 }}</AlertTitle>
|
||||
<AlertTitle>{{ title ?? t('common.error') }}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul class="list-inside list-disc text-sm">
|
||||
<li v-for="(error, index) in uniqueErrors" :key="index">
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import AppLogoIcon from '@/components/AppLogoIcon.vue';
|
||||
import { Columns3 } from 'lucide-vue-next';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md"
|
||||
<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"
|
||||
>
|
||||
<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
|
||||
>
|
||||
</div>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import {
|
||||
BookOpen,
|
||||
Folder,
|
||||
FolderGit2,
|
||||
LayoutGrid,
|
||||
Layers3,
|
||||
Shield,
|
||||
Users,
|
||||
} from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import { Link, router } from '@inertiajs/vue3';
|
||||
import { Settings2, X } from 'lucide-vue-next';
|
||||
import { computed, onMounted, onUnmounted } from 'vue';
|
||||
import AppLogo from '@/components/AppLogo.vue';
|
||||
import NavFooter from '@/components/NavFooter.vue';
|
||||
import NavMain from '@/components/NavMain.vue';
|
||||
import NavUser from '@/components/NavUser.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -22,93 +13,98 @@ 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 { 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';
|
||||
import { edit as profile } from '@/routes/profile';
|
||||
import { show } from '@/routes/projects';
|
||||
|
||||
const page = usePage();
|
||||
|
||||
const mainNavItems = computed<NavItem[]>(() => {
|
||||
const items: NavItem[] = [
|
||||
const { t } = useLocalization();
|
||||
const { currentUrl } = useCurrentUrl();
|
||||
const { project, workspaceItems, projectItems, projectSettingsItems } =
|
||||
useAppNavigation();
|
||||
const { isMobile, setOpenMobile } = useSidebar();
|
||||
const accountItems = computed(() => [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
href: dashboard(),
|
||||
icon: LayoutGrid,
|
||||
title: t('navigation.settings'),
|
||||
href: profile(),
|
||||
icon: Settings2,
|
||||
isActive: currentUrl.value.startsWith('/settings/'),
|
||||
},
|
||||
{
|
||||
title: 'Projects',
|
||||
href: projectsIndex(),
|
||||
icon: Folder,
|
||||
},
|
||||
];
|
||||
|
||||
if (page.props.auth.permissions.projectCreate) {
|
||||
items.push({
|
||||
title: 'Project templates',
|
||||
href: projectTemplatesIndex(),
|
||||
icon: Layers3,
|
||||
]);
|
||||
let removeNavigationListener: (() => void) | undefined;
|
||||
onMounted(() => {
|
||||
removeNavigationListener = router.on('navigate', () =>
|
||||
setOpenMobile(false),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (page.props.auth.permissions.globalAdmin) {
|
||||
items.push({
|
||||
title: 'Users',
|
||||
href: adminUsers(),
|
||||
icon: Shield,
|
||||
});
|
||||
}
|
||||
|
||||
if (page.props.auth.permissions.canManageTeams) {
|
||||
items.push({
|
||||
title: 'Teams',
|
||||
href: teamsIndex(),
|
||||
icon: Users,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const footerNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Repository',
|
||||
href: 'https://github.com/laravel/vue-starter-kit',
|
||||
icon: FolderGit2,
|
||||
},
|
||||
{
|
||||
title: 'Documentation',
|
||||
href: 'https://laravel.com/docs/starter-kits#vue',
|
||||
icon: BookOpen,
|
||||
},
|
||||
];
|
||||
onUnmounted(() => removeNavigationListener?.());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sidebar collapsible="icon" variant="inset">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<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>
|
||||
<Link :href="dashboard()">
|
||||
<AppLogo />
|
||||
</Link>
|
||||
<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>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<NavMain :items="mainNavItems" />
|
||||
<nav :aria-label="t('navigation.main')">
|
||||
<NavMain
|
||||
:items="workspaceItems"
|
||||
:label="t('navigation.workspace')"
|
||||
/>
|
||||
<NavMain
|
||||
v-if="projectItems.length"
|
||||
:items="projectItems"
|
||||
:label="t('navigation.project')"
|
||||
/>
|
||||
<NavMain
|
||||
v-if="projectSettingsItems.length"
|
||||
:items="projectSettingsItems"
|
||||
:label="t('navigation.projectSettings')"
|
||||
/>
|
||||
</nav>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<NavFooter :items="footerNavItems" />
|
||||
<NavUser />
|
||||
<SidebarFooter class="border-sidebar-border border-t p-0">
|
||||
<NavMain :items="accountItems" :label="t('navigation.account')" />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
<slot />
|
||||
</template>
|
||||
|
||||
@@ -1,27 +1,145 @@
|
||||
<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[];
|
||||
}>(),
|
||||
{
|
||||
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],
|
||||
);
|
||||
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="border-sidebar-border/70 group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 flex h-16 shrink-0 items-center gap-2 border-b px-6 transition-[width,height] ease-linear md:px-4"
|
||||
class="bg-background md:px-page sticky top-0 z-20 flex min-h-16 min-w-0 shrink-0 items-center justify-between gap-2 border-b px-3 md:gap-4"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<SidebarTrigger class="-ml-1" />
|
||||
<template v-if="breadcrumbs && breadcrumbs.length > 0">
|
||||
<Breadcrumbs :breadcrumbs="breadcrumbs" />
|
||||
</template>
|
||||
<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>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -1,33 +1,39 @@
|
||||
<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: 'Light' },
|
||||
{ value: 'dark', Icon: Moon, label: 'Dark' },
|
||||
{ value: 'system', Icon: Monitor, label: 'System' },
|
||||
{ value: 'light', Icon: Sun, label: 'appearance.light' },
|
||||
{ value: 'dark', Icon: Moon, label: 'appearance.dark' },
|
||||
{ value: 'system', Icon: Monitor, label: 'appearance.system' },
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800"
|
||||
role="group"
|
||||
:aria-label="t('appearance.label')"
|
||||
class="border-border bg-muted inline-flex max-w-full gap-1 rounded-lg border p-1"
|
||||
>
|
||||
<button
|
||||
v-for="{ value, Icon, label } in tabs"
|
||||
:key="value"
|
||||
type="button"
|
||||
:aria-pressed="appearance === value"
|
||||
@click="updateAppearance(value)"
|
||||
:class="[
|
||||
'flex items-center rounded-md px-3.5 py-1.5 transition-colors',
|
||||
'min-h-control text-body focus-visible:outline-ring flex min-w-0 items-center justify-center gap-1.5 rounded-md px-3 font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||
appearance === value
|
||||
? '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',
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
]"
|
||||
>
|
||||
<component :is="Icon" class="-ml-1 h-4 w-4" />
|
||||
<span class="ml-1.5 text-sm">{{ label }}</span>
|
||||
<component :is="Icon" class="size-4 shrink-0" aria-hidden="true" />
|
||||
<span>{{ t(label) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type { FormFeedbackState } from '@/lib/form-feedback';
|
||||
import { login } from '@/routes/index';
|
||||
|
||||
const props = defineProps<{
|
||||
state: FormFeedbackState;
|
||||
responseStatus?: number | null;
|
||||
loadingTitle?: string;
|
||||
successTitle?: string;
|
||||
}>();
|
||||
defineEmits<{ retry: [] }>();
|
||||
const { t } = useLocalization();
|
||||
const expired = computed(
|
||||
() => props.state === 'server' && props.responseStatus === 419,
|
||||
);
|
||||
const limited = computed(
|
||||
() => props.state === 'server' && props.responseStatus === 429,
|
||||
);
|
||||
const title = computed(() => {
|
||||
if (expired.value) {
|
||||
return t('auth.feedback.expiredTitle');
|
||||
}
|
||||
if (limited.value) {
|
||||
return t('auth.feedback.limitedTitle');
|
||||
}
|
||||
if (props.state === 'loading') {
|
||||
return props.loadingTitle ?? t('auth.feedback.pending');
|
||||
}
|
||||
if (props.state === 'success') {
|
||||
return props.successTitle ?? t('auth.feedback.completed');
|
||||
}
|
||||
if (props.state === 'validation') {
|
||||
return t('auth.feedback.validationTitle');
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const description = computed(() => {
|
||||
if (expired.value) {
|
||||
return t('auth.feedback.expiredDescription');
|
||||
}
|
||||
if (limited.value) {
|
||||
return t('auth.feedback.limitedDescription');
|
||||
}
|
||||
if (props.state === 'validation') {
|
||||
return t('auth.feedback.validationDescription');
|
||||
}
|
||||
if (props.state === 'network') {
|
||||
return t('auth.feedback.networkDescription');
|
||||
}
|
||||
if (props.state === 'server') {
|
||||
return t('auth.feedback.serverDescription');
|
||||
}
|
||||
if (props.state === 'cancelled') {
|
||||
return t('auth.feedback.cancelledDescription');
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gap-card grid" v-show="state !== 'idle'">
|
||||
<FormFeedback
|
||||
:state="state"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:retryable="!expired"
|
||||
@retry="$emit('retry')"
|
||||
/>
|
||||
<Button v-if="expired" as-child variant="outline">
|
||||
<!-- A fresh document renews the session/CSRF state and drops in-memory secrets. -->
|
||||
<a :href="login.url()">{{ t('auth.backToLogin') }}</a>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -9,29 +9,48 @@ 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>
|
||||
<BreadcrumbList>
|
||||
<Breadcrumb class="min-w-0" :aria-label="t('navigation.breadcrumbs')">
|
||||
<BreadcrumbList class="flex-nowrap">
|
||||
<template v-for="(item, index) in breadcrumbs" :key="index">
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbItem
|
||||
:class="[
|
||||
'min-w-0',
|
||||
index !== breadcrumbs.length - 1
|
||||
? 'hidden md:inline-flex'
|
||||
: '',
|
||||
]"
|
||||
>
|
||||
<template v-if="index === breadcrumbs.length - 1">
|
||||
<BreadcrumbPage>{{ item.title }}</BreadcrumbPage>
|
||||
<BreadcrumbPage class="truncate" :title="item.title">{{
|
||||
item.title
|
||||
}}</BreadcrumbPage>
|
||||
</template>
|
||||
<template v-else>
|
||||
<BreadcrumbLink as-child>
|
||||
<Link :href="item.href">{{ item.title }}</Link>
|
||||
<Link
|
||||
:href="item.href"
|
||||
class="max-w-40 truncate"
|
||||
:title="item.title"
|
||||
>{{ item.title }}</Link
|
||||
>
|
||||
</BreadcrumbLink>
|
||||
</template>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator v-if="index !== breadcrumbs.length - 1" />
|
||||
<BreadcrumbSeparator
|
||||
v-if="index !== breadcrumbs.length - 1"
|
||||
class="hidden md:block"
|
||||
/>
|
||||
</template>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { CircleAlert, CircleCheck, Inbox, LoaderCircle } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
state: 'empty' | 'loading' | 'error' | 'success';
|
||||
title?: string;
|
||||
description?: string;
|
||||
retryable?: boolean;
|
||||
}>(),
|
||||
{ retryable: false },
|
||||
);
|
||||
defineEmits<{ retry: [] }>();
|
||||
const { t } = useLocalization();
|
||||
const icons = {
|
||||
empty: Inbox,
|
||||
loading: LoaderCircle,
|
||||
error: CircleAlert,
|
||||
success: CircleCheck,
|
||||
};
|
||||
const defaultTitles = {
|
||||
empty: 'feedback.empty',
|
||||
loading: 'common.loading',
|
||||
error: 'common.error',
|
||||
success: 'feedback.saved',
|
||||
} as const;
|
||||
const heading = computed(() => props.title ?? t(defaultTitles[props.state]));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:role="state === 'error' ? 'alert' : 'status'"
|
||||
:aria-busy="state === 'loading' || undefined"
|
||||
:data-feedback="state"
|
||||
class="gap-card bg-card p-card text-body text-card-foreground flex min-w-0 items-start rounded-md border"
|
||||
:class="{
|
||||
'border-destructive/40': state === 'error',
|
||||
'border-success/40': state === 'success',
|
||||
}"
|
||||
>
|
||||
<component
|
||||
:is="icons[state]"
|
||||
aria-hidden="true"
|
||||
class="mt-0.5 size-5 shrink-0"
|
||||
:class="{
|
||||
'text-primary animate-spin motion-reduce:animate-none':
|
||||
state === 'loading',
|
||||
'text-destructive': state === 'error',
|
||||
'text-success-foreground': state === 'success',
|
||||
'text-muted-foreground': state === 'empty',
|
||||
}"
|
||||
/>
|
||||
<div class="gap-stack grid min-w-0 flex-1 break-words">
|
||||
<p class="font-semibold">{{ heading }}</p>
|
||||
<p v-if="description" class="text-muted-foreground">
|
||||
{{ description }}
|
||||
</p>
|
||||
<div v-if="$slots.default"><slot /></div>
|
||||
<Button
|
||||
v-if="retryable && state === 'error'"
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="justify-self-start"
|
||||
@click="$emit('retry')"
|
||||
>{{ t('common.retry') }}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, useTemplateRef, watch } from 'vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type { FormFeedbackState } from '@/lib/form-feedback';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
state: FormFeedbackState;
|
||||
title?: string;
|
||||
description?: string;
|
||||
retryable?: boolean;
|
||||
}>(),
|
||||
{ retryable: true },
|
||||
);
|
||||
defineEmits<{ retry: [] }>();
|
||||
const { t } = useLocalization();
|
||||
const feedback = useTemplateRef<HTMLDivElement>('feedback');
|
||||
watch(
|
||||
() => props.state,
|
||||
async (state) => {
|
||||
if (['validation', 'network', 'server', 'cancelled'].includes(state)) {
|
||||
await nextTick();
|
||||
if (props.state === state) {
|
||||
feedback.value?.focus();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
const titles = {
|
||||
idle: 'feedback.saved',
|
||||
loading: 'forms.saving',
|
||||
success: 'feedback.saved',
|
||||
validation: 'forms.validationTitle',
|
||||
network: 'forms.networkTitle',
|
||||
server: 'forms.serverTitle',
|
||||
cancelled: 'forms.cancelledTitle',
|
||||
} as const;
|
||||
const descriptions = {
|
||||
validation: 'forms.validationDescription',
|
||||
network: 'forms.networkDescription',
|
||||
server: 'forms.serverDescription',
|
||||
cancelled: 'forms.cancelledDescription',
|
||||
} as const;
|
||||
const description = computed(() =>
|
||||
props.state in descriptions
|
||||
? t(descriptions[props.state as keyof typeof descriptions])
|
||||
: undefined,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="feedback"
|
||||
v-show="state !== 'idle'"
|
||||
:data-form-state="state"
|
||||
tabindex="-1"
|
||||
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
|
||||
>
|
||||
<FeedbackState
|
||||
v-if="state !== 'idle'"
|
||||
:state="
|
||||
state === 'loading' || state === 'success' ? state : 'error'
|
||||
"
|
||||
:title="props.title ?? t(titles[state])"
|
||||
:description="props.description ?? description"
|
||||
:retryable="
|
||||
props.retryable !== false &&
|
||||
(state === 'network' ||
|
||||
state === 'server' ||
|
||||
state === 'cancelled')
|
||||
"
|
||||
@retry="$emit('retry')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -5,8 +5,8 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-show="message">
|
||||
<p class="text-sm text-red-600 dark:text-red-500">
|
||||
<div v-show="message" role="alert">
|
||||
<p class="text-body text-destructive">
|
||||
{{ message }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -12,23 +12,46 @@ import type { NavItem } from '@/types';
|
||||
|
||||
defineProps<{
|
||||
items: NavItem[];
|
||||
label: string;
|
||||
}>();
|
||||
|
||||
const { isCurrentUrl } = useCurrentUrl();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarGroup class="px-2 py-0">
|
||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<SidebarGroup class="px-3 py-2">
|
||||
<SidebarGroupLabel class="text-label text-muted-foreground uppercase">{{
|
||||
label
|
||||
}}</SidebarGroupLabel>
|
||||
<SidebarMenu role="list" :aria-label="label">
|
||||
<SidebarMenuItem v-for="item in items" :key="item.title">
|
||||
<SidebarMenuButton
|
||||
as-child
|
||||
:is-active="isCurrentUrl(item.href)"
|
||||
:is-active="
|
||||
item.isActive ??
|
||||
isCurrentUrl(
|
||||
item.href,
|
||||
undefined,
|
||||
item.match === 'section',
|
||||
)
|
||||
"
|
||||
: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">
|
||||
<component :is="item.icon" />
|
||||
<Link
|
||||
:href="item.href"
|
||||
:aria-current="
|
||||
(item.isActive ??
|
||||
isCurrentUrl(
|
||||
item.href,
|
||||
undefined,
|
||||
item.match === 'section',
|
||||
))
|
||||
? 'page'
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<component :is="item.icon" aria-hidden="true" />
|
||||
<span>{{ item.title }}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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';
|
||||
|
||||
@@ -9,12 +10,14 @@ 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,
|
||||
@@ -28,7 +31,8 @@ defineExpose({
|
||||
ref="inputRef"
|
||||
v-model="model"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
:class="cn('pr-10', props.class)"
|
||||
:class="cn('pr-12', props.class)"
|
||||
:disabled="disabled"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
<button
|
||||
@@ -36,11 +40,14 @@ defineExpose({
|
||||
@click="showPassword = !showPassword"
|
||||
:class="
|
||||
cn(
|
||||
'text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 focus-visible:outline-none focus-visible:ring-[3px]',
|
||||
'text-muted-foreground hover:text-foreground focus-visible:ring-ring w-control absolute inset-y-0 right-0 flex items-center justify-center rounded-r-md focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
)
|
||||
"
|
||||
:aria-label="showPassword ? 'Hide password' : 'Show password'"
|
||||
:tabindex="-1"
|
||||
:aria-label="
|
||||
t(showPassword ? 'auth.password.hide' : 'auth.password.show')
|
||||
"
|
||||
:aria-pressed="showPassword"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<EyeOff v-if="showPassword" class="size-4" />
|
||||
<Eye v-else class="size-4" />
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { ChevronDown, Settings2 } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useAppNavigation } from '@/composables/useAppNavigation';
|
||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
|
||||
const { projectItems, projectSettingsItems } = useAppNavigation();
|
||||
const { isCurrentUrl } = useCurrentUrl();
|
||||
const { t } = useLocalization();
|
||||
const settingsActive = computed(() =>
|
||||
projectSettingsItems.value.some((item) => isCurrentUrl(item.href)),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="projectItems.length"
|
||||
class="bg-card md:px-page flex min-w-0 items-center gap-2 border-b px-4"
|
||||
>
|
||||
<nav
|
||||
:aria-label="t('navigation.project')"
|
||||
class="flex min-w-0 flex-1 gap-4 overflow-x-auto"
|
||||
>
|
||||
<Link
|
||||
v-for="item in projectItems"
|
||||
:key="item.title"
|
||||
:href="item.href"
|
||||
:aria-current="isCurrentUrl(item.href) ? 'page' : undefined"
|
||||
:class="[
|
||||
'min-h-control text-body flex shrink-0 items-center gap-2 border-b-2 px-1 py-3 font-medium outline-offset-[-3px]',
|
||||
isCurrentUrl(item.href)
|
||||
? 'border-brand text-primary'
|
||||
: 'text-muted-foreground hover:border-border hover:text-foreground border-transparent',
|
||||
]"
|
||||
>
|
||||
<component
|
||||
:is="item.icon"
|
||||
class="size-4"
|
||||
aria-hidden="true"
|
||||
/>{{ item.title }}
|
||||
</Link>
|
||||
</nav>
|
||||
<DropdownMenu v-if="projectSettingsItems.length">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:aria-label="t('navigation.projectSettings')"
|
||||
:class="{
|
||||
'bg-accent text-accent-foreground': settingsActive,
|
||||
}"
|
||||
>
|
||||
<Settings2 aria-hidden="true" /><span
|
||||
class="hidden sm:inline"
|
||||
>{{ t('navigation.projectSettings') }}</span
|
||||
><ChevronDown class="hidden sm:block" aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
v-for="item in projectSettingsItems"
|
||||
:key="item.title"
|
||||
as-child
|
||||
>
|
||||
<Link
|
||||
:href="item.href"
|
||||
:aria-current="
|
||||
isCurrentUrl(item.href) ? 'page' : undefined
|
||||
"
|
||||
:class="{ 'bg-accent': isCurrentUrl(item.href) }"
|
||||
>
|
||||
<component :is="item.icon" aria-hidden="true" />{{
|
||||
item.title
|
||||
}}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,33 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useInitials } from '@/composables/useInitials';
|
||||
import type { User } from '@/types';
|
||||
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||
import type { User } from '@/types';
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
showEmail?: boolean;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
withDefaults(defineProps<{ user: User; showEmail?: boolean }>(), {
|
||||
showEmail: false,
|
||||
});
|
||||
|
||||
const { getInitials } = useInitials();
|
||||
|
||||
// Compute whether we should show the avatar image
|
||||
const showAvatar = computed(
|
||||
() => props.user.avatar && props.user.avatar !== '',
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserAvatar />
|
||||
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<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>
|
||||
<span
|
||||
v-if="showEmail"
|
||||
class="text-muted-foreground truncate text-xs"
|
||||
>{{ user.email }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,6 +11,7 @@ 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;
|
||||
@@ -21,6 +22,7 @@ const handleLogout = () => {
|
||||
};
|
||||
|
||||
defineProps<Props>();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -34,7 +36,7 @@ defineProps<Props>();
|
||||
<DropdownMenuItem :as-child="true">
|
||||
<Link class="block w-full cursor-pointer" :href="edit()" prefetch>
|
||||
<Settings class="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
{{ t('navigation.settings') }}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
@@ -48,7 +50,7 @@ defineProps<Props>();
|
||||
data-test="logout-button"
|
||||
>
|
||||
<LogOut class="mr-2 h-4 w-4" />
|
||||
Log out
|
||||
{{ t('navigation.logout') }}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { ArrowUpRight } from 'lucide-vue-next';
|
||||
import TicketMetadata from '@/components/tickets/TicketMetadata.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { show as showTicket } from '@/routes/projects/tickets';
|
||||
import type {
|
||||
TicketPresentationEmits,
|
||||
TicketPresentationProps,
|
||||
} from '@/types/ticket-presentation';
|
||||
|
||||
withDefaults(defineProps<TicketPresentationProps>(), {
|
||||
showDescription: true,
|
||||
showStatus: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<TicketPresentationEmits>();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
:class="[
|
||||
'bg-card text-card-foreground min-w-0 rounded-md border shadow-sm',
|
||||
ticket.is_blocked ? 'border-warning' : 'border-border',
|
||||
]"
|
||||
>
|
||||
<Link
|
||||
:href="showTicket({ project: projectId, ticket: ticket.id })"
|
||||
:aria-label="
|
||||
t('tickets.open', {
|
||||
key: ticket.ticket_key,
|
||||
title: ticket.title,
|
||||
})
|
||||
"
|
||||
class="focus-visible:ring-ring/35 gap-card min-h-control hover:bg-accent/35 group flex min-w-0 flex-col rounded-t-md p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2"
|
||||
@click="emit('open', ticket.id)"
|
||||
>
|
||||
<div class="flex min-w-0 items-start justify-between gap-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="wrap-anywhere max-w-full whitespace-normal"
|
||||
>
|
||||
{{ ticket.ticket_key }}
|
||||
</Badge>
|
||||
<ArrowUpRight
|
||||
class="text-muted-foreground group-hover:text-primary size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
|
||||
>
|
||||
{{ ticket.title }}
|
||||
</h3>
|
||||
|
||||
<TicketMetadata :ticket="ticket" :show-status="showStatus" />
|
||||
|
||||
<p
|
||||
v-if="showDescription && ticket.description"
|
||||
class="text-caption text-muted-foreground wrap-anywhere line-clamp-3"
|
||||
>
|
||||
{{ ticket.description }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="ticket.is_blocked && ticket.blocked_comment"
|
||||
class="text-caption text-warning-foreground bg-warning wrap-anywhere rounded-sm px-3 py-2"
|
||||
>
|
||||
{{
|
||||
t('tickets.blockerComment', {
|
||||
comment: ticket.blocked_comment,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
<div v-if="$slots.actions" class="border-t p-4">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { ArrowUpRight } from 'lucide-vue-next';
|
||||
import TicketMetadata from '@/components/tickets/TicketMetadata.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { show as showTicket } from '@/routes/projects/tickets';
|
||||
import type {
|
||||
TicketPresentationEmits,
|
||||
TicketPresentationProps,
|
||||
} from '@/types/ticket-presentation';
|
||||
|
||||
withDefaults(defineProps<TicketPresentationProps>(), {
|
||||
showDescription: true,
|
||||
showStatus: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<TicketPresentationEmits>();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
:class="[
|
||||
'bg-card text-card-foreground min-w-0 rounded-md border',
|
||||
ticket.is_blocked ? 'border-warning' : 'border-border',
|
||||
]"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col lg:flex-row lg:items-stretch">
|
||||
<Link
|
||||
:href="showTicket({ project: projectId, ticket: ticket.id })"
|
||||
:aria-label="
|
||||
t('tickets.open', {
|
||||
key: ticket.ticket_key,
|
||||
title: ticket.title,
|
||||
})
|
||||
"
|
||||
class="focus-visible:ring-ring/35 gap-card min-h-control hover:bg-accent/35 group flex min-w-0 flex-1 flex-col rounded-t-md p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2 lg:rounded-l-md lg:rounded-tr-none"
|
||||
@click="emit('open', ticket.id)"
|
||||
>
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="wrap-anywhere max-w-full whitespace-normal"
|
||||
>
|
||||
{{ ticket.ticket_key }}
|
||||
</Badge>
|
||||
<h3
|
||||
class="text-body group-hover:text-primary wrap-anywhere min-w-0 flex-1 font-semibold"
|
||||
>
|
||||
{{ ticket.title }}
|
||||
</h3>
|
||||
<ArrowUpRight
|
||||
class="text-muted-foreground group-hover:text-primary size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TicketMetadata :ticket="ticket" :show-status="showStatus" />
|
||||
|
||||
<p
|
||||
v-if="showDescription && ticket.description"
|
||||
class="text-caption text-muted-foreground wrap-anywhere line-clamp-2"
|
||||
>
|
||||
{{ ticket.description }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="ticket.is_blocked && ticket.blocked_comment"
|
||||
class="text-caption text-warning-foreground bg-warning wrap-anywhere rounded-sm px-3 py-2"
|
||||
>
|
||||
{{
|
||||
t('tickets.blockerComment', {
|
||||
comment: ticket.blocked_comment,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
<div
|
||||
v-if="$slots.actions"
|
||||
class="border-t p-4 lg:flex lg:w-80 lg:items-center lg:border-l lg:border-t-0"
|
||||
>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { CircleUserRound, OctagonAlert } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { UserAvatar } from '@/components/ui/avatar';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { ticketStatusLabel } from '@/lib/tickets';
|
||||
import type { TicketModel } from '@/types/models/ticket-model';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
showStatus?: boolean;
|
||||
ticket: TicketModel;
|
||||
}>(),
|
||||
{
|
||||
showStatus: true,
|
||||
},
|
||||
);
|
||||
|
||||
const { t } = useLocalization();
|
||||
const statusLabel = computed(() =>
|
||||
ticketStatusLabel(props.ticket.ticket_status, t('tickets.noStatus')),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gap-stack flex min-w-0 flex-wrap items-center">
|
||||
<Badge
|
||||
v-if="ticket.is_blocked"
|
||||
variant="warning"
|
||||
:aria-label="t('tickets.blocked')"
|
||||
>
|
||||
<OctagonAlert aria-hidden="true" />
|
||||
{{ t('tickets.blocked') }}
|
||||
</Badge>
|
||||
|
||||
<Badge
|
||||
variant="secondary"
|
||||
:aria-label="
|
||||
t('tickets.typeLabel', { name: ticket.ticket_type.name })
|
||||
"
|
||||
:data-ticket-type-id="ticket.ticket_type.id"
|
||||
>
|
||||
{{ ticket.ticket_type.name }}
|
||||
</Badge>
|
||||
|
||||
<Badge
|
||||
variant="outline"
|
||||
:aria-label="
|
||||
t('tickets.priorityLabel', {
|
||||
name: ticket.ticket_priority.name,
|
||||
})
|
||||
"
|
||||
:data-ticket-priority-id="ticket.ticket_priority.id"
|
||||
>
|
||||
{{ ticket.ticket_priority.name }}
|
||||
</Badge>
|
||||
|
||||
<Badge
|
||||
v-if="showStatus"
|
||||
variant="outline"
|
||||
:aria-label="t('tickets.statusLabel', { name: statusLabel })"
|
||||
:data-ticket-status-id="ticket.ticket_status?.id"
|
||||
>
|
||||
{{ statusLabel }}
|
||||
</Badge>
|
||||
|
||||
<span
|
||||
v-if="ticket.assignee"
|
||||
class="text-caption text-muted-foreground flex min-w-0 items-center gap-1.5"
|
||||
:aria-label="
|
||||
t('tickets.assignedTo', { name: ticket.assignee.name })
|
||||
"
|
||||
>
|
||||
<UserAvatar
|
||||
:user="ticket.assignee"
|
||||
size="sm"
|
||||
:label="ticket.assignee.name"
|
||||
/>
|
||||
<span class="max-w-40 truncate">{{ ticket.assignee.name }}</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-else
|
||||
class="text-caption text-muted-foreground flex items-center gap-1.5"
|
||||
>
|
||||
<CircleUserRound class="size-4" aria-hidden="true" />
|
||||
{{ t('tickets.unassigned') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as TicketCard } from './TicketCard.vue';
|
||||
export { default as TicketListItem } from './TicketListItem.vue';
|
||||
@@ -1,17 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { AvatarRoot } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
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 ".";
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
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);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AvatarRoot
|
||||
data-slot="avatar"
|
||||
:class="cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', props.class)"
|
||||
:data-size="size"
|
||||
:data-shape="shape"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn(avatarVariants({ size, shape }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AvatarRoot>
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
<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-muted flex size-full items-center justify-center rounded-full', props.class)"
|
||||
:class="
|
||||
cn(
|
||||
'bg-accent text-accent-foreground flex size-full items-center justify-center font-semibold',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</AvatarFallback>
|
||||
|
||||
@@ -1,41 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { getInitials } from "@/composables/useInitials";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/components/ui/avatar/index";
|
||||
import type { AvatarVariants } from "@/components/ui/avatar";
|
||||
import type { UserModel } from "@/types/models/user-model";
|
||||
import { usePage } from "@inertiajs/vue3";
|
||||
import { computed, PropType } from "vue";
|
||||
import { UserModel } from "@/types/models/user-model";
|
||||
import { computed } from "vue";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { getInitials } from "@/composables/useInitials";
|
||||
|
||||
const props = defineProps({
|
||||
user: {
|
||||
type: Object as PropType<UserModel>,
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
user?: UserModel;
|
||||
rounded?: boolean;
|
||||
size?: AvatarVariants["size"];
|
||||
label?: string;
|
||||
}>(),
|
||||
{
|
||||
rounded: true,
|
||||
size: "default",
|
||||
},
|
||||
rounded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const page = usePage();
|
||||
const auth = computed(() => page.props.auth);
|
||||
|
||||
const user = computed(() => props.user ?? auth.value.user)
|
||||
const user = computed(() => props.user ?? page.props.auth.user);
|
||||
const accessibleLabel = computed(() => props.label ?? user.value.name);
|
||||
const initials = computed(() => getInitials(user.value.name));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Avatar class="size-8 overflow-hidden" :class="rounded ? 'rounded-full' : 'rounded-lg'">
|
||||
<Avatar :size="size" :shape="rounded ? 'circle' : 'rounded'">
|
||||
<AvatarImage
|
||||
v-if="user.avatar"
|
||||
:src="user.avatar"
|
||||
:alt="user.name"
|
||||
:alt="accessibleLabel"
|
||||
/>
|
||||
<AvatarFallback
|
||||
class="rounded-lg bg-neutral-200 font-semibold text-black dark:bg-neutral-700 dark:text-white"
|
||||
>
|
||||
{{ getInitials(auth.user?.name) }}
|
||||
<AvatarFallback :aria-label="accessibleLabel">
|
||||
{{ initials }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</template>
|
||||
|
||||
@@ -1,3 +1,31 @@
|
||||
export { default as Avatar } from "./Avatar.vue"
|
||||
export { default as AvatarFallback } from "./AvatarFallback.vue"
|
||||
export { default as AvatarImage } from "./AvatarImage.vue"
|
||||
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>;
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
<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")
|
||||
const delegatedProps = reactiveOmit(props, "class", "variant");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
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 items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2.5 py-1 text-label whitespace-nowrap transition-[background-color,border-color,color,box-shadow] outline-none [&>svg]:pointer-events-none [&>svg]:size-3 [&>svg]:shrink-0 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
{
|
||||
variants: {
|
||||
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",
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
"border-transparent bg-destructive text-destructive-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/35",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
"border-border bg-card text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
success:
|
||||
"border-transparent bg-success text-success-foreground [a&]:hover:brightness-95",
|
||||
warning:
|
||||
"border-transparent bg-warning text-warning-foreground [a&]:hover:brightness-95",
|
||||
info: "border-transparent bg-info text-info-foreground [a&]:hover:brightness-95",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||
);
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>;
|
||||
|
||||
@@ -1,29 +1,47 @@
|
||||
<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 { 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 { computed } from "vue";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from ".";
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants["variant"]
|
||||
size?: ButtonVariants["size"]
|
||||
class?: HTMLAttributes["class"]
|
||||
variant?: ButtonVariants["variant"];
|
||||
size?: ButtonVariants["size"];
|
||||
class?: HTMLAttributes["class"];
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
loadingLabel?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: "button",
|
||||
})
|
||||
disabled: false,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const isDisabled = computed(() => props.disabled || props.loading);
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -1,32 +1,31 @@
|
||||
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 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-body font-semibold transition-[background-color,border-color,color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[loading]:cursor-wait data-[loading]:opacity-80 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 focus-visible:ring-offset-2 focus-visible:ring-offset-background [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
"border border-transparent bg-brand text-brand-foreground shadow-xs hover:brightness-95",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
"border border-transparent bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/35",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
"border border-input bg-card text-foreground shadow-xs hover:border-primary/45 hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"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",
|
||||
"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": "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",
|
||||
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: {
|
||||
@@ -34,5 +33,5 @@ export const buttonVariants = cva(
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
);
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>;
|
||||
|
||||
@@ -1,34 +1,47 @@
|
||||
<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 = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<CheckboxRootEmits>()
|
||||
const props = withDefaults(
|
||||
defineProps<
|
||||
CheckboxRootProps & {
|
||||
class?: HTMLAttributes["class"];
|
||||
invalid?: boolean;
|
||||
}
|
||||
>(),
|
||||
{ invalid: false },
|
||||
);
|
||||
const emits = defineEmits<CheckboxRootEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, "class", "invalid");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<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 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)"
|
||||
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,
|
||||
)
|
||||
"
|
||||
>
|
||||
<CheckboxIndicator
|
||||
data-slot="checkbox-indicator"
|
||||
class="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<slot v-bind="slotProps">
|
||||
<Check class="size-3.5" />
|
||||
<Check class="size-4" stroke-width="2.5" />
|
||||
</slot>
|
||||
</CheckboxIndicator>
|
||||
</CheckboxRoot>
|
||||
|
||||
@@ -1,29 +1,41 @@
|
||||
<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"
|
||||
} from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import DialogOverlay from "./DialogOverlay.vue";
|
||||
import { useDialogFocus } from "@/composables/useDialogFocus";
|
||||
import { useLocalization } from "@/composables/useLocalization";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
|
||||
const props = withDefaults(
|
||||
defineProps<
|
||||
DialogContentProps & {
|
||||
class?: HTMLAttributes["class"];
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
>(),
|
||||
{
|
||||
showCloseButton: true,
|
||||
})
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
},
|
||||
);
|
||||
const emits = defineEmits<DialogContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, "class", "showCloseButton");
|
||||
const { restoreFocus } = useDialogFocus();
|
||||
const { t } = useLocalization();
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -32,21 +44,23 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<DialogContent
|
||||
data-slot="dialog-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
@close-auto-focus="restoreFocus"
|
||||
: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',
|
||||
'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 />
|
||||
|
||||
<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"
|
||||
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">Close</span>
|
||||
<span class="sr-only">{{ t("common.close") }}</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<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-2 text-center sm:text-left', props.class)"
|
||||
:class="cn('flex flex-col gap-stack pr-6 text-left', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
<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"
|
||||
} from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDialogFocus } from "@/composables/useDialogFocus";
|
||||
import { useLocalization } from "@/composables/useLocalization";
|
||||
|
||||
defineOptions({
|
||||
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 forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
const { restoreFocus } = useDialogFocus();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -32,26 +38,32 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<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',
|
||||
'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 }"
|
||||
@pointer-down-outside="(event) => {
|
||||
@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) {
|
||||
if (
|
||||
originalEvent.offsetX > target.clientWidth ||
|
||||
originalEvent.offsetY > target.clientHeight
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}"
|
||||
}
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||
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">Close</span>
|
||||
<span class="sr-only">{{ t("common.close") }}</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
|
||||
@@ -1,33 +1,42 @@
|
||||
<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"
|
||||
} 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 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',
|
||||
: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"
|
||||
>
|
||||
<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" />
|
||||
|
||||
@@ -1,29 +1,34 @@
|
||||
<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"
|
||||
} from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||
defineProps<
|
||||
DropdownMenuContentProps & { class?: HTMLAttributes["class"] }
|
||||
>(),
|
||||
{
|
||||
sideOffset: 4,
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<DropdownMenuContentEmits>()
|
||||
);
|
||||
const emits = defineEmits<DropdownMenuContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -31,7 +36,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<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)"
|
||||
: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>
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
<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"
|
||||
}>(), {
|
||||
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>
|
||||
@@ -24,7 +29,12 @@ const forwardedProps = useForwardProps(delegatedProps)
|
||||
: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)"
|
||||
: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>
|
||||
|
||||
@@ -1,34 +1,43 @@
|
||||
<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"
|
||||
} 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 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',
|
||||
: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"
|
||||
>
|
||||
<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" />
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
<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 min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', props.class)"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] 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>
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
<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 cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
|
||||
: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" />
|
||||
|
||||
@@ -1,33 +1,38 @@
|
||||
<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"]
|
||||
}>()
|
||||
defaultValue?: string | number;
|
||||
modelValue?: string | number | null;
|
||||
class?: HTMLAttributes["class"];
|
||||
invalid?: boolean;
|
||||
}>();
|
||||
|
||||
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,
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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',
|
||||
: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,
|
||||
)"
|
||||
>
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
<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 "."
|
||||
} from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SelectScrollDownButton, SelectScrollUpButton } from ".";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||
{
|
||||
position: "popper",
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<SelectContentEmits>()
|
||||
);
|
||||
const emits = defineEmits<SelectContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -33,16 +33,25 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<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',
|
||||
: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')">
|
||||
<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 />
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
<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"
|
||||
} 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>
|
||||
@@ -24,12 +26,14 @@ const forwardedProps = useForwardProps(delegatedProps)
|
||||
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',
|
||||
'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">
|
||||
<span
|
||||
class="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<Check class="size-4" />
|
||||
|
||||
@@ -1,33 +1,45 @@
|
||||
<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" }>(),
|
||||
{ size: "default" },
|
||||
)
|
||||
defineProps<
|
||||
SelectTriggerProps & {
|
||||
class?: HTMLAttributes["class"];
|
||||
size?: "sm" | "default";
|
||||
invalid?: boolean;
|
||||
}
|
||||
>(),
|
||||
{ size: "default", invalid: false },
|
||||
);
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "size")
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
const delegatedProps = reactiveOmit(props, "class", "size", "invalid");
|
||||
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(
|
||||
'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',
|
||||
: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 opacity-50" />
|
||||
<ChevronDown
|
||||
class="size-4 transition-transform duration-150 group-data-[state=open]/select-trigger:rotate-180"
|
||||
/>
|
||||
</SelectIcon>
|
||||
</SelectTrigger>
|
||||
</template>
|
||||
|
||||
@@ -1,34 +1,38 @@
|
||||
<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"
|
||||
} from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import SheetOverlay from "./SheetOverlay.vue";
|
||||
import { useDialogFocus } from "@/composables/useDialogFocus";
|
||||
import { useLocalization } from "@/composables/useLocalization";
|
||||
|
||||
interface SheetContentProps extends DialogContentProps {
|
||||
class?: HTMLAttributes["class"]
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
class?: HTMLAttributes["class"];
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<SheetContentProps>(), {
|
||||
side: "right",
|
||||
})
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
});
|
||||
const emits = defineEmits<DialogContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "side")
|
||||
const delegatedProps = reactiveOmit(props, "class", "side");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
const { restoreFocus } = useDialogFocus();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -36,26 +40,30 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<SheetOverlay />
|
||||
<DialogContent
|
||||
data-slot="sheet-content"
|
||||
:class="cn(
|
||||
: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)"
|
||||
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 />
|
||||
|
||||
<DialogClose
|
||||
class="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
|
||||
class="ring-offset-background focus-visible:ring-ring absolute top-1 right-1 flex size-control items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none"
|
||||
>
|
||||
<X class="size-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
<span class="sr-only">{{ t("common.close") }}</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
|
||||
@@ -1,51 +1,69 @@
|
||||
<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 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";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<SidebarProps>(), {
|
||||
side: "left",
|
||||
variant: "sidebar",
|
||||
collapsible: "offcanvas",
|
||||
})
|
||||
});
|
||||
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
const { t } = useLocalization();
|
||||
|
||||
function restoreTriggerFocus(event: Event) {
|
||||
event.preventDefault();
|
||||
document
|
||||
.querySelector<HTMLButtonElement>('[data-sidebar="trigger"]')
|
||||
?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<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)"
|
||||
: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">
|
||||
<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) p-0 [&>button]:hidden"
|
||||
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>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
<SheetTitle>{{ t("navigation.main") }}</SheetTitle>
|
||||
<SheetDescription>{{
|
||||
t("navigation.mobileDescription")
|
||||
}}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div class="flex h-full w-full flex-col">
|
||||
<div class="flex min-h-0 h-full w-full flex-col">
|
||||
<slot />
|
||||
</div>
|
||||
</SheetContent>
|
||||
@@ -62,17 +80,20 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
>
|
||||
<!-- This is what handles the sidebar gap on desktop -->
|
||||
<div
|
||||
:class="cn(
|
||||
: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(
|
||||
: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)]'
|
||||
@@ -82,7 +103,8 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
? '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
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
<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-auto group-data-[collapsible=icon]:overflow-hidden', props.class)"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
<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 w-full flex-1 flex-col',
|
||||
: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>
|
||||
|
||||
@@ -1,30 +1,42 @@
|
||||
<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,
|
||||
})
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<SidebarMenuButtonProps & {
|
||||
tooltip?: string | Component
|
||||
}>(), {
|
||||
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" v-bind="{ ...delegatedProps, ...$attrs }">
|
||||
<SidebarMenuButtonChild
|
||||
v-if="!tooltip || isMobile || state !== 'collapsed'"
|
||||
v-bind="{ ...delegatedProps, ...$attrs }"
|
||||
>
|
||||
<slot />
|
||||
</SidebarMenuButtonChild>
|
||||
|
||||
|
||||
@@ -1,58 +1,83 @@
|
||||
<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 } 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, watch } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
provideSidebarContext,
|
||||
SIDEBAR_COOKIE_MAX_AGE,
|
||||
SIDEBAR_COOKIE_NAME,
|
||||
SIDEBAR_KEYBOARD_SHORTCUT,
|
||||
SIDEBAR_WIDTH,
|
||||
SIDEBAR_WIDTH_ICON,
|
||||
} from "./utils";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
class?: HTMLAttributes["class"]
|
||||
}>(), {
|
||||
defaultOpen: !defaultDocument?.cookie.includes(`${SIDEBAR_COOKIE_NAME}=false`),
|
||||
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("(max-width: 768px)")
|
||||
const openMobile = ref(false)
|
||||
const isMobile = useMediaQuery("(width < 768px)");
|
||||
const openMobile = ref(false);
|
||||
watch(isMobile, () => {
|
||||
openMobile.value = false;
|
||||
});
|
||||
|
||||
const open = useVModel(props, "open", emits, {
|
||||
defaultValue: props.defaultOpen ?? false,
|
||||
passive: (props.open === undefined) as false,
|
||||
}) as Ref<boolean>
|
||||
}) 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}`
|
||||
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,
|
||||
@@ -62,7 +87,7 @@ provideSidebarContext({
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -73,7 +98,12 @@ provideSidebarContext({
|
||||
'--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)"
|
||||
:class="
|
||||
cn(
|
||||
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<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 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";
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
class?: HTMLAttributes["class"];
|
||||
}>();
|
||||
|
||||
const { isMobile, state, toggleSidebar } = useSidebar()
|
||||
const { isMobile, state, openMobile, toggleSidebar } = useSidebar();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -18,11 +20,25 @@ const { isMobile, state, toggleSidebar } = useSidebar()
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:class="cn('h-7 w-7', props.class)"
|
||||
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'" />
|
||||
<PanelLeftClose v-else />
|
||||
<span class="sr-only">Toggle sidebar</span>
|
||||
<PanelLeftOpen
|
||||
v-if="isMobile || state === 'collapsed'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<PanelLeftClose v-else aria-hidden="true" />
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@@ -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,11 +11,12 @@ 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 = "3rem"
|
||||
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 = "4rem";
|
||||
export const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
|
||||
export const [useSidebar, provideSidebarContext] = createContext<SidebarContext>("Sidebar")
|
||||
export const [useSidebar, provideSidebarContext] =
|
||||
createContext<SidebarContext>("Sidebar");
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { Loader2Icon } from "lucide-vue-next"
|
||||
import { cn } from "@/lib/utils"
|
||||
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";
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
class?: HTMLAttributes["class"];
|
||||
label?: string;
|
||||
}>();
|
||||
|
||||
const { t } = useLocalization();
|
||||
const accessibleLabel = computed(() => props.label ?? t("common.loading"));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Loader2Icon
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
:aria-label="accessibleLabel"
|
||||
:class="cn('size-4 animate-spin', props.class)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import {
|
||||
projectNavigation,
|
||||
projectSettingsNavigation,
|
||||
workspaceNavigation,
|
||||
} from '@/lib/navigation';
|
||||
|
||||
export function useAppNavigation() {
|
||||
const page = usePage();
|
||||
const { t } = useLocalization();
|
||||
const project = computed(() => page.props.navigation?.project ?? null);
|
||||
|
||||
return {
|
||||
project,
|
||||
workspaceItems: computed(() =>
|
||||
workspaceNavigation(page.props.auth.permissions, t),
|
||||
),
|
||||
projectItems: computed(() => projectNavigation(project.value, t)),
|
||||
projectSettingsItems: computed(() =>
|
||||
projectSettingsNavigation(project.value, t),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -1,124 +1,107 @@
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import type { App, ComputedRef, InjectionKey, Ref } from 'vue';
|
||||
import { computed, inject, readonly, ref } from 'vue';
|
||||
import type { Appearance, ResolvedAppearance } from '@/types';
|
||||
|
||||
export type { Appearance, ResolvedAppearance };
|
||||
|
||||
export type UseAppearanceReturn = {
|
||||
appearance: Ref<Appearance>;
|
||||
appearance: Readonly<Ref<Appearance>>;
|
||||
resolvedAppearance: ComputedRef<ResolvedAppearance>;
|
||||
updateAppearance: (value: Appearance) => void;
|
||||
};
|
||||
|
||||
export function updateTheme(value: Appearance): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
const appearanceKey: InjectionKey<UseAppearanceReturn> = Symbol('appearance');
|
||||
|
||||
export function normalizeAppearance(value: unknown): Appearance {
|
||||
return value === 'light' || value === 'dark' ? value : 'system';
|
||||
}
|
||||
|
||||
if (value === 'system') {
|
||||
const mediaQueryList = window.matchMedia(
|
||||
'(prefers-color-scheme: dark)',
|
||||
/** 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,
|
||||
);
|
||||
const systemTheme = mediaQueryList.matches ? 'dark' : 'light';
|
||||
|
||||
function applyTheme(): void {
|
||||
document.documentElement.classList.toggle(
|
||||
'dark',
|
||||
systemTheme === 'dark',
|
||||
resolvedAppearance.value === 'dark',
|
||||
);
|
||||
} else {
|
||||
document.documentElement.classList.toggle('dark', value === 'dark');
|
||||
}
|
||||
document.documentElement.style.colorScheme = resolvedAppearance.value;
|
||||
document.documentElement.dataset.appearance = appearance.value;
|
||||
}
|
||||
|
||||
const setCookie = (name: string, value: string, days = 365) => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxAge = days * 24 * 60 * 60;
|
||||
|
||||
document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`;
|
||||
};
|
||||
|
||||
const mediaQuery = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)');
|
||||
};
|
||||
|
||||
const getStoredAppearance = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return localStorage.getItem('appearance') as Appearance | null;
|
||||
};
|
||||
|
||||
const prefersDark = (): boolean => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
};
|
||||
|
||||
const handleSystemThemeChange = () => {
|
||||
const currentAppearance = getStoredAppearance();
|
||||
|
||||
updateTheme(currentAppearance || 'system');
|
||||
};
|
||||
|
||||
export function initializeTheme(): void {
|
||||
function updateAppearance(value: Appearance): void {
|
||||
appearance.value = normalizeAppearance(value);
|
||||
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);
|
||||
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`;
|
||||
}
|
||||
|
||||
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';
|
||||
function handleSystemChange(event: MediaQueryListEvent): void {
|
||||
systemDark.value = event.matches;
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
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);
|
||||
function handleStorageChange(event: StorageEvent): void {
|
||||
if (event.key === 'appearance' || event.key === null) {
|
||||
updateAppearance(normalizeAppearance(event.newValue));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
appearance,
|
||||
appearance: readonly(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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
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 };
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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>>;
|
||||
@@ -22,40 +23,21 @@ export type UseCurrentUrlReturn = {
|
||||
) => T | F;
|
||||
};
|
||||
|
||||
export function useCurrentUrl(): UseCurrentUrlReturn {
|
||||
const page = usePage();
|
||||
const currentUrlReactive = computed(
|
||||
() =>
|
||||
new URL(
|
||||
page.url,
|
||||
typeof window !== 'undefined'
|
||||
? window.location.origin
|
||||
: 'http://localhost',
|
||||
).pathname,
|
||||
() => new URL(page.url, 'http://localhost').pathname,
|
||||
);
|
||||
|
||||
export function useCurrentUrl(): UseCurrentUrlReturn {
|
||||
function isCurrentUrl(
|
||||
urlToCheck: NonNullable<InertiaLinkProps['href']>,
|
||||
currentUrl?: string,
|
||||
startsWith: boolean = false,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
return matchesNavigationUrl(
|
||||
toUrl(urlToCheck),
|
||||
currentUrl ?? currentUrlReactive.value,
|
||||
startsWith,
|
||||
);
|
||||
}
|
||||
|
||||
function isCurrentOrParentUrl(
|
||||
@@ -65,11 +47,11 @@ export function useCurrentUrl(): UseCurrentUrlReturn {
|
||||
return isCurrentUrl(urlToCheck, currentUrl, true);
|
||||
}
|
||||
|
||||
function whenCurrentUrl(
|
||||
function whenCurrentUrl<T, F = null>(
|
||||
urlToCheck: NonNullable<InertiaLinkProps['href']>,
|
||||
ifTrue: any,
|
||||
ifFalse: any = null,
|
||||
) {
|
||||
ifTrue: T,
|
||||
ifFalse: F = null as F,
|
||||
): T | F {
|
||||
return isCurrentUrl(urlToCheck) ? ifTrue : ifFalse;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { injectDialogRootContext } from 'reka-ui';
|
||||
import { watch } from 'vue';
|
||||
|
||||
/** Reka restores registered triggers; also support dialogs opened programmatically. */
|
||||
export function useDialogFocus() {
|
||||
const context = injectDialogRootContext();
|
||||
let previousFocus: HTMLElement | null = null;
|
||||
|
||||
watch(
|
||||
context.open,
|
||||
(open) => {
|
||||
if (open && typeof document !== 'undefined') {
|
||||
previousFocus =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
}
|
||||
},
|
||||
{ flush: 'sync', immediate: true },
|
||||
);
|
||||
|
||||
function restoreFocus(event: Event): void {
|
||||
if (
|
||||
!event.defaultPrevented &&
|
||||
!context.triggerElement.value &&
|
||||
previousFocus?.isConnected
|
||||
) {
|
||||
event.preventDefault();
|
||||
previousFocus.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
return { restoreFocus };
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type {
|
||||
PendingVisit,
|
||||
UrlMethodPair,
|
||||
UseFormSubmitOptions,
|
||||
} from '@inertiajs/core';
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import type { InertiaForm } from '@inertiajs/vue3';
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useUnsavedChanges } from '@/composables/useUnsavedChanges';
|
||||
import {
|
||||
formRequestHeader,
|
||||
isFormNetworkFailure,
|
||||
matchesFormRequest,
|
||||
} from '@/lib/form-feedback';
|
||||
import type { FormFeedbackState } from '@/lib/form-feedback';
|
||||
|
||||
/** Pair with a keyed useForm/useRemember for restoration on browser Back/Forward. */
|
||||
export function useFormProtection<T extends object>(
|
||||
form: InertiaForm<T>,
|
||||
options: { isDirty?: () => boolean } = {},
|
||||
) {
|
||||
const failure = ref<FormFeedbackState | null>(null);
|
||||
const responseStatus = ref<number | null>(null);
|
||||
let ownVisit: PendingVisit | null = null;
|
||||
let requestId: string | null = null;
|
||||
const removeListeners: Array<() => void> = [];
|
||||
|
||||
const { confirmLeave } = useUnsavedChanges(
|
||||
() => form.processing || (options.isDirty?.() ?? form.isDirty),
|
||||
(visit) => visit === ownVisit,
|
||||
);
|
||||
|
||||
const state = computed<FormFeedbackState>(() => {
|
||||
if (form.processing) {
|
||||
return 'loading';
|
||||
}
|
||||
if (failure.value) {
|
||||
return failure.value;
|
||||
}
|
||||
if (Object.keys(form.errors).length > 0) {
|
||||
return 'validation';
|
||||
}
|
||||
return form.recentlySuccessful ? 'success' : 'idle';
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
removeListeners.push(
|
||||
router.on('exception', (event) => {
|
||||
if (isFormNetworkFailure(event.detail.exception, requestId)) {
|
||||
event.preventDefault();
|
||||
failure.value = 'network';
|
||||
}
|
||||
}),
|
||||
router.on('invalid', (event) => {
|
||||
if (
|
||||
matchesFormRequest(event.detail.response.config, requestId)
|
||||
) {
|
||||
event.preventDefault();
|
||||
failure.value = 'server';
|
||||
responseStatus.value = event.detail.response.status;
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
onUnmounted(() => removeListeners.forEach((remove) => remove()));
|
||||
|
||||
function submit(
|
||||
target: UrlMethodPair,
|
||||
visitOptions: UseFormSubmitOptions = {},
|
||||
): void {
|
||||
if (form.processing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRequestId = crypto.randomUUID();
|
||||
form.submit(target, {
|
||||
...visitOptions,
|
||||
preserveState: true,
|
||||
headers: {
|
||||
...visitOptions.headers,
|
||||
[formRequestHeader]: nextRequestId,
|
||||
},
|
||||
onBefore: (visit) => {
|
||||
if (visitOptions.onBefore?.(visit) === false) {
|
||||
return false;
|
||||
}
|
||||
ownVisit = visit;
|
||||
},
|
||||
onStart: (visit) => {
|
||||
requestId = nextRequestId;
|
||||
failure.value = null;
|
||||
responseStatus.value = null;
|
||||
visitOptions.onStart?.(visit);
|
||||
},
|
||||
onError: (errors) => {
|
||||
failure.value = 'validation';
|
||||
return visitOptions.onError?.(errors);
|
||||
},
|
||||
onSuccess: (page) => {
|
||||
failure.value = null;
|
||||
return visitOptions.onSuccess?.(page);
|
||||
},
|
||||
onCancel: () => {
|
||||
failure.value = 'cancelled';
|
||||
visitOptions.onCancel?.();
|
||||
},
|
||||
onFinish: (visit) => {
|
||||
requestId = null;
|
||||
ownVisit = null;
|
||||
visitOptions.onFinish?.(visit);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function clearFeedback(): void {
|
||||
failure.value = null;
|
||||
responseStatus.value = null;
|
||||
}
|
||||
|
||||
return { state, responseStatus, submit, confirmLeave, clearFeedback };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { inject } from 'vue';
|
||||
import { localizationKey } from '@/lib/localization';
|
||||
|
||||
export function useLocalization() {
|
||||
const localization = inject(localizationKey);
|
||||
if (!localization) {
|
||||
throw new Error(
|
||||
'The localization plugin must be installed before useLocalization().',
|
||||
);
|
||||
}
|
||||
return localization;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { PendingVisit } from '@inertiajs/core';
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { onMounted, onUnmounted, toValue } from 'vue';
|
||||
import type { MaybeRefOrGetter } from 'vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
|
||||
export function useUnsavedChanges(
|
||||
dirty: MaybeRefOrGetter<boolean>,
|
||||
allowVisit: (visit: PendingVisit) => boolean = () => false,
|
||||
) {
|
||||
const { t } = useLocalization();
|
||||
let removeBeforeListener: (() => void) | undefined;
|
||||
|
||||
const confirmLeave = (): boolean =>
|
||||
!toValue(dirty) || window.confirm(t('forms.leaveConfirm'));
|
||||
|
||||
const beforeUnload = (event: BeforeUnloadEvent): void => {
|
||||
if (toValue(dirty)) {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
removeBeforeListener = router.on('before', (event) => {
|
||||
if (event.defaultPrevented || allowVisit(event.detail.visit)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the original visit and its callbacks; never replay a mutation.
|
||||
return confirmLeave();
|
||||
});
|
||||
window.addEventListener('beforeunload', beforeUnload);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
removeBeforeListener?.();
|
||||
window.removeEventListener('beforeunload', beforeUnload);
|
||||
});
|
||||
|
||||
return { confirmLeave };
|
||||
}
|
||||
@@ -3,6 +3,8 @@ 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 = {
|
||||
@@ -12,14 +14,27 @@ 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="overflow-x-hidden">
|
||||
<AppContent variant="sidebar" class="min-w-0">
|
||||
<AppSidebarHeader :breadcrumbs="breadcrumbs" />
|
||||
<ProjectNavigation />
|
||||
<div
|
||||
id="main-content"
|
||||
tabindex="-1"
|
||||
class="flex min-w-0 flex-1 flex-col"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</AppContent>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { ShieldCheck } from 'lucide-vue-next';
|
||||
import type { Component } from 'vue';
|
||||
import AppLogo from '@/components/AppLogo.vue';
|
||||
import AppearanceTabs from '@/components/AppearanceTabs.vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { home } from '@/routes/index';
|
||||
|
||||
defineProps<{ title: string; description: string; icon?: Component }>();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-background text-foreground flex min-h-dvh flex-col">
|
||||
<header
|
||||
class="bg-card px-page flex justify-center border-b py-4 sm:justify-start"
|
||||
>
|
||||
<Link
|
||||
:href="home()"
|
||||
:aria-label="t('auth.home')"
|
||||
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
|
||||
><AppLogo
|
||||
/></Link>
|
||||
</header>
|
||||
<main
|
||||
class="gap-column sm:px-page mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-8 sm:py-12"
|
||||
>
|
||||
<section
|
||||
aria-labelledby="auth-title"
|
||||
class="bg-card p-page text-card-foreground shadow-xs grid gap-6 rounded-md border"
|
||||
>
|
||||
<div class="gap-column grid">
|
||||
<span
|
||||
class="bg-primary/10 text-primary flex size-11 items-center justify-center rounded-md"
|
||||
>
|
||||
<component
|
||||
:is="icon ?? ShieldCheck"
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<div class="gap-stack grid">
|
||||
<p
|
||||
class="text-label text-muted-foreground uppercase tracking-wide"
|
||||
>
|
||||
{{ t('auth.accountAccess') }}
|
||||
</p>
|
||||
<h1
|
||||
id="auth-title"
|
||||
class="text-headline-mobile sm:text-headline"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p class="text-body text-muted-foreground">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<slot />
|
||||
</section>
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
class="px-card text-body text-muted-foreground text-center"
|
||||
>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</main>
|
||||
<footer class="pb-page flex justify-center px-4">
|
||||
<AppearanceTabs />
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,9 +1,11 @@
|
||||
<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';
|
||||
@@ -12,28 +14,29 @@ import { edit as editProfile } from '@/routes/profile/index';
|
||||
import { edit as editSecurity } from '@/routes/security/index';
|
||||
import type { NavItem } from '@/types';
|
||||
|
||||
const sidebarNavItems: NavItem[] = [
|
||||
const { t } = useLocalization();
|
||||
const sidebarNavItems = computed<NavItem[]>(() => [
|
||||
{
|
||||
title: 'Profile',
|
||||
title: t('navigation.profile'),
|
||||
href: editProfile(),
|
||||
},
|
||||
{
|
||||
title: 'Security',
|
||||
title: t('navigation.security'),
|
||||
href: editSecurity(),
|
||||
},
|
||||
{
|
||||
title: 'Git connections',
|
||||
title: t('navigation.gitConnections'),
|
||||
href: editGitConnections(),
|
||||
},
|
||||
{
|
||||
title: 'Applications',
|
||||
title: t('navigation.applications'),
|
||||
href: editApplications(),
|
||||
},
|
||||
{
|
||||
title: 'Appearance',
|
||||
title: t('navigation.appearance'),
|
||||
href: editAppearance(),
|
||||
},
|
||||
];
|
||||
]);
|
||||
|
||||
const { isCurrentOrParentUrl } = useCurrentUrl();
|
||||
</script>
|
||||
@@ -41,27 +44,34 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
|
||||
<template>
|
||||
<div class="px-4 py-6">
|
||||
<Heading
|
||||
title="Settings"
|
||||
description="Manage your profile, security, and Git integration settings"
|
||||
:title="t('navigation.settings')"
|
||||
:description="t('navigation.settingsDescription')"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col lg:flex-row lg:space-x-12">
|
||||
<aside class="w-full max-w-xl lg:w-48">
|
||||
<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">
|
||||
<nav
|
||||
class="flex flex-col space-x-0 space-y-1"
|
||||
aria-label="Settings"
|
||||
class="flex gap-1 overflow-x-auto lg:flex-col"
|
||||
:aria-label="t('navigation.settings')"
|
||||
>
|
||||
<Button
|
||||
v-for="item in sidebarNavItems"
|
||||
:key="toUrl(item.href)"
|
||||
variant="ghost"
|
||||
:class="[
|
||||
'w-full justify-start',
|
||||
'shrink-0 justify-start lg:w-full',
|
||||
{ 'bg-muted': isCurrentOrParentUrl(item.href) },
|
||||
]"
|
||||
as-child
|
||||
>
|
||||
<Link :href="item.href">
|
||||
<Link
|
||||
:href="item.href"
|
||||
:aria-current="
|
||||
isCurrentOrParentUrl(item.href)
|
||||
? 'page'
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<component :is="item.icon" class="h-4 w-4" />
|
||||
{{ item.title }}
|
||||
</Link>
|
||||
@@ -69,9 +79,9 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<Separator class="my-6 lg:hidden" />
|
||||
<Separator class="lg:hidden" />
|
||||
|
||||
<div class="flex-1 md:max-w-2xl">
|
||||
<div class="min-w-0 flex-1 md:max-w-2xl">
|
||||
<section class="max-w-xl space-y-12">
|
||||
<slot />
|
||||
</section>
|
||||
|
||||
@@ -121,9 +121,7 @@ const parseChecklistLine = (
|
||||
return null;
|
||||
}
|
||||
|
||||
const indentLevel = clampIndent(
|
||||
Math.floor(match.groups.indent.length / 2),
|
||||
);
|
||||
const indentLevel = clampIndent(Math.floor(match.groups.indent.length / 2));
|
||||
let content = match.groups.content.trim();
|
||||
let checked = false;
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
export type FormFeedbackState =
|
||||
| 'idle'
|
||||
| 'loading'
|
||||
| 'success'
|
||||
| 'validation'
|
||||
| 'network'
|
||||
| 'server'
|
||||
| 'cancelled';
|
||||
|
||||
export const formRequestHeader = 'X-KanbanGit-Form-Request';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
/** Match failures to one submission, without intercepting other forms or polling. */
|
||||
export function matchesFormRequest(
|
||||
config: unknown,
|
||||
requestId: string | null,
|
||||
): boolean {
|
||||
if (!requestId || !isRecord(config) || !isRecord(config.headers)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return config.headers[formRequestHeader] === requestId;
|
||||
}
|
||||
|
||||
/** Runtime exceptions and cancellations are not network failures. */
|
||||
export function isFormNetworkFailure(
|
||||
error: unknown,
|
||||
requestId: string | null,
|
||||
): boolean {
|
||||
return (
|
||||
isRecord(error) &&
|
||||
error.isAxiosError === true &&
|
||||
!error.response &&
|
||||
['ERR_NETWORK', 'ECONNABORTED', 'ETIMEDOUT'].includes(
|
||||
String(error.code),
|
||||
) &&
|
||||
matchesFormRequest(error.config, requestId)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { readonly, ref } from 'vue';
|
||||
import type { App, InjectionKey } from 'vue';
|
||||
import { en } from '@/locales/en';
|
||||
import type {
|
||||
LocalizationSettings,
|
||||
TranslationCatalog,
|
||||
TranslationKey,
|
||||
TranslationParameters,
|
||||
} from '@/types/localization';
|
||||
|
||||
const defaultLocale = 'en';
|
||||
const catalogs: Record<string, TranslationCatalog> = { en };
|
||||
|
||||
function placeholders(message: string): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
[...message.matchAll(/\{(\w+)\}/g)].map((match) => match[1]),
|
||||
),
|
||||
].sort();
|
||||
}
|
||||
|
||||
/** Validate every catalog, including messages that no component has rendered yet. */
|
||||
export function validateCatalog(catalog: TranslationCatalog): void {
|
||||
for (const key of Object.keys(en) as TranslationKey[]) {
|
||||
const message = Object.hasOwn(catalog, key) ? catalog[key] : undefined;
|
||||
const source = en[key];
|
||||
|
||||
if (message === undefined || typeof message !== typeof source) {
|
||||
throw new Error(`Missing or invalid translation: ${key}`);
|
||||
}
|
||||
|
||||
const expected = placeholders(
|
||||
typeof source === 'string' ? source : source.other,
|
||||
);
|
||||
const forms =
|
||||
typeof message === 'string'
|
||||
? [message]
|
||||
: Object.values(message ?? {});
|
||||
|
||||
if (
|
||||
typeof message !== 'string' &&
|
||||
(!message || typeof message.other !== 'string')
|
||||
) {
|
||||
throw new Error(`Missing plural fallback: ${key}`);
|
||||
}
|
||||
|
||||
for (const form of forms) {
|
||||
if (
|
||||
typeof form !== 'string' ||
|
||||
form.trim() === '' ||
|
||||
JSON.stringify(placeholders(form)) !== JSON.stringify(expected)
|
||||
) {
|
||||
throw new Error(`Invalid translation placeholders: ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createLocalization(
|
||||
settings: Partial<LocalizationSettings> = {},
|
||||
availableCatalogs: Record<string, TranslationCatalog> = catalogs,
|
||||
) {
|
||||
const messages: Record<string, TranslationCatalog> = {
|
||||
...availableCatalogs,
|
||||
en,
|
||||
};
|
||||
for (const catalog of Object.values(messages)) {
|
||||
validateCatalog(catalog);
|
||||
}
|
||||
|
||||
function resolveLocale(requested: string): string {
|
||||
try {
|
||||
const [canonical] = Intl.getCanonicalLocales(
|
||||
requested.replaceAll('_', '-'),
|
||||
);
|
||||
if (Object.hasOwn(messages, canonical)) {
|
||||
return canonical;
|
||||
}
|
||||
const language = canonical.split('-')[0];
|
||||
return Object.hasOwn(messages, language) ? language : defaultLocale;
|
||||
} catch {
|
||||
return defaultLocale;
|
||||
}
|
||||
}
|
||||
|
||||
const locale = ref(resolveLocale(settings.locale ?? defaultLocale));
|
||||
const timeZone = settings.timeZone ?? 'UTC';
|
||||
|
||||
function number(
|
||||
value: number,
|
||||
options: Intl.NumberFormatOptions = {},
|
||||
): string {
|
||||
return new Intl.NumberFormat(locale.value, options).format(value);
|
||||
}
|
||||
|
||||
function date(
|
||||
value: Date | number,
|
||||
options: Intl.DateTimeFormatOptions = {},
|
||||
): string {
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
timeZone,
|
||||
...options,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
/** Interpolate plain text only; render with Vue text bindings, never v-html. */
|
||||
function t(
|
||||
key: TranslationKey,
|
||||
parameters: TranslationParameters = {},
|
||||
): string {
|
||||
const catalog = messages[locale.value];
|
||||
if (!Object.hasOwn(catalog, key)) {
|
||||
throw new Error(`Missing translation: ${key}`);
|
||||
}
|
||||
|
||||
const message = catalog[key];
|
||||
let template: string;
|
||||
if (typeof message === 'string') {
|
||||
template = message;
|
||||
} else {
|
||||
const count = parameters.count;
|
||||
if (typeof count !== 'number' || !Number.isFinite(count)) {
|
||||
throw new Error(`Translation requires a finite count: ${key}`);
|
||||
}
|
||||
template =
|
||||
message[new Intl.PluralRules(locale.value).select(count)] ??
|
||||
message.other;
|
||||
}
|
||||
|
||||
return template.replace(/\{(\w+)\}/g, (_, name: string) => {
|
||||
if (!Object.hasOwn(parameters, name)) {
|
||||
throw new Error(
|
||||
`Missing translation parameter: ${key}.${name}`,
|
||||
);
|
||||
}
|
||||
const value = parameters[name];
|
||||
return typeof value === 'number' ? number(value) : value;
|
||||
});
|
||||
}
|
||||
|
||||
const localization = {
|
||||
locale: readonly(locale),
|
||||
t,
|
||||
number,
|
||||
date,
|
||||
setLocale(requested: string): void {
|
||||
locale.value = resolveLocale(requested);
|
||||
},
|
||||
install(app: App): void {
|
||||
app.provide(localizationKey, localization);
|
||||
},
|
||||
};
|
||||
|
||||
return localization;
|
||||
}
|
||||
|
||||
export type Localization = ReturnType<typeof createLocalization>;
|
||||
export const localizationKey: InjectionKey<Localization> =
|
||||
Symbol('localization');
|
||||
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
Bell,
|
||||
Columns3,
|
||||
Folder,
|
||||
FolderGit2,
|
||||
House,
|
||||
KeyRound,
|
||||
Layers3,
|
||||
LayoutGrid,
|
||||
ListTodo,
|
||||
Settings2,
|
||||
Shield,
|
||||
Tags,
|
||||
Users,
|
||||
} from 'lucide-vue-next';
|
||||
import { dashboard } from '@/routes';
|
||||
import { index as adminUsers } from '@/routes/admin/users';
|
||||
import { index as templates } from '@/routes/project-templates';
|
||||
import { access, index as projects, show } from '@/routes/projects';
|
||||
import { index as backlog } from '@/routes/projects/backlog';
|
||||
import { index as board } from '@/routes/projects/board';
|
||||
import {
|
||||
board as boardSettings,
|
||||
general,
|
||||
metadata,
|
||||
notifications,
|
||||
repositories,
|
||||
} from '@/routes/projects/settings';
|
||||
import { index as teams } from '@/routes/teams';
|
||||
import type { Localization } from '@/lib/localization';
|
||||
import type { AuthPermissions, NavItem, NavigationProject } from '@/types';
|
||||
|
||||
export function workspaceNavigation(
|
||||
permissions: AuthPermissions,
|
||||
t: Localization['t'],
|
||||
): NavItem[] {
|
||||
const items: NavItem[] = [
|
||||
{
|
||||
title: t('navigation.dashboard'),
|
||||
href: dashboard(),
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
{
|
||||
title: t('navigation.projects'),
|
||||
href: projects(),
|
||||
icon: Folder,
|
||||
match: 'section',
|
||||
},
|
||||
];
|
||||
if (permissions.projectCreate) {
|
||||
items.push({
|
||||
title: t('navigation.templates'),
|
||||
href: templates(),
|
||||
icon: Layers3,
|
||||
match: 'section',
|
||||
});
|
||||
}
|
||||
if (permissions.canManageTeams) {
|
||||
items.push({
|
||||
title: t('navigation.teams'),
|
||||
href: teams(),
|
||||
icon: Users,
|
||||
match: 'section',
|
||||
});
|
||||
}
|
||||
if (permissions.globalAdmin) {
|
||||
items.push({
|
||||
title: t('navigation.users'),
|
||||
href: adminUsers(),
|
||||
icon: Shield,
|
||||
match: 'section',
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function projectNavigation(
|
||||
project: NavigationProject | null,
|
||||
t: Localization['t'],
|
||||
): NavItem[] {
|
||||
if (!project) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
title: t('navigation.overview'),
|
||||
href: show(project.id),
|
||||
icon: House,
|
||||
},
|
||||
{
|
||||
title: t('navigation.board'),
|
||||
href: board(project.id),
|
||||
icon: Columns3,
|
||||
match: 'section',
|
||||
},
|
||||
{
|
||||
title: t('navigation.backlog'),
|
||||
href: backlog(project.id),
|
||||
icon: ListTodo,
|
||||
match: 'section',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function projectSettingsNavigation(
|
||||
project: NavigationProject | null,
|
||||
t: Localization['t'],
|
||||
): NavItem[] {
|
||||
if (!project?.canManage) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
title: t('navigation.general'),
|
||||
href: general(project.id),
|
||||
icon: Settings2,
|
||||
},
|
||||
{
|
||||
title: t('navigation.boardSettings'),
|
||||
href: boardSettings(project.id),
|
||||
icon: Columns3,
|
||||
},
|
||||
{
|
||||
title: t('navigation.ticketSettings'),
|
||||
href: metadata(project.id),
|
||||
icon: Tags,
|
||||
},
|
||||
{
|
||||
title: t('navigation.repositories'),
|
||||
href: repositories(project.id),
|
||||
icon: FolderGit2,
|
||||
},
|
||||
{
|
||||
title: t('navigation.notifications'),
|
||||
href: notifications(project.id),
|
||||
icon: Bell,
|
||||
},
|
||||
{
|
||||
title: t('navigation.access'),
|
||||
href: access(project.id),
|
||||
icon: KeyRound,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function matchesNavigationUrl(
|
||||
target: string,
|
||||
current: string,
|
||||
section = false,
|
||||
): boolean {
|
||||
try {
|
||||
const base = 'http://localhost';
|
||||
const targetPath =
|
||||
new URL(target, base).pathname.replace(/\/+$/, '') || '/';
|
||||
const currentPath =
|
||||
new URL(current, base).pathname.replace(/\/+$/, '') || '/';
|
||||
return (
|
||||
currentPath === targetPath ||
|
||||
(section &&
|
||||
targetPath !== '/' &&
|
||||
currentPath.startsWith(`${targetPath}/`))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||
|
||||
export function ticketStatusLabel(
|
||||
ticketStatus: TicketStatusModel | null,
|
||||
fallback: string,
|
||||
): string {
|
||||
if (ticketStatus === null) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (
|
||||
ticketStatus.board_column === null ||
|
||||
ticketStatus.board_column === undefined ||
|
||||
ticketStatus.board_column.name === ticketStatus.name
|
||||
) {
|
||||
return ticketStatus.name;
|
||||
}
|
||||
|
||||
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
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;
|
||||
@@ -1,47 +1,211 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
|
||||
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 AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { dashboard } from '@/routes/index';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
import { index as projectsIndex, show as showProject } from '@/routes/projects';
|
||||
import type { BreadcrumbItem, DashboardPageProps } from '@/types';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
const props = defineProps<DashboardPageProps>();
|
||||
const { t } = useLocalization();
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: t('navigation.dashboard'), href: dashboard() },
|
||||
]);
|
||||
const assignedTickets = computed(() =>
|
||||
props.assignedTicketsByProject.flatMap((group) => group.tickets),
|
||||
);
|
||||
const summaryItems = computed(() => [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
href: dashboard(),
|
||||
label: t('dashboard.summary.assignedTickets'),
|
||||
value: assignedTickets.value.length,
|
||||
icon: ListTodo,
|
||||
},
|
||||
];
|
||||
{
|
||||
label: t('dashboard.summary.projects'),
|
||||
value: props.assignedTicketsByProject.length,
|
||||
icon: FolderGit2,
|
||||
},
|
||||
{
|
||||
label: t('dashboard.summary.blockedTickets'),
|
||||
value: assignedTickets.value.filter((ticket) => ticket.is_blocked)
|
||||
.length,
|
||||
icon: OctagonAlert,
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Dashboard" />
|
||||
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<div
|
||||
class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4"
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
<Card class="h-full gap-0 rounded-md py-0">
|
||||
<CardContent
|
||||
class="flex min-w-0 items-center justify-between gap-4 p-4"
|
||||
>
|
||||
<PlaceholderPattern />
|
||||
<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>
|
||||
<div
|
||||
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
|
||||
<span
|
||||
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-md"
|
||||
>
|
||||
<PlaceholderPattern />
|
||||
</div>
|
||||
<div
|
||||
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
|
||||
<component
|
||||
:is="item.icon"
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="assigned-work-title"
|
||||
class="gap-column grid min-w-0"
|
||||
>
|
||||
<PlaceholderPattern />
|
||||
<header class="gap-stack grid min-w-0">
|
||||
<h2 id="assigned-work-title" class="text-title">
|
||||
{{ t('dashboard.assignedWork') }}
|
||||
</h2>
|
||||
<p class="text-muted-foreground max-w-2xl">
|
||||
{{ t('dashboard.assignedWorkDescription') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<FeedbackState
|
||||
v-if="assignedTicketsByProject.length === 0"
|
||||
state="empty"
|
||||
:title="t('dashboard.empty.title')"
|
||||
:description="t('dashboard.empty.description')"
|
||||
>
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="projectsIndex()">
|
||||
<FolderGit2 aria-hidden="true" />
|
||||
{{ t('dashboard.empty.action') }}
|
||||
</Link>
|
||||
</Button>
|
||||
</FeedbackState>
|
||||
|
||||
<div v-else class="gap-page grid min-w-0">
|
||||
<section
|
||||
v-for="group in assignedTicketsByProject"
|
||||
:key="group.project.id"
|
||||
:aria-labelledby="`dashboard-project-${group.project.id}`"
|
||||
class="gap-column grid min-w-0"
|
||||
>
|
||||
<header
|
||||
class="flex min-w-0 flex-col gap-3 border-b pb-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="max-w-full shrink-0 whitespace-normal break-all"
|
||||
>
|
||||
{{ group.project.key }}
|
||||
</Badge>
|
||||
<div class="min-w-0">
|
||||
<h3
|
||||
:id="`dashboard-project-${group.project.id}`"
|
||||
class="text-body break-words font-semibold"
|
||||
>
|
||||
{{ group.project.name }}
|
||||
</h3>
|
||||
<p
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
t('tickets.count', {
|
||||
count: group.tickets.length,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="border-sidebar-border/70 dark:border-sidebar-border relative min-h-[100vh] flex-1 rounded-xl border md:min-h-min"
|
||||
<Button
|
||||
as-child
|
||||
variant="link"
|
||||
class="self-start sm:self-auto"
|
||||
>
|
||||
<PlaceholderPattern />
|
||||
<Link
|
||||
:href="showProject(group.project.id)"
|
||||
:aria-label="
|
||||
t('dashboard.openProject', {
|
||||
name: group.project.name,
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ t('dashboard.openProjectAction') }}
|
||||
<ArrowUpRight aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<ul
|
||||
:aria-label="
|
||||
t('dashboard.projectTickets', {
|
||||
name: group.project.name,
|
||||
})
|
||||
"
|
||||
class="gap-column grid min-w-0 md:grid-cols-2 2xl:grid-cols-3"
|
||||
>
|
||||
<li
|
||||
v-for="ticket in group.tickets"
|
||||
:key="ticket.id"
|
||||
class="min-w-0"
|
||||
>
|
||||
<TicketCard
|
||||
:project-id="group.project.id"
|
||||
:ticket="ticket"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -52,12 +52,16 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
<CardContent>
|
||||
<Form
|
||||
v-bind="UserManagementController.store.form()"
|
||||
:transform="(data) => ({
|
||||
:transform="
|
||||
(data) => ({
|
||||
...data,
|
||||
must_change_password_on_next_login:
|
||||
data.must_change_password_on_next_login === true ||
|
||||
data.must_change_password_on_next_login === 'on',
|
||||
})"
|
||||
data.must_change_password_on_next_login ===
|
||||
true ||
|
||||
data.must_change_password_on_next_login ===
|
||||
'on',
|
||||
})
|
||||
"
|
||||
class="space-y-6"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
@@ -128,13 +132,19 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
<span class="block font-medium">
|
||||
Must change password on next login
|
||||
</span>
|
||||
<span class="text-muted-foreground block text-sm">
|
||||
Redirect the user to the security settings page until they save a new password.
|
||||
<span
|
||||
class="text-muted-foreground block text-sm"
|
||||
>
|
||||
Redirect the user to the security
|
||||
settings page until they save a new
|
||||
password.
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
<InputError
|
||||
:message="errors.must_change_password_on_next_login"
|
||||
:message="
|
||||
errors.must_change_password_on_next_login
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,52 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import { ShieldCheck } from 'lucide-vue-next';
|
||||
import { onUnmounted } from 'vue';
|
||||
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import PasswordInput from '@/components/PasswordInput.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.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>
|
||||
<AuthLayout
|
||||
title="Confirm your password"
|
||||
description="This is a secure area of the application. Please confirm your password before continuing."
|
||||
<AuthFlowLayout
|
||||
:title="t('auth.confirm.title')"
|
||||
:description="t('auth.confirm.description')"
|
||||
:icon="ShieldCheck"
|
||||
>
|
||||
<Head title="Confirm password" />
|
||||
|
||||
<Form
|
||||
v-bind="store.form()"
|
||||
reset-on-success
|
||||
v-slot="{ errors, processing }"
|
||||
<Head :title="t('auth.confirm.title')" />
|
||||
<form
|
||||
:action="store.url()"
|
||||
method="post"
|
||||
class="grid gap-5"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<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"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
autofocus
|
||||
:disabled="form.processing"
|
||||
:invalid="Boolean(form.errors.password)"
|
||||
aria-describedby="password-hint password-error"
|
||||
/>
|
||||
|
||||
<InputError :message="errors.password" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<Button
|
||||
class="w-full"
|
||||
:disabled="processing"
|
||||
data-test="confirm-password-button"
|
||||
<p
|
||||
id="password-hint"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
<Spinner v-if="processing" />
|
||||
Confirm password
|
||||
</Button>
|
||||
{{ t('auth.confirm.passwordHint') }}
|
||||
</p>
|
||||
<InputError
|
||||
id="password-error"
|
||||
:message="form.errors.password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</AuthLayout>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -1,115 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||
import { LogOut } from 'lucide-vue-next';
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import { KeyRound } from 'lucide-vue-next';
|
||||
import { computed, onUnmounted } from 'vue';
|
||||
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 { Spinner } from '@/components/ui/spinner';
|
||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||
import { logout } from '@/routes';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
||||
import { logout } from '@/routes/index';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
status?: string | null;
|
||||
}>(),
|
||||
{
|
||||
status: null,
|
||||
},
|
||||
);
|
||||
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());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthLayout
|
||||
title="Change your password"
|
||||
description="You must update your password before you can continue using the application."
|
||||
<AuthFlowLayout
|
||||
:title="t('auth.forceChange.title')"
|
||||
:description="t('auth.forceChange.description')"
|
||||
:icon="KeyRound"
|
||||
>
|
||||
<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>
|
||||
|
||||
<div
|
||||
<Head :title="t('auth.forceChange.title')" />
|
||||
<FeedbackState
|
||||
v-if="status"
|
||||
class="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900"
|
||||
state="success"
|
||||
:title="t('auth.forceChange.statusTitle')"
|
||||
:description="status"
|
||||
/>
|
||||
<form
|
||||
:action="SecurityController.update.url()"
|
||||
method="post"
|
||||
class="grid gap-5"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
{{ status }}
|
||||
</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>
|
||||
<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"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
autofocus
|
||||
placeholder="Current password"
|
||||
:disabled="busy"
|
||||
:invalid="Boolean(form.errors.current_password)"
|
||||
aria-describedby="current-password-error"
|
||||
/>
|
||||
<InputError
|
||||
id="current-password-error"
|
||||
:message="form.errors.current_password"
|
||||
/>
|
||||
|
||||
<InputError :message="errors.current_password" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label htmlFor="password">New password</Label>
|
||||
<div class="gap-stack grid">
|
||||
<Label for="password">{{
|
||||
t('auth.forceChange.password')
|
||||
}}</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
name="password"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
placeholder="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"
|
||||
/>
|
||||
|
||||
<InputError :message="errors.password" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label htmlFor="password_confirmation">
|
||||
Confirm new password
|
||||
</Label>
|
||||
<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"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
placeholder="Confirm 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"
|
||||
/>
|
||||
|
||||
<InputError :message="errors.password_confirmation" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<Button
|
||||
type="submit"
|
||||
class="w-full"
|
||||
:disabled="processing"
|
||||
:disabled="logoutForm.processing"
|
||||
:loading="form.processing"
|
||||
:loading-label="t('auth.forceChange.pending')"
|
||||
data-test="force-password-change-button"
|
||||
>{{ t('auth.forceChange.submit') }}</Button
|
||||
>
|
||||
<Spinner v-if="processing" />
|
||||
Save new password
|
||||
</Button>
|
||||
</form>
|
||||
<div class="gap-stack pt-column grid border-t">
|
||||
<p class="text-body text-muted-foreground">
|
||||
{{ t('auth.forceChange.wrongAccount') }}
|
||||
</p>
|
||||
<form
|
||||
:action="logout.url()"
|
||||
method="post"
|
||||
class="gap-card grid"
|
||||
@submit.prevent="signOut"
|
||||
>
|
||||
<AuthFormFeedback
|
||||
:state="logoutProtection.state.value"
|
||||
:response-status="logoutProtection.responseStatus.value"
|
||||
:loading-title="t('auth.signingOut')"
|
||||
@retry="signOut"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
:disabled="form.processing"
|
||||
:loading="logoutForm.processing"
|
||||
:loading-label="t('auth.signingOut')"
|
||||
>{{ t('auth.signOut') }}</Button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</AuthLayout>
|
||||
<template #footer>{{ t('auth.forceChange.footer') }}</template>
|
||||
</AuthFlowLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,65 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import { Mail } from 'lucide-vue-next';
|
||||
import { onUnmounted } from 'vue';
|
||||
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import TextLink from '@/components/TextLink.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
||||
import { login } from '@/routes/index';
|
||||
import { email } from '@/routes/password';
|
||||
|
||||
defineProps<{
|
||||
status?: string;
|
||||
}>();
|
||||
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());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthLayout
|
||||
title="Forgot password"
|
||||
description="Enter your email to receive a password reset link"
|
||||
<AuthFlowLayout
|
||||
:title="t('auth.forgot.title')"
|
||||
:description="t('auth.forgot.description')"
|
||||
:icon="Mail"
|
||||
>
|
||||
<Head title="Forgot password" />
|
||||
|
||||
<div
|
||||
<Head :title="t('auth.forgot.title')" />
|
||||
<FeedbackState
|
||||
v-if="status"
|
||||
class="mb-4 text-center text-sm font-medium text-green-600"
|
||||
state="success"
|
||||
:title="t('auth.forgot.sentTitle')"
|
||||
:description="status"
|
||||
/>
|
||||
<form
|
||||
:action="email.url()"
|
||||
method="post"
|
||||
class="grid gap-5"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
{{ status }}
|
||||
</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>
|
||||
<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"
|
||||
type="email"
|
||||
v-model="form.email"
|
||||
name="email"
|
||||
autocomplete="off"
|
||||
type="email"
|
||||
inputmode="email"
|
||||
autocomplete="email"
|
||||
autocapitalize="none"
|
||||
:spellcheck="false"
|
||||
required
|
||||
autofocus
|
||||
placeholder="email@example.com"
|
||||
:disabled="form.processing"
|
||||
:placeholder="t('auth.forgot.emailPlaceholder')"
|
||||
:invalid="Boolean(form.errors.email)"
|
||||
aria-describedby="email-hint email-error"
|
||||
/>
|
||||
<InputError :message="errors.email" />
|
||||
<p id="email-hint" class="text-caption text-muted-foreground">
|
||||
{{ t('auth.forgot.emailHint') }}
|
||||
</p>
|
||||
<InputError id="email-error" :message="form.errors.email" />
|
||||
</div>
|
||||
|
||||
<div class="my-6 flex items-center justify-start">
|
||||
<Button
|
||||
type="submit"
|
||||
class="w-full"
|
||||
:disabled="processing"
|
||||
:loading="form.processing"
|
||||
:loading-label="t('auth.forgot.pending')"
|
||||
data-test="email-password-reset-link-button"
|
||||
>{{ t('auth.forgot.submit') }}</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>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
</form>
|
||||
<template #footer
|
||||
><TextLink :href="login()">{{
|
||||
t('auth.backToLogin')
|
||||
}}</TextLink></template
|
||||
>
|
||||
</AuthFlowLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import { LogIn } from 'lucide-vue-next';
|
||||
import { onUnmounted } from 'vue';
|
||||
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import PasswordInput from '@/components/PasswordInput.vue';
|
||||
import TextLink from '@/components/TextLink.vue';
|
||||
@@ -7,94 +11,114 @@ 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 { Spinner } from '@/components/ui/spinner';
|
||||
import AuthBase from '@/layouts/AuthLayout.vue';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
||||
import { store } from '@/routes/login';
|
||||
import { request } from '@/routes/password';
|
||||
|
||||
defineProps<{
|
||||
status?: string;
|
||||
canResetPassword: boolean;
|
||||
}>();
|
||||
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'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthBase
|
||||
title="Log in to your account"
|
||||
description="Enter your email and password below to log in"
|
||||
<AuthFlowLayout
|
||||
:title="t('auth.login.title')"
|
||||
:description="t('auth.login.description')"
|
||||
:icon="LogIn"
|
||||
>
|
||||
<Head title="Log in" />
|
||||
|
||||
<div
|
||||
<Head :title="t('auth.login.submit')" />
|
||||
<FeedbackState
|
||||
v-if="status"
|
||||
class="mb-4 text-center text-sm font-medium text-green-600"
|
||||
state="success"
|
||||
:title="t('auth.login.statusTitle')"
|
||||
:description="status"
|
||||
/>
|
||||
<form
|
||||
:action="store.url()"
|
||||
method="post"
|
||||
class="grid gap-5"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
{{ status }}
|
||||
</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>
|
||||
<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"
|
||||
type="email"
|
||||
v-model="form.email"
|
||||
name="email"
|
||||
type="email"
|
||||
inputmode="email"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
:spellcheck="false"
|
||||
required
|
||||
autofocus
|
||||
:tabindex="1"
|
||||
autocomplete="email"
|
||||
placeholder="email@example.com"
|
||||
:disabled="form.processing"
|
||||
:placeholder="t('auth.login.emailPlaceholder')"
|
||||
:invalid="Boolean(form.errors.email)"
|
||||
aria-describedby="email-error"
|
||||
/>
|
||||
<InputError :message="errors.email" />
|
||||
<InputError id="email-error" :message="form.errors.email" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label for="password">Password</Label>
|
||||
<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"
|
||||
/>
|
||||
{{ t('auth.login.remember') }}
|
||||
</Label>
|
||||
<TextLink
|
||||
v-if="canResetPassword"
|
||||
:href="request()"
|
||||
class="text-sm"
|
||||
:tabindex="5"
|
||||
class="min-h-control text-body inline-flex items-center"
|
||||
>{{ t('auth.login.forgot') }}</TextLink
|
||||
>
|
||||
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"
|
||||
class="w-full"
|
||||
:loading="form.processing"
|
||||
:loading-label="t('auth.login.pending')"
|
||||
data-test="login-button"
|
||||
>{{ t('auth.login.submit') }}</Button
|
||||
>
|
||||
<Spinner v-if="processing" />
|
||||
Log in
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</AuthBase>
|
||||
</form>
|
||||
<template #footer>{{ t('auth.login.help') }}</template>
|
||||
</AuthFlowLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,88 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
import { ref } from 'vue';
|
||||
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 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 { Spinner } from '@/components/ui/spinner';
|
||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||
import { update } from '@/routes/password';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
||||
import { request, update } from '@/routes/password';
|
||||
|
||||
const props = defineProps<{
|
||||
token: string;
|
||||
email: string;
|
||||
}>();
|
||||
|
||||
const inputEmail = ref(props.email);
|
||||
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());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthLayout
|
||||
title="Reset password"
|
||||
description="Please enter your new password below"
|
||||
<AuthFlowLayout
|
||||
:title="t('auth.reset.title')"
|
||||
:description="t('auth.reset.description')"
|
||||
:icon="KeyRound"
|
||||
>
|
||||
<Head title="Reset password" />
|
||||
|
||||
<Form
|
||||
v-bind="update.form()"
|
||||
:transform="(data) => ({ ...data, token, email })"
|
||||
:reset-on-success="['password', 'password_confirmation']"
|
||||
v-slot="{ errors, processing }"
|
||||
<Head :title="t('auth.reset.title')" />
|
||||
<FeedbackState
|
||||
v-if="invalidLink"
|
||||
state="error"
|
||||
:title="t('auth.reset.invalidTitle')"
|
||||
:description="form.errors.email"
|
||||
>
|
||||
<div class="grid gap-6">
|
||||
<div class="grid gap-2">
|
||||
<Label for="email">Email</Label>
|
||||
<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"
|
||||
type="email"
|
||||
v-model="form.email"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
v-model="inputEmail"
|
||||
class="mt-1 block w-full"
|
||||
type="email"
|
||||
autocomplete="username"
|
||||
readonly
|
||||
:disabled="form.processing"
|
||||
:invalid="Boolean(form.errors.email)"
|
||||
aria-describedby="email-error"
|
||||
/>
|
||||
<InputError :message="errors.email" class="mt-2" />
|
||||
<InputError id="email-error" :message="form.errors.email" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="password">Password</Label>
|
||||
<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"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autofocus
|
||||
placeholder="Password"
|
||||
:disabled="form.processing"
|
||||
:invalid="Boolean(form.errors.password)"
|
||||
aria-describedby="password-hint password-error"
|
||||
/>
|
||||
<p
|
||||
id="password-hint"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('auth.reset.passwordHint') }}
|
||||
</p>
|
||||
<InputError
|
||||
id="password-error"
|
||||
:message="form.errors.password"
|
||||
/>
|
||||
<InputError :message="errors.password" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="password_confirmation">
|
||||
Confirm password
|
||||
</Label>
|
||||
<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"
|
||||
class="mt-1 block w-full"
|
||||
placeholder="Confirm 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"
|
||||
/>
|
||||
<InputError :message="errors.password_confirmation" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
class="mt-4 w-full"
|
||||
:disabled="processing"
|
||||
class="w-full"
|
||||
:loading="form.processing"
|
||||
:loading-label="t('auth.reset.pending')"
|
||||
data-test="reset-password-button"
|
||||
>{{ t('auth.reset.submit') }}</Button
|
||||
>
|
||||
<Spinner v-if="processing" />
|
||||
Reset password
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</AuthLayout>
|
||||
</form>
|
||||
<template #footer>{{ t('auth.reset.footer') }}</template>
|
||||
</AuthFlowLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,133 +1,173 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
import { computed, ref } from 'vue';
|
||||
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 InputError from '@/components/InputError.vue';
|
||||
import TextLink from '@/components/TextLink.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from '@/components/ui/input-otp';
|
||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||
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 { store } from '@/routes/two-factor/login';
|
||||
import type { TwoFactorConfigContent } from '@/types';
|
||||
|
||||
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',
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Authentication code',
|
||||
description:
|
||||
'Enter the authentication code provided by your authenticator application.',
|
||||
buttonText: 'login using a recovery code',
|
||||
recovery.value = !recovery.value;
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
await nextTick();
|
||||
(recovery.value ? recoveryInput.value : codeInput.value)?.$el?.focus();
|
||||
};
|
||||
});
|
||||
|
||||
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 code = ref<string>('');
|
||||
const submit = (): void => {
|
||||
form.transform((data) =>
|
||||
recovery.value
|
||||
? { recovery_code: data.recovery_code.trim() }
|
||||
: { code: data.code },
|
||||
);
|
||||
protection.submit(store(), { onSuccess: () => form.reset() });
|
||||
};
|
||||
onUnmounted(() => form.reset());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthLayout
|
||||
:title="authConfigContent.title"
|
||||
:description="authConfigContent.description"
|
||||
<AuthFlowLayout
|
||||
:title="
|
||||
t(
|
||||
recovery
|
||||
? 'auth.twoFactor.recoveryTitle'
|
||||
: 'auth.twoFactor.title',
|
||||
)
|
||||
"
|
||||
:description="
|
||||
t(
|
||||
recovery
|
||||
? 'auth.twoFactor.recoveryDescription'
|
||||
: 'auth.twoFactor.description',
|
||||
)
|
||||
"
|
||||
:icon="recovery ? KeyRound : ShieldCheck"
|
||||
>
|
||||
<Head title="Two-factor authentication" />
|
||||
|
||||
<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 }"
|
||||
<Head :title="t('auth.twoFactor.title')" />
|
||||
<form
|
||||
:action="store.url()"
|
||||
method="post"
|
||||
class="grid gap-5"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<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"
|
||||
<AuthFormFeedback
|
||||
:state="protection.state.value"
|
||||
:response-status="protection.responseStatus.value"
|
||||
:loading-title="t('auth.twoFactor.pending')"
|
||||
@retry="submit"
|
||||
/>
|
||||
</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 }"
|
||||
>
|
||||
<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"
|
||||
placeholder="Enter recovery code"
|
||||
:autofocus="showRecoveryInput"
|
||||
autocomplete="one-time-code"
|
||||
autocapitalize="none"
|
||||
:spellcheck="false"
|
||||
required
|
||||
:disabled="form.processing"
|
||||
:invalid="Boolean(fieldError)"
|
||||
aria-describedby="recovery-hint recovery-error"
|
||||
class="font-mono"
|
||||
/>
|
||||
<InputError :message="errors.recovery_code" />
|
||||
<Button type="submit" class="w-full" :disabled="processing"
|
||||
>Continue</Button
|
||||
<p
|
||||
id="recovery-hint"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
|
||||
<div class="text-muted-foreground text-center text-sm">
|
||||
<span>or you can </span>
|
||||
<button
|
||||
{{ 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"
|
||||
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)"
|
||||
variant="outline"
|
||||
class="w-full whitespace-normal"
|
||||
:disabled="form.processing"
|
||||
@click="toggleMode"
|
||||
>{{
|
||||
t(
|
||||
recovery
|
||||
? 'auth.twoFactor.useCode'
|
||||
: 'auth.twoFactor.useRecovery',
|
||||
)
|
||||
}}</Button
|
||||
>
|
||||
{{ authConfigContent.buttonText }}
|
||||
</button>
|
||||
</div>
|
||||
</Form>
|
||||
</template>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
</form>
|
||||
<template #footer
|
||||
><TextLink :href="login()">{{
|
||||
t('auth.backToLogin')
|
||||
}}</TextLink></template
|
||||
>
|
||||
</AuthFlowLayout>
|
||||
</template>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user