Compare commits

...
48 Commits
Author SHA1 Message Date
Twyco ba7e174e82 Merge pull request 'Feature/kan 31 ticket create edit forms' (#126) from feature/KAN-31-ticket-create-edit-forms into main
CI/CD / Publish immutable image (push) Successful in 4m13s
CI/CD / preconditions (push) Successful in 1m0s
CI/CD / test_and_check_app (push) Successful in 8m40s
Reviewed-on: #126
2026-09-12 11:34:10 +00:00
Eric d646f8b537 feat: KAN-30
CI/CD / preconditions (pull_request) Successful in 1m0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-12 13:33:53 +02:00
Twyco 509d7abf82 Merge pull request 'KAN-30 Responsive ticket detail view' (#125) from feature/KAN-30-ticket-detail-layout into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #125
2026-09-12 06:22:31 +00:00
Twyco 5cc1840592 feat(KAN-30): rebuild responsive ticket detail view 2026-09-12 06:22:31 +00:00
Twyco 11b06d9b8c Merge pull request 'KAN-29 Backlog and ticket tree' (#124) from feature/KAN-29-backlog-ticket-tree into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #124
2026-09-12 06:22:20 +00:00
Twyco b76b4adaec feat(KAN-29): rework backlog ticket tree
CI/CD / preconditions (pull_request) Successful in 1m1s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 4m3s
2026-09-12 06:18:47 +00:00
Twyco c8fa76e08a fix Docker
CI/CD / preconditions (push) Successful in 1m0s
CI/CD / test_and_check_app (push) Successful in 7m43s
CI/CD / Publish immutable image (push) Successful in 3m51s
2026-09-12 02:43:19 +02:00
Twyco 87ae19f972 docker build npm fix
CI/CD / preconditions (push) Successful in 1m0s
CI/CD / test_and_check_app (push) Successful in 7m18s
CI/CD / Publish immutable image (push) Successful in 3m24s
2026-09-12 02:41:42 +02:00
Twyco a97451edfa fix
CI/CD / preconditions (push) Successful in 59s
CI/CD / test_and_check_app (push) Successful in 7m40s
CI/CD / Publish immutable image (push) Failing after 1m27s
2026-09-12 02:23:44 +02:00
Twyco 17b3e02bd8 fix
CI/CD / preconditions (push) Successful in 1m5s
CI/CD / test_and_check_app (push) Successful in 8m46s
CI/CD / Publish immutable image (push) Failing after 1m27s
2026-09-12 02:06:10 +02:00
Twyco d58fbd21ad fix: enable pnpm
CI/CD / preconditions (push) Successful in 57s
CI/CD / test_and_check_app (push) Successful in 8m40s
CI/CD / Publish immutable image (push) Failing after 1m23s
2026-09-11 18:40:54 +02:00
Twyco 002de1b293 fix: use pnpm in docker
CI/CD / preconditions (push) Successful in 59s
CI/CD / Publish immutable image (push) Failing after 1m25s
CI/CD / test_and_check_app (push) Successful in 8m24s
2026-09-11 16:25:02 +00:00
Twyco 707ebe6e48 switch to pnpm
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 13m54s
CI/CD / Publish immutable image (push) Failing after 1m27s
2026-09-11 17:39:12 +02:00
Twyco 4e29fc4526 Merge pull request 'fix: adjusted drag and drop' (#122) from feature/KAN-28-drag-and-drop-status into main
CI/CD / test_and_check_app (push) Successful in 5m42s
CI/CD / Publish immutable image (push) Successful in 2m30s
Reviewed-on: #122
2026-09-11 14:40:58 +00:00
Eric fab1e9c7a8 fix: adjusted drag and drop
CI/CD / Publish immutable image (pull_request) Skipped
CI/CD / test_and_check_app (push) Successful in 6m29s
CI/CD / Publish immutable image (push) Skipped
CI/CD / test_and_check_app (pull_request) Successful in 6m52s
2026-09-11 15:55:13 +02:00
Twyco ccd199a695 Merge pull request 'KAN-27 Responsive board' (#121) from feature/KAN-27-responsive-board into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
Reviewed-on: #121
2026-09-11 12:21:06 +00:00
Twyco f7b50e8715 feat(KAN-27): rebuild responsive project board
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-11 14:20:56 +02:00
Twyco 5147c8ed8d Merge pull request 'KAN-25 Personal dashboard' (#119) from feature/KAN-25-personal-dashboard into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #119
2026-09-11 12:20:42 +00:00
Twyco 3914fd5432 feat(KAN-25): build personal dashboard
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-11 14:20:27 +02:00
Twyco 4d6da2c5f5 Merge pull request 'KAN-26 Ticket cards and lists' (#120) from feature/KAN-26-ticket-cards-and-lists into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #120
2026-09-11 12:20:12 +00:00
Twyco 313e2d2396 feat(KAN-26): add reusable ticket presentations
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-11 14:20:02 +02:00
Twyco 810d1850ae Merge pull request 'KAN-24 Project creation' (#118) from feature/KAN-24-project-creation into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #118
2026-09-11 12:19:50 +00:00
Twyco cd7fae3841 feat(KAN-24): rebuild project creation flow
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
2026-09-11 14:19:32 +02:00
Twyco 32e3496248 Merge pull request 'KAN-23 Project overview' (#117) from feature/KAN-23-project-overview into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #117
2026-09-11 12:19:16 +00:00
Twyco 0e78be0eae feat(KAN-23): rebuild project overview and entry pages
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-11 14:19:07 +02:00
Twyco 17caabbb3e Merge pull request 'KAN-22 Password confirmation pages' (#116) from feature/KAN-22-password-confirmation-pages into main
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 37s
Reviewed-on: #116
2026-09-11 12:18:52 +00:00
Twyco 3f4945c20d feat(KAN-22): rebuild password confirmation flows
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
2026-09-11 14:18:45 +02:00
Twyco 65bf75ed77 Merge pull request 'KAN-21 Login, verification, and two-factor authentication' (#115) from feature/KAN-21-login-verification-two-factor into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #115
2026-09-11 12:18:31 +00:00
Twyco 802294d6a2 feat(KAN-21): rebuild login verification and two-factor flows
CI/CD / test_and_check_app (pull_request) Canceled after 6s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-11 14:18:23 +02:00
Twyco 5e52e4fe78 Merge pull request 'KAN-20 Dialogs, feedback, and input protection' (#114) from feature/KAN-20-dialogs-feedback-input-protection into main
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 6s
Reviewed-on: #114
2026-09-11 12:18:04 +00:00
Twyco f4a8bdd134 feat(KAN-20): unify dialogs feedback and protect ticket drafts
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
2026-09-11 14:17:10 +02:00
Twyco 850ae337ca Merge pull request 'KAN-19 Responsive app navigation' (#113) from feature/KAN-19-responsive-app-navigation into main
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 6s
Reviewed-on: #113
2026-09-11 12:16:54 +00:00
Twyco 4adda6e53b feat(KAN-19): add responsive permission-aware navigation
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-11 14:16:43 +02:00
Twyco de6f4e5b2f Merge pull request 'KAN-18 Basic UI components' (#112) from feature/KAN-18-basic-ui-components into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #112
2026-09-11 12:14:09 +00:00
Twyco 2e2ddd5b74 feat(KAN-18): redesign foundational UI components
CI/CD / test_and_check_app (push) Canceled after 6s
CI/CD / test_and_check_app (pull_request) Canceled after 6s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-11 14:13:49 +02:00
Twyco c58196293f Merge pull request 'KAN-17 Emerald design system' (#111) from feature/KAN-17-emerald-design-system into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #111
2026-09-11 12:11:50 +00:00
Twyco 8657d75682 feat(KAN-17): add Emerald design system and resilient themes 2026-09-11 12:11:50 +00:00
Twyco 71d9d1b63e feat(KAN-16): add English localization foundation 2026-09-11 12:11:50 +00:00
Twyco 68995a9f4c Merge pull request 'Codex/mcp list ticket types' (#110) from codex/mcp-list-ticket-types into main
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 1m29s
Reviewed-on: #110
2026-09-11 12:05:54 +00:00
Twyco 3e998a483e remove ui docs
CI/CD / test_and_check_app (pull_request) Successful in 4m53s
CI/CD / Publish immutable image (pull_request) Skipped
CI/CD / test_and_check_app (push) Successful in 4m38s
CI/CD / Publish immutable image (push) Skipped
2026-09-11 12:05:39 +00:00
Twyco ceffe4f4a4 Merge pull request 'Add MCP tool to list project ticket types' (#109) from codex/mcp-list-ticket-types into main
CI/CD / Publish immutable image (push) Successful in 2m31s
CI/CD / test_and_check_app (push) Successful in 4m2s
Reviewed-on: #109
2026-09-09 07:44:51 +00:00
Twyco ce01b8ec55 Add MCP tool to list project ticket types
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 6s
2026-09-09 07:44:41 +00:00
Twyco bd774e20f6 Merge pull request 'Add MCP ticket creation with full fields and parent support' (#108) from codex/mcp-create-ticket into main
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 42s
Reviewed-on: #108
2026-09-09 07:44:33 +00:00
Twyco d9cb8d0903 Add MCP ticket creation with full fields and parent support
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-09 07:40:25 +00:00
Twyco 19e8ba89bf chroe: audit fix
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 2m34s
2026-09-09 09:40:10 +02:00
Twyco 642e49d7b0 feat: added ui docs
CI/CD / test_and_check_app (push) Failing after 2m11s
CI/CD / Publish immutable image (push) Skipped
2026-09-09 08:15:37 +02:00
Twyco 1d8fff1d51 formatted
CI/CD / test_and_check_app (push) Successful in 3m53s
CI/CD / Publish immutable image (push) Successful in 1m33s
2026-09-08 14:24:04 +02:00
Twyco bd7be4e41e Merge pull request 'epic/kan-3' (#95) from epic/kan-3 into main
CI/CD / test_and_check_app (push) Failing after 3m1s
CI/CD / Publish immutable image (push) Skipped
Reviewed-on: #95
2026-09-08 10:27:25 +00:00
204 changed files with 22997 additions and 12080 deletions
+67 -13
View File
@@ -1,18 +1,72 @@
name: test_and_check_app name: "Lint and Test API"
description: Build the test image and run all application checks description: "Run Pint and the Pest test suite for the Laravel API"
runs: runs:
using: composite using: composite
steps: steps:
- name: Test and check app - name: Setup PHP
shell: bash uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 #v2.37.2
run: | with:
set -Eeuo pipefail php-version: "8.5"
extensions: mbstring, pdo_sqlite
coverage: none
tools: composer:v2
docker buildx build \ - name: Verify Composer lockfile
--file Dockerfile \ run: |
--target test \ if [ ! -f composer.lock ]; then
--tag "local/kanbangit-test:${GITHUB_SHA}" \ echo "::error::api/composer.lock not found"
--progress plain \ exit 1
--load \ fi
. shell: bash
- name: Get Composer cache directory
id: composer_cache
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
shell: bash
- name: Cache Composer packages
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ${{ steps.composer_cache.outputs.dir }}
key: ${{ runner.os }}-php-8.5-${{ hashFiles('api/composer.lock') }}
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v11.7.0
with:
package-json-file: package.json
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: "pnpm"
cache-dependency-path: "pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install --frozen-lockfile
shell: bash
- name: Prepare environment file
run: cp .env.example .env
shell: bash
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --no-progress
shell: bash
- name: Generate application key
run: php artisan key:generate
shell: bash
- name: Generate Wayfinder routes and actions
run: php artisan wayfinder:generate --with-form
shell: bash
- name: Build Vite app
run: pnpm run build
shell: bash
- name: Run ci check
run: composer run ci:check
shell: bash
+19 -2
View File
@@ -2,7 +2,13 @@ name: CI/CD
on: on:
push: push:
branches:
- main
tags:
- "v*"
pull_request: pull_request:
branches:
- main
permissions: permissions:
contents: read contents: read
@@ -11,9 +17,19 @@ env:
REGISTRY: git.twyco.de REGISTRY: git.twyco.de
jobs: jobs:
test_and_check_app: preconditions:
name: test_and_check_app if: >
!(contains(github.event.head_commit.message, '[no-ci]') || contains(github.event.head_commit.message, '[skip-ci]'))
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps:
- name: Conditions met
run: |
echo "Conditions for CI/CD are met. Proceeding with the workflow."
test_and_check_app:
runs-on: ubuntu-latest
needs:
- preconditions
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
@@ -27,6 +43,7 @@ jobs:
github.event_name == 'push' && github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
needs: needs:
- preconditions
- test_and_check_app - test_and_check_app
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+3 -2
View File
@@ -55,8 +55,9 @@ COPY --from=node_binary /usr/local/ /usr/local/
COPY . /var/www COPY . /var/www
RUN composer install --prefer-dist --no-interaction --no-progress \ RUN composer install --prefer-dist --no-interaction --no-progress \
&& npm ci \ && corepack enable pnpm \
&& npm run build && pnpm install --frozen-lockfile \
&& pnpm run build
FROM build AS test FROM build AS test
@@ -47,6 +47,7 @@ class BuildProjectBacklogPayload
'tickets' => TicketResource::collection( 'tickets' => TicketResource::collection(
Ticket::query() Ticket::query()
->with([ ->with([
'assignee',
'ticketPriority', 'ticketPriority',
'ticketStatus.allowedTargetStatuses.boardColumn', 'ticketStatus.allowedTargetStatuses.boardColumn',
'ticketStatus.boardColumn', 'ticketStatus.boardColumn',
@@ -29,6 +29,7 @@ class BuildProjectBoardPayload
'ticketStatuses', 'ticketStatuses',
'tickets' => fn ($query) => $query 'tickets' => fn ($query) => $query
->with([ ->with([
'assignee',
'ticketPriority', 'ticketPriority',
'ticketStatus.allowedTargetStatuses.boardColumn', 'ticketStatus.allowedTargetStatuses.boardColumn',
'ticketStatus.boardColumn', 'ticketStatus.boardColumn',
+24 -1
View File
@@ -19,6 +19,7 @@ class CreateProjectTicket
public function __construct( public function __construct(
private readonly BuildTicketCreatedPayload $buildTicketCreatedPayload, private readonly BuildTicketCreatedPayload $buildTicketCreatedPayload,
private readonly CreateTicketWorklogEntry $createTicketWorklogEntry, private readonly CreateTicketWorklogEntry $createTicketWorklogEntry,
private readonly SyncProjectTicketFieldValues $syncProjectTicketFieldValues,
) {} ) {}
/** /**
@@ -29,7 +30,15 @@ class CreateProjectTicket
* text: string * text: string
* }>, * }>,
* assignee_user_id?: int|null, * assignee_user_id?: int|null,
* author_user_id?: int|null,
* blocked_comment?: string|null,
* description: 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, * parent_ticket_id?: int|null,
* story_points?: int|null, * story_points?: int|null,
* ticket_priority_id: int, * ticket_priority_id: int,
@@ -50,11 +59,14 @@ class CreateProjectTicket
$ticketStatusQuery = TicketStatus::query()->where('project_id', $project->id); $ticketStatusQuery = TicketStatus::query()->where('project_id', $project->id);
$ticketStatus = $ticketStatusQuery->findOrFail(Arr::get($attributes, 'ticket_status_id')); $ticketStatus = $ticketStatusQuery->findOrFail(Arr::get($attributes, 'ticket_status_id'));
$isBlocked = (bool) Arr::get($attributes, 'is_blocked', false);
$ticket = Ticket::query()->create([ $ticket = Ticket::query()->create([
'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'), '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'), 'description' => Arr::get($attributes, 'description'),
'is_blocked' => $isBlocked,
'parent_ticket_id' => Arr::get($attributes, 'parent_ticket_id'), 'parent_ticket_id' => Arr::get($attributes, 'parent_ticket_id'),
'project_id' => $project->id, 'project_id' => $project->id,
'sequence_number' => $nextSequenceNumber, 'sequence_number' => $nextSequenceNumber,
@@ -68,6 +80,17 @@ class CreateProjectTicket
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $acceptanceCriteria); 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(); $occurredAt = CarbonImmutable::now();
if ($actor !== null) { if ($actor !== null) {
@@ -41,8 +41,8 @@ class ListAssignedTicketsByVisibleProject
->where('assignee_user_id', $assignee->id) ->where('assignee_user_id', $assignee->id)
->whereIn('project_id', $visibleProjectIds) ->whereIn('project_id', $visibleProjectIds)
->with([ ->with([
'acceptanceCriteria',
'assignee', 'assignee',
'project',
'ticketPriority', 'ticketPriority',
'ticketStatus.boardColumn', 'ticketStatus.boardColumn',
'ticketType', 'ticketType',
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Tickets\ListAssignedTicketsByVisibleProject;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
public function __construct(
private readonly ListAssignedTicketsByVisibleProject $listAssignedTicketsByVisibleProject,
) {}
public function __invoke(Request $request): Response
{
$user = $request->user();
return Inertia::render('Dashboard', [
'assignedTicketsByProject' => $this->listAssignedTicketsByVisibleProject->handle($user, $user),
]);
}
}
+5 -1
View File
@@ -16,7 +16,11 @@ class HandleAppearance
*/ */
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
View::share('appearance', $request->cookie('appearance') ?? 'system'); $appearance = $request->cookie('appearance');
$appearance = in_array($appearance, ['light', 'dark', 'system'], true) ? $appearance : 'system';
$request->attributes->set('appearance', $appearance);
View::share('appearance', $appearance);
return $next($request); return $next($request);
} }
@@ -4,6 +4,7 @@ namespace App\Http\Middleware;
use App\Actions\Permissions\PermissionService; use App\Actions\Permissions\PermissionService;
use App\Enums\Permission; use App\Enums\Permission;
use App\Models\Project;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Middleware; use Inertia\Middleware;
@@ -46,6 +47,11 @@ class HandleInertiaRequests extends Middleware
return [ return [
...parent::share($request), ...parent::share($request),
'name' => config('app.name'), 'name' => config('app.name'),
'appearance' => $request->attributes->get('appearance', 'system'),
'localization' => [
'locale' => app()->getLocale(),
'timeZone' => config('app.timezone'),
],
'auth' => [ 'auth' => [
'permissions' => [ 'permissions' => [
'canManageTeams' => $user !== null && ($this->permissionService->canManageAnyTeam($user) || $this->permissionService->allows($user, Permission::TeamCreate)), 'canManageTeams' => $user !== null && ($this->permissionService->canManageAnyTeam($user) || $this->permissionService->allows($user, Permission::TeamCreate)),
@@ -55,6 +61,36 @@ class HandleInertiaRequests extends Middleware
'user' => $user, 'user' => $user,
], ],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'navigation' => fn (): array => [
'project' => $this->projectNavigation($request),
],
];
}
/**
* @return array{id: int, name: string, key: string, canManage: bool, canCreateTickets: bool}|null
*/
private function projectNavigation(Request $request): ?array
{
$project = $request->route('project');
$user = $request->user();
if (! $project instanceof Project || $user === null) {
return null;
}
$permissions = $this->permissionService->effectivePermissions($user, $project);
if (! in_array(Permission::ProjectRead, $permissions, true)) {
return null;
}
return [
'id' => $project->id,
'name' => $project->name,
'key' => $project->key,
'canManage' => in_array(Permission::ProjectAdmin, $permissions, true),
'canCreateTickets' => in_array(Permission::ProjectWrite, $permissions, true),
]; ];
} }
} }
+5 -1
View File
@@ -2,9 +2,11 @@
namespace App\Mcp\Servers; namespace App\Mcp\Servers;
use App\Mcp\Tools\CreateTicket;
use App\Mcp\Tools\GetTicket; use App\Mcp\Tools\GetTicket;
use App\Mcp\Tools\ListMyAssignedTickets; use App\Mcp\Tools\ListMyAssignedTickets;
use App\Mcp\Tools\ListTicketsByStatus; use App\Mcp\Tools\ListTicketsByStatus;
use App\Mcp\Tools\ListTicketTypes;
use App\Mcp\Tools\MoveTicketStatus; use App\Mcp\Tools\MoveTicketStatus;
use App\Mcp\Tools\UpdateTicketAssignee; use App\Mcp\Tools\UpdateTicketAssignee;
use App\Mcp\Tools\UpdateTicketDescription; use App\Mcp\Tools\UpdateTicketDescription;
@@ -15,7 +17,7 @@ use Laravel\Mcp\Server\Attributes\Version;
#[Name('KanbanGit MCP')] #[Name('KanbanGit MCP')]
#[Version('0.1.0')] #[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 class KanbanGitServer extends Server
{ {
protected array $tools = [ protected array $tools = [
@@ -25,6 +27,8 @@ class KanbanGitServer extends Server
UpdateTicketDescription::class, UpdateTicketDescription::class,
UpdateTicketAssignee::class, UpdateTicketAssignee::class,
MoveTicketStatus::class, MoveTicketStatus::class,
CreateTicket::class,
ListTicketTypes::class,
]; ];
protected array $resources = [ protected array $resources = [
+20
View File
@@ -120,6 +120,24 @@ class KanbanGitMcp
return $project; 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 * @param array<string, mixed> $validated
* @return Collection<int, TicketStatus>|Response * @return Collection<int, TicketStatus>|Response
@@ -189,10 +207,12 @@ class KanbanGitMcp
public function ticketPayload(Ticket $ticket): array public function ticketPayload(Ticket $ticket): array
{ {
$ticket->load([ $ticket->load([
'acceptanceCriteria',
'assignee', 'assignee',
'author', 'author',
'fieldValues.ticketFieldDefinition', 'fieldValues.ticketFieldDefinition',
'fieldValues.userValue', 'fieldValues.userValue',
'parentTicket',
'project', 'project',
'ticketPriority', 'ticketPriority',
'ticketStatus.boardColumn', 'ticketStatus.boardColumn',
+169
View File
@@ -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.'),
];
}
}
+56
View File
@@ -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(),
];
}
}
+1
View File
@@ -16,6 +16,7 @@ class BoardColumn extends Model
{ {
/** @use HasFactory<BoardColumnFactory> */ /** @use HasFactory<BoardColumnFactory> */
use HasFactory; use HasFactory;
use SyncsProjectTemplateOwnership; use SyncsProjectTemplateOwnership;
protected static function booted(): void protected static function booted(): void
+1
View File
@@ -31,6 +31,7 @@ class NotificationAction extends Model
{ {
/** @use HasFactory<NotificationActionFactory> */ /** @use HasFactory<NotificationActionFactory> */
use HasFactory; use HasFactory;
use SyncsProjectTemplateOwnership; use SyncsProjectTemplateOwnership;
/** /**
+1
View File
@@ -26,6 +26,7 @@ class RepositoryConnection extends Model
{ {
/** @use HasFactory<RepositoryConnectionFactory> */ /** @use HasFactory<RepositoryConnectionFactory> */
use HasFactory; use HasFactory;
use SyncsProjectTemplateOwnership; use SyncsProjectTemplateOwnership;
/** /**
+1
View File
@@ -23,6 +23,7 @@ class TicketFieldDefinition extends Model
/** @use HasFactory<TicketFieldDefinitionFactory> */ /** @use HasFactory<TicketFieldDefinitionFactory> */
use HasFactory; use HasFactory;
use SyncsProjectTemplateOwnership; use SyncsProjectTemplateOwnership;
protected static function booted(): void protected static function booted(): void
+1
View File
@@ -15,6 +15,7 @@ class TicketLinkType extends Model
{ {
/** @use HasFactory<TicketLinkTypeFactory> */ /** @use HasFactory<TicketLinkTypeFactory> */
use HasFactory; use HasFactory;
use SyncsProjectTemplateOwnership; use SyncsProjectTemplateOwnership;
protected static function booted(): void protected static function booted(): void
+1
View File
@@ -17,6 +17,7 @@ class TicketStatus extends Model
{ {
/** @use HasFactory<TicketStatusFactory> */ /** @use HasFactory<TicketStatusFactory> */
use HasFactory; use HasFactory;
use SyncsProjectTemplateOwnership; use SyncsProjectTemplateOwnership;
protected static function booted(): void protected static function booted(): void
+1
View File
@@ -15,6 +15,7 @@ class TicketType extends Model
{ {
/** @use HasFactory<TicketTypeFactory> */ /** @use HasFactory<TicketTypeFactory> */
use HasFactory; use HasFactory;
use SyncsProjectTemplateOwnership; use SyncsProjectTemplateOwnership;
protected static function booted(): void protected static function booted(): void
+6 -6
View File
@@ -47,15 +47,15 @@
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"", "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate", "@php artisan key:generate",
"@php artisan migrate --force", "@php artisan migrate --force",
"npm install", "pnpm install",
"npm run build" "pnpm run build"
], ],
"dev": [ "dev": [
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
"@php artisan dev" "@php artisan dev"
], ],
"dev:ssr": [ "dev:ssr": [
"npm run build:ssr", "pnpm run build:ssr",
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others" "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others"
], ],
@@ -67,9 +67,9 @@
], ],
"ci:check": [ "ci:check": [
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
"npm audit --audit-level=high", "pnpm audit --audit-level=high",
"npm run lint:check", "pnpm run lint:check",
"npm run types:check", "pnpm run types:check",
"@test" "@test"
], ],
"test": [ "test": [
+1
View File
@@ -25,6 +25,7 @@ const prettierOptions = {
const eslintConfig = [ const eslintConfig = [
{ {
ignores: [ ignores: [
'bootstrap/ssr/**',
'**/dist/**', '**/dist/**',
'node_modules/**', 'node_modules/**',
'.github/**', '.github/**',
-7268
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -2,6 +2,13 @@
"$schema": "https://www.schemastore.org/package.json", "$schema": "https://www.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "11.26.0+sha512.fc0e2bf890b9f983611f1ab68c0637bce914390653699f83c1a78b005ed25f2c81e77920c8fd8eee2ecf0b58b28cdcb00a84d97f69bcf7c56b2f344710238664",
"onFail": "error"
}
},
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
"build:ssr": "vite build && vite build --ssr", "build:ssr": "vite build && vite build --ssr",
@@ -39,6 +46,7 @@
"vue-tsc": "^2.2.4" "vue-tsc": "^2.2.4"
}, },
"dependencies": { "dependencies": {
"@inertiajs/core": "2.3.18",
"@inertiajs/vue3": "^2.3.7", "@inertiajs/vue3": "^2.3.7",
"@vueuse/core": "^12.8.2", "@vueuse/core": "^12.8.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
-1
View File
@@ -19,7 +19,6 @@
</source> </source>
<php> <php>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="APP_KEY" value="base64:11ujGYe5vzbwqcdFDVQC6PuDgd7c8PKD9kWt9HH9Bvs="/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/> <env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="AVATAR_FILESYSTEM_DISK" value="public"/> <env name="AVATAR_FILESYSTEM_DISK" value="public"/>
<env name="AVATAR_URLS_TEMPORARY" value="false"/> <env name="AVATAR_URLS_TEMPORARY" value="false"/>
+4832
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
minimumReleaseAge: 10080
allowBuilds:
unrs-resolver: true
vue-demi: true
Binary file not shown.
Binary file not shown.
+92
View File
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+87 -122
View File
@@ -1,56 +1,98 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import 'tw-animate-css'; @import 'tw-animate-css';
@import './theme.css';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php'; @source '../../storage/framework/views/*.php';
@source not '../../bootstrap/ssr';
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:where(.dark, .dark *));
@font-face {
font-family: Inter;
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/inter/InterVariable.woff2') format('woff2');
}
@font-face {
font-family: Inter;
font-style: italic;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/inter/InterVariable-Italic.woff2') format('woff2');
}
@theme inline { @theme inline {
--font-sans: --font-sans: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
Instrument Sans, ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--radius-lg: var(--radius); --radius-sm: 0.125rem;
--radius-md: calc(var(--radius) - 2px); --radius-md: var(--radius);
--radius-sm: calc(var(--radius) - 4px); --radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--spacing-page: 1.5rem;
--spacing-column: 1rem;
--spacing-card: 0.75rem;
--spacing-stack: 0.5rem;
--spacing-control: 2.75rem;
--text-headline: 1.5rem;
--text-headline--line-height: 2rem;
--text-headline--font-weight: 600;
--text-headline--letter-spacing: -0.02em;
--text-headline-mobile: 1.25rem;
--text-headline-mobile--line-height: 1.75rem;
--text-headline-mobile--font-weight: 600;
--text-headline-mobile--letter-spacing: -0.02em;
--text-title: 1.125rem;
--text-title--line-height: 1.5rem;
--text-title--font-weight: 600;
--text-title--letter-spacing: -0.01em;
--text-body: 0.875rem;
--text-body--line-height: 1.25rem;
--text-caption: 0.8125rem;
--text-caption--line-height: 1.125rem;
--text-label: 0.6875rem;
--text-label--line-height: 1rem;
--text-label--font-weight: 700;
--text-label--letter-spacing: 0.05em;
--shadow-card-hover: 0 4px 12px rgb(0 0 0 / 0.08);
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground); --color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
--color-brand: var(--brand);
--color-brand-foreground: var(--brand-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-info: var(--info);
--color-info-foreground: var(--info-foreground);
--color-chart-1: var(--chart-1); --color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar-background); --color-sidebar: var(--sidebar-background);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);
@@ -61,112 +103,35 @@
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
} }
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
@layer utilities {
body,
html {
--font-sans:
'Instrument Sans', ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
}
}
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(0 0% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(0 0% 3.9%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(0 0% 3.9%);
--primary: hsl(0 0% 9%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(0 0% 92.1%);
--secondary-foreground: hsl(0 0% 9%);
--muted: hsl(0 0% 96.1%);
--muted-foreground: hsl(0 0% 45.1%);
--accent: hsl(0 0% 96.1%);
--accent-foreground: hsl(0 0% 9%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(0 0% 92.8%);
--input: hsl(0 0% 89.8%);
--ring: hsl(0 0% 3.9%);
--chart-1: hsl(12 76% 61%);
--chart-2: hsl(173 58% 39%);
--chart-3: hsl(197 37% 24%);
--chart-4: hsl(43 74% 66%);
--chart-5: hsl(27 87% 67%);
--radius: 0.5rem;
--sidebar-background: hsl(0 0% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%);
--sidebar-primary: hsl(0 0% 10%);
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(0 0% 94%);
--sidebar-accent-foreground: hsl(0 0% 30%);
--sidebar-border: hsl(0 0% 91%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(0 0% 98%);
}
.dark {
--background: hsl(0 0% 3.9%);
--foreground: hsl(0 0% 98%);
--card: hsl(0 0% 3.9%);
--card-foreground: hsl(0 0% 98%);
--popover: hsl(0 0% 3.9%);
--popover-foreground: hsl(0 0% 98%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(0 0% 9%);
--secondary: hsl(0 0% 14.9%);
--secondary-foreground: hsl(0 0% 98%);
--muted: hsl(0 0% 16.08%);
--muted-foreground: hsl(0 0% 63.9%);
--accent: hsl(0 0% 14.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 84% 60%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(0 0% 14.9%);
--input: hsl(0 0% 14.9%);
--ring: hsl(0 0% 83.1%);
--chart-1: hsl(220 70% 50%);
--chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
--sidebar-background: hsl(0 0% 7%);
--sidebar-foreground: hsl(0 0% 95.9%);
--sidebar-primary: hsl(360, 100%, 100%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(0 0% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(0 0% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(240 5.9% 10%);
}
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background font-sans text-body text-foreground antialiased;
font-optical-sizing: auto;
}
:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 3px;
}
::selection {
background: var(--brand);
color: var(--brand-foreground);
}
}
/* Preserve completion events while removing visible nonessential motion. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
} }
} }
+96
View File
@@ -0,0 +1,96 @@
/* Fluid Emerald / Kinetic Kanban: shared by the first paint and the app bundle. */
:root {
color-scheme: light;
--radius: 0.25rem;
--background: #f7f9fb;
--foreground: #191c1e;
--card: #ffffff;
--card-foreground: #191c1e;
--popover: #ffffff;
--popover-foreground: #191c1e;
--primary: #006e2f;
--primary-foreground: #ffffff;
--secondary: #d0e1fb;
--secondary-foreground: #38485d;
--muted: #f2f4f6;
--muted-foreground: #505f76;
--accent: #dcfce7;
--accent-foreground: #005321;
--destructive: #ba1a1a;
--destructive-foreground: #ffffff;
--border: #e2e8f0;
--input: #6d7b6c;
--ring: #006e2f;
--brand: #22c55e;
--brand-foreground: #002109;
--success: #dcfce7;
--success-foreground: #005321;
--warning: #fef3c7;
--warning-foreground: #78350f;
--info: #dbeafe;
--info-foreground: #1e40af;
--chart-1: #006e2f;
--chart-2: #505f76;
--chart-3: #9e4036;
--chart-4: #92400e;
--chart-5: #1d4ed8;
--sidebar-background: #f2f4f6;
--sidebar-foreground: #191c1e;
--sidebar-primary: #006e2f;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #dcfce7;
--sidebar-accent-foreground: #005321;
--sidebar-border: #e2e8f0;
--sidebar-ring: #006e2f;
--sidebar: #f2f4f6;
}
.dark {
color-scheme: dark;
--background: #0f172a;
--foreground: #f1f5f9;
--card: #1e293b;
--card-foreground: #f1f5f9;
--popover: #1e293b;
--popover-foreground: #f1f5f9;
--primary: #4ae176;
--primary-foreground: #002109;
--secondary: #334155;
--secondary-foreground: #e2e8f0;
--muted: #1e293b;
--muted-foreground: #b7c8e1;
--accent: #173b2b;
--accent-foreground: #6bff8f;
--destructive: #fca5a5;
--destructive-foreground: #450a0a;
--border: #334155;
--input: #8293aa;
--ring: #4ae176;
--brand: #22c55e;
--brand-foreground: #002109;
--success: #173b2b;
--success-foreground: #6bff8f;
--warning: #422006;
--warning-foreground: #fde68a;
--info: #172554;
--info-foreground: #bfdbfe;
--chart-1: #4ae176;
--chart-2: #b7c8e1;
--chart-3: #ffb4a9;
--chart-4: #fbbf24;
--chart-5: #60a5fa;
--sidebar-background: #111c30;
--sidebar-foreground: #f1f5f9;
--sidebar-primary: #4ae176;
--sidebar-primary-foreground: #002109;
--sidebar-accent: #173b2b;
--sidebar-accent-foreground: #6bff8f;
--sidebar-border: #334155;
--sidebar-ring: #4ae176;
--sidebar: #111c30;
}
html {
background-color: var(--background);
color: var(--foreground);
}
+5 -5
View File
@@ -3,7 +3,8 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import type { DefineComponent } from 'vue'; import type { DefineComponent } from 'vue';
import { createApp, h } from 'vue'; import { createApp, h } from 'vue';
import '../css/app.css'; import '../css/app.css';
import { initializeTheme } from '@/composables/useAppearance'; import { createAppearance } from '@/composables/useAppearance';
import { createLocalization } from '@/lib/localization';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
@@ -17,12 +18,11 @@ createInertiaApp({
setup({ el, App, props, plugin }) { setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) }) createApp({ render: () => h(App, props) })
.use(plugin) .use(plugin)
.use(createLocalization(props.initialPage.props.localization))
.use(createAppearance(props.initialPage.props.appearance))
.mount(el); .mount(el);
}, },
progress: { progress: {
color: '#4B5563', color: 'var(--primary)',
}, },
}); });
// This will set light / dark mode on page load...
initializeTheme();
+4 -4
View File
@@ -2,15 +2,15 @@
import { AlertCircle } from 'lucide-vue-next'; import { AlertCircle } from 'lucide-vue-next';
import { computed } from 'vue'; import { computed } from 'vue';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { useLocalization } from '@/composables/useLocalization';
type Props = { type Props = {
errors: string[]; errors: string[];
title?: string; title?: string;
}; };
const props = withDefaults(defineProps<Props>(), { const props = defineProps<Props>();
title: 'Something went wrong.', const { t } = useLocalization();
});
const uniqueErrors = computed(() => Array.from(new Set(props.errors))); const uniqueErrors = computed(() => Array.from(new Set(props.errors)));
</script> </script>
@@ -18,7 +18,7 @@ const uniqueErrors = computed(() => Array.from(new Set(props.errors)));
<template> <template>
<Alert variant="destructive"> <Alert variant="destructive">
<AlertCircle class="size-4" /> <AlertCircle class="size-4" />
<AlertTitle>{{ title }}</AlertTitle> <AlertTitle>{{ title ?? t('common.error') }}</AlertTitle>
<AlertDescription> <AlertDescription>
<ul class="list-inside list-disc text-sm"> <ul class="list-inside list-disc text-sm">
<li v-for="(error, index) in uniqueErrors" :key="index"> <li v-for="(error, index) in uniqueErrors" :key="index">
+11 -10
View File
@@ -1,16 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import AppLogoIcon from '@/components/AppLogoIcon.vue'; import { Columns3 } from 'lucide-vue-next';
</script> </script>
<template> <template>
<div <span class="flex min-w-0 items-center gap-3">
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md" <span
> class="bg-brand text-brand-foreground flex size-8 shrink-0 items-center justify-center rounded-md"
<AppLogoIcon class="size-5 fill-current text-white dark:text-black" />
</div>
<div class="ml-1 grid flex-1 text-left text-sm">
<span class="mb-0.5 truncate font-semibold leading-tight"
>Laravel Starter Kit</span
> >
</div> <Columns3 class="size-5" aria-hidden="true" />
</span>
<span
class="text-title truncate font-semibold group-data-[collapsible=icon]:hidden"
>KanbanGit</span
>
</span>
</template> </template>
+86 -90
View File
@@ -1,19 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3'; import { Link, router } from '@inertiajs/vue3';
import { import { Settings2, X } from 'lucide-vue-next';
BookOpen, import { computed, onMounted, onUnmounted } from 'vue';
Folder,
FolderGit2,
LayoutGrid,
Layers3,
Shield,
Users,
} from 'lucide-vue-next';
import { computed } from 'vue';
import AppLogo from '@/components/AppLogo.vue'; import AppLogo from '@/components/AppLogo.vue';
import NavFooter from '@/components/NavFooter.vue';
import NavMain from '@/components/NavMain.vue'; import NavMain from '@/components/NavMain.vue';
import NavUser from '@/components/NavUser.vue'; import { Button } from '@/components/ui/button';
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -22,93 +13,98 @@ import {
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import { useAppNavigation } from '@/composables/useAppNavigation';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization';
import { dashboard } from '@/routes'; import { dashboard } from '@/routes';
import { index as adminUsers } from '@/routes/admin/users'; import { edit as profile } from '@/routes/profile';
import { index as projectTemplatesIndex } from '@/routes/project-templates'; import { show } from '@/routes/projects';
import { index as projectsIndex } from '@/routes/projects';
import { index as teamsIndex } from '@/routes/teams';
import type { NavItem } from '@/types';
const page = usePage(); const { t } = useLocalization();
const { currentUrl } = useCurrentUrl();
const mainNavItems = computed<NavItem[]>(() => { const { project, workspaceItems, projectItems, projectSettingsItems } =
const items: NavItem[] = [ useAppNavigation();
{ const { isMobile, setOpenMobile } = useSidebar();
title: 'Dashboard', const accountItems = computed(() => [
href: dashboard(), {
icon: LayoutGrid, title: t('navigation.settings'),
}, href: profile(),
{ icon: Settings2,
title: 'Projects', isActive: currentUrl.value.startsWith('/settings/'),
href: projectsIndex(), },
icon: Folder, ]);
}, let removeNavigationListener: (() => void) | undefined;
]; onMounted(() => {
removeNavigationListener = router.on('navigate', () =>
if (page.props.auth.permissions.projectCreate) { setOpenMobile(false),
items.push({ );
title: 'Project templates',
href: projectTemplatesIndex(),
icon: Layers3,
});
}
if (page.props.auth.permissions.globalAdmin) {
items.push({
title: 'Users',
href: adminUsers(),
icon: Shield,
});
}
if (page.props.auth.permissions.canManageTeams) {
items.push({
title: 'Teams',
href: teamsIndex(),
icon: Users,
});
}
return items;
}); });
onUnmounted(() => removeNavigationListener?.());
const footerNavItems: NavItem[] = [
{
title: 'Repository',
href: 'https://github.com/laravel/vue-starter-kit',
icon: FolderGit2,
},
{
title: 'Documentation',
href: 'https://laravel.com/docs/starter-kits#vue',
icon: BookOpen,
},
];
</script> </script>
<template> <template>
<Sidebar collapsible="icon" variant="inset"> <Sidebar id="app-navigation" collapsible="icon" variant="sidebar">
<SidebarHeader> <SidebarHeader class="border-sidebar-border gap-4 border-b p-3">
<SidebarMenu> <div class="flex items-center gap-2">
<SidebarMenuItem> <SidebarMenu class="min-w-0">
<SidebarMenuButton size="lg" as-child> <SidebarMenuItem>
<Link :href="dashboard()"> <SidebarMenuButton
<AppLogo /> size="lg"
</Link> as-child
</SidebarMenuButton> tooltip="KanbanGit"
</SidebarMenuItem> >
</SidebarMenu> <Link :href="dashboard()" aria-label="KanbanGit"
><AppLogo
/></Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<Button
v-if="isMobile"
variant="ghost"
size="icon"
:aria-label="t('navigation.close')"
@click="setOpenMobile(false)"
>
<X aria-hidden="true" />
</Button>
</div>
<Link
v-if="project"
:href="show(project.id)"
class="bg-sidebar-accent flex min-w-0 items-center gap-3 rounded-md p-3 group-data-[collapsible=icon]:hidden"
>
<span
class="text-label bg-brand/15 text-primary shrink-0 rounded-md px-2 py-1"
>{{ project.key }}</span
>
<span class="truncate font-semibold" :title="project.name">{{
project.name
}}</span>
</Link>
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
<NavMain :items="mainNavItems" /> <nav :aria-label="t('navigation.main')">
<NavMain
:items="workspaceItems"
:label="t('navigation.workspace')"
/>
<NavMain
v-if="projectItems.length"
:items="projectItems"
:label="t('navigation.project')"
/>
<NavMain
v-if="projectSettingsItems.length"
:items="projectSettingsItems"
:label="t('navigation.projectSettings')"
/>
</nav>
</SidebarContent> </SidebarContent>
<SidebarFooter class="border-sidebar-border border-t p-0">
<SidebarFooter> <NavMain :items="accountItems" :label="t('navigation.account')" />
<NavFooter :items="footerNavItems" />
<NavUser />
</SidebarFooter> </SidebarFooter>
</Sidebar> </Sidebar>
<slot />
</template> </template>
+131 -13
View File
@@ -1,27 +1,145 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { Monitor, Moon, Plus, Sun } from 'lucide-vue-next';
import { computed } from 'vue';
import Breadcrumbs from '@/components/Breadcrumbs.vue'; import Breadcrumbs from '@/components/Breadcrumbs.vue';
import UserMenuContent from '@/components/UserMenuContent.vue';
import { UserAvatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { SidebarTrigger } from '@/components/ui/sidebar'; import { SidebarTrigger } from '@/components/ui/sidebar';
import { useAppNavigation } from '@/composables/useAppNavigation';
import {
normalizeAppearance,
useAppearance,
} from '@/composables/useAppearance';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization';
import { create as createProject } from '@/routes/projects';
import { index as board } from '@/routes/projects/board';
import { create as createTicket } from '@/routes/projects/tickets';
import type { BreadcrumbItem } from '@/types'; import type { BreadcrumbItem } from '@/types';
withDefaults( withDefaults(defineProps<{ breadcrumbs?: BreadcrumbItem[] }>(), {
defineProps<{ breadcrumbs: () => [],
breadcrumbs?: BreadcrumbItem[]; });
}>(), const page = usePage();
{ const { project } = useAppNavigation();
breadcrumbs: () => [], const { isCurrentUrl } = useCurrentUrl();
}, const { t } = useLocalization();
const { appearance, updateAppearance } = useAppearance();
const themeIcon = computed(
() => ({ light: Sun, dark: Moon, system: Monitor })[appearance.value],
); );
const createAction = computed(() => {
if (project.value?.canCreateTickets) {
return {
title: t('navigation.createTicket'),
href: createTicket(project.value.id, {
query: {
source: isCurrentUrl(board(project.value.id))
? 'board'
: 'backlog',
},
}),
};
}
if (!project.value && page.props.auth.permissions.projectCreate) {
return { title: t('navigation.createProject'), href: createProject() };
}
return null;
});
</script> </script>
<template> <template>
<header <header
class="border-sidebar-border/70 group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 flex h-16 shrink-0 items-center gap-2 border-b px-6 transition-[width,height] ease-linear md:px-4" class="bg-background md:px-page sticky top-0 z-20 flex min-h-16 min-w-0 shrink-0 items-center justify-between gap-2 border-b px-3 md:gap-4"
> >
<div class="flex items-center gap-2"> <div class="flex min-w-0 flex-1 items-center gap-2">
<SidebarTrigger class="-ml-1" /> <SidebarTrigger class="shrink-0" />
<template v-if="breadcrumbs && breadcrumbs.length > 0"> <Breadcrumbs v-if="breadcrumbs.length" :breadcrumbs="breadcrumbs" />
<Breadcrumbs :breadcrumbs="breadcrumbs" /> <span v-else class="truncate font-semibold">KanbanGit</span>
</template> </div>
<div class="flex shrink-0 items-center gap-1 sm:gap-2">
<Button
v-if="createAction"
as-child
:aria-label="createAction.title"
class="px-3"
>
<Link :href="createAction.href"
><Plus aria-hidden="true" /><span
class="hidden sm:inline"
>{{ createAction.title }}</span
></Link
>
</Button>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="icon"
:aria-label="t('appearance.label')"
><component :is="themeIcon" aria-hidden="true"
/></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{{
t('appearance.label')
}}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
:model-value="appearance"
@update:model-value="
(value) =>
updateAppearance(normalizeAppearance(value))
"
>
<DropdownMenuRadioItem value="light"
><Sun aria-hidden="true" />{{
t('appearance.light')
}}</DropdownMenuRadioItem
>
<DropdownMenuRadioItem value="dark"
><Moon aria-hidden="true" />{{
t('appearance.dark')
}}</DropdownMenuRadioItem
>
<DropdownMenuRadioItem value="system"
><Monitor aria-hidden="true" />{{
t('appearance.system')
}}</DropdownMenuRadioItem
>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="icon"
:aria-label="
t('navigation.userMenu', {
name: page.props.auth.user.name,
})
"
data-test="sidebar-menu-button"
>
<UserAvatar :user="page.props.auth.user" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="min-w-60">
<UserMenuContent :user="page.props.auth.user" />
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</header> </header>
</template> </template>
+15 -9
View File
@@ -1,33 +1,39 @@
<script setup lang="ts"> <script setup lang="ts">
import { Monitor, Moon, Sun } from 'lucide-vue-next'; import { Monitor, Moon, Sun } from 'lucide-vue-next';
import { useAppearance } from '@/composables/useAppearance'; import { useAppearance } from '@/composables/useAppearance';
import { useLocalization } from '@/composables/useLocalization';
const { appearance, updateAppearance } = useAppearance(); const { appearance, updateAppearance } = useAppearance();
const { t } = useLocalization();
const tabs = [ const tabs = [
{ value: 'light', Icon: Sun, label: 'Light' }, { value: 'light', Icon: Sun, label: 'appearance.light' },
{ value: 'dark', Icon: Moon, label: 'Dark' }, { value: 'dark', Icon: Moon, label: 'appearance.dark' },
{ value: 'system', Icon: Monitor, label: 'System' }, { value: 'system', Icon: Monitor, label: 'appearance.system' },
] as const; ] as const;
</script> </script>
<template> <template>
<div <div
class="inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800" role="group"
:aria-label="t('appearance.label')"
class="border-border bg-muted inline-flex max-w-full gap-1 rounded-lg border p-1"
> >
<button <button
v-for="{ value, Icon, label } in tabs" v-for="{ value, Icon, label } in tabs"
:key="value" :key="value"
type="button"
:aria-pressed="appearance === value"
@click="updateAppearance(value)" @click="updateAppearance(value)"
:class="[ :class="[
'flex items-center rounded-md px-3.5 py-1.5 transition-colors', 'min-h-control text-body focus-visible:outline-ring flex min-w-0 items-center justify-center gap-1.5 rounded-md px-3 font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
appearance === value appearance === value
? 'shadow-xs bg-white dark:bg-neutral-700 dark:text-neutral-100' ? 'bg-primary text-primary-foreground'
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60', : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
]" ]"
> >
<component :is="Icon" class="-ml-1 h-4 w-4" /> <component :is="Icon" class="size-4 shrink-0" aria-hidden="true" />
<span class="ml-1.5 text-sm">{{ label }}</span> <span>{{ t(label) }}</span>
</button> </button>
</div> </div>
</template> </template>
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { computed } from 'vue';
import FormFeedback from '@/components/FormFeedback.vue';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import type { FormFeedbackState } from '@/lib/form-feedback';
import { login } from '@/routes/index';
const props = defineProps<{
state: FormFeedbackState;
responseStatus?: number | null;
loadingTitle?: string;
successTitle?: string;
}>();
defineEmits<{ retry: [] }>();
const { t } = useLocalization();
const expired = computed(
() => props.state === 'server' && props.responseStatus === 419,
);
const limited = computed(
() => props.state === 'server' && props.responseStatus === 429,
);
const title = computed(() => {
if (expired.value) {
return t('auth.feedback.expiredTitle');
}
if (limited.value) {
return t('auth.feedback.limitedTitle');
}
if (props.state === 'loading') {
return props.loadingTitle ?? t('auth.feedback.pending');
}
if (props.state === 'success') {
return props.successTitle ?? t('auth.feedback.completed');
}
if (props.state === 'validation') {
return t('auth.feedback.validationTitle');
}
return undefined;
});
const description = computed(() => {
if (expired.value) {
return t('auth.feedback.expiredDescription');
}
if (limited.value) {
return t('auth.feedback.limitedDescription');
}
if (props.state === 'validation') {
return t('auth.feedback.validationDescription');
}
if (props.state === 'network') {
return t('auth.feedback.networkDescription');
}
if (props.state === 'server') {
return t('auth.feedback.serverDescription');
}
if (props.state === 'cancelled') {
return t('auth.feedback.cancelledDescription');
}
return undefined;
});
</script>
<template>
<div class="gap-card grid" v-show="state !== 'idle'">
<FormFeedback
:state="state"
:title="title"
:description="description"
:retryable="!expired"
@retry="$emit('retry')"
/>
<Button v-if="expired" as-child variant="outline">
<!-- A fresh document renews the session/CSRF state and drops in-memory secrets. -->
<a :href="login.url()">{{ t('auth.backToLogin') }}</a>
</Button>
</div>
</template>
@@ -0,0 +1,209 @@
<script setup lang="ts">
import { Form } from '@inertiajs/vue3';
import { ChevronDown, ChevronRight } from 'lucide-vue-next';
import { move } from '@/actions/App/Http/Controllers/ProjectBoardController';
import InputError from '@/components/InputError.vue';
import { TicketCard } from '@/components/tickets';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import { ticketStatusLabel } from '@/lib/tickets';
import type {
BacklogTicketTreeEmits,
BacklogTicketTreeProps,
} from '@/types/backlog';
import type { TicketModel } from '@/types/models/ticket-model';
const props = defineProps<BacklogTicketTreeProps>();
const emit = defineEmits<BacklogTicketTreeEmits>();
const { t } = useLocalization();
const allowedTargetStatuses = (ticket: TicketModel) =>
(ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(status) => status.id !== ticket.ticket_status_id,
);
</script>
<template>
<ul class="grid min-w-0 gap-3">
<li v-for="node in nodes" :key="node.ticket.id" class="min-w-0">
<div class="grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] gap-2">
<div class="flex justify-center pt-1">
<button
v-if="node.children.length > 0"
type="button"
class="border-border bg-card text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/35 flex size-10 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2"
:aria-expanded="!collapsedTicketIds.has(node.ticket.id)"
:aria-controls="`backlog-children-${node.ticket.id}`"
:aria-label="
t(
collapsedTicketIds.has(node.ticket.id)
? 'backlog.tree.expand'
: 'backlog.tree.collapse',
{ key: node.ticket.ticket_key },
)
"
@click="emit('toggle', node.ticket.id)"
>
<ChevronRight
v-if="collapsedTicketIds.has(node.ticket.id)"
class="size-4"
aria-hidden="true"
/>
<ChevronDown v-else class="size-4" aria-hidden="true" />
</button>
<span
v-else
class="bg-border mt-4 size-1.5 rounded-full"
aria-hidden="true"
/>
</div>
<div class="grid min-w-0 gap-2">
<Badge
v-if="!node.matchesFilters"
variant="secondary"
class="w-fit"
>
{{ t('backlog.filters.parentContext') }}
</Badge>
<TicketCard
:project-id="projectId"
:ticket="node.ticket"
layout="list"
:class="{ 'opacity-80': !node.matchesFilters }"
>
<template v-if="canEditTickets" #actions>
<Form
v-bind="
move.form({
project: projectId,
ticket: node.ticket.id,
})
"
v-slot="{ processing, errors }"
:error-bag="`backlog-ticket-${node.ticket.id}`"
:options="{ preserveScroll: true }"
:on-before="() => pendingTicketId === null"
class="grid min-w-0 gap-2"
@start="emit('moveStart', node.ticket.id)"
@finish="emit('moveFinish', node.ticket.id)"
>
<input
type="hidden"
name="redirect_to"
value="backlog"
/>
<label
:for="`backlog-ticket-status-${node.ticket.id}`"
class="text-caption text-muted-foreground font-semibold"
>
{{
t('backlog.moveTicket', {
key: node.ticket.ticket_key,
})
}}
</label>
<select
:id="`backlog-ticket-status-${node.ticket.id}`"
name="ticket_status_id"
:disabled="
pendingTicketId !== null ||
processing ||
allowedTargetStatuses(node.ticket)
.length === 0
"
:aria-invalid="
!!errors.ticket_status_id || undefined
"
:aria-describedby="
errors.ticket_status_id
? `backlog-ticket-status-error-${node.ticket.id}`
: undefined
"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-if="
allowedTargetStatuses(node.ticket)
.length === 0
"
value=""
>
{{ t('board.noMoves') }}
</option>
<option
v-for="target in allowedTargetStatuses(
node.ticket,
)"
:key="target.id"
:value="target.id"
>
{{
ticketStatusLabel(
target,
t('tickets.noStatus'),
)
}}
</option>
</select>
<InputError
:id="`backlog-ticket-status-error-${node.ticket.id}`"
:message="errors.ticket_status_id"
/>
<Button
type="submit"
variant="outline"
:disabled="
pendingTicketId !== null ||
processing ||
allowedTargetStatuses(node.ticket)
.length === 0
"
>
{{
t(
processing
? 'board.updatingStatus'
: 'board.updateStatus',
)
}}
</Button>
<p
v-if="
allowedTargetStatuses(node.ticket)
.length === 0
"
class="text-caption text-muted-foreground"
>
{{ t('board.noMovesDescription') }}
</p>
</Form>
</template>
</TicketCard>
</div>
</div>
<div
v-if="
node.children.length > 0 &&
!collapsedTicketIds.has(node.ticket.id)
"
:id="`backlog-children-${node.ticket.id}`"
class="border-border ml-[1.375rem] mt-3 border-l pl-2 sm:pl-4"
>
<BacklogTicketTree
:nodes="node.children"
:project-id="projectId"
:can-edit-tickets="canEditTickets"
:collapsed-ticket-ids="collapsedTicketIds"
:pending-ticket-id="pendingTicketId"
@toggle="emit('toggle', $event)"
@move-start="emit('moveStart', $event)"
@move-finish="emit('moveFinish', $event)"
/>
</div>
</li>
</ul>
</template>
+25 -6
View File
@@ -9,29 +9,48 @@ import {
BreadcrumbSeparator, BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'; } from '@/components/ui/breadcrumb';
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types'; import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
import { useLocalization } from '@/composables/useLocalization';
type Props = { type Props = {
breadcrumbs: BreadcrumbItemType[]; breadcrumbs: BreadcrumbItemType[];
}; };
defineProps<Props>(); defineProps<Props>();
const { t } = useLocalization();
</script> </script>
<template> <template>
<Breadcrumb> <Breadcrumb class="min-w-0" :aria-label="t('navigation.breadcrumbs')">
<BreadcrumbList> <BreadcrumbList class="flex-nowrap">
<template v-for="(item, index) in breadcrumbs" :key="index"> <template v-for="(item, index) in breadcrumbs" :key="index">
<BreadcrumbItem> <BreadcrumbItem
:class="[
'min-w-0',
index !== breadcrumbs.length - 1
? 'hidden md:inline-flex'
: '',
]"
>
<template v-if="index === breadcrumbs.length - 1"> <template v-if="index === breadcrumbs.length - 1">
<BreadcrumbPage>{{ item.title }}</BreadcrumbPage> <BreadcrumbPage class="truncate" :title="item.title">{{
item.title
}}</BreadcrumbPage>
</template> </template>
<template v-else> <template v-else>
<BreadcrumbLink as-child> <BreadcrumbLink as-child>
<Link :href="item.href">{{ item.title }}</Link> <Link
:href="item.href"
class="max-w-40 truncate"
:title="item.title"
>{{ item.title }}</Link
>
</BreadcrumbLink> </BreadcrumbLink>
</template> </template>
</BreadcrumbItem> </BreadcrumbItem>
<BreadcrumbSeparator v-if="index !== breadcrumbs.length - 1" /> <BreadcrumbSeparator
v-if="index !== breadcrumbs.length - 1"
class="hidden md:block"
/>
</template> </template>
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { CircleAlert, CircleCheck, Inbox, LoaderCircle } from 'lucide-vue-next';
import { computed } from 'vue';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
const props = withDefaults(
defineProps<{
state: 'empty' | 'loading' | 'error' | 'success';
title?: string;
description?: string;
retryable?: boolean;
}>(),
{ retryable: false },
);
defineEmits<{ retry: [] }>();
const { t } = useLocalization();
const icons = {
empty: Inbox,
loading: LoaderCircle,
error: CircleAlert,
success: CircleCheck,
};
const defaultTitles = {
empty: 'feedback.empty',
loading: 'common.loading',
error: 'common.error',
success: 'feedback.saved',
} as const;
const heading = computed(() => props.title ?? t(defaultTitles[props.state]));
</script>
<template>
<div
:role="state === 'error' ? 'alert' : 'status'"
:aria-busy="state === 'loading' || undefined"
:data-feedback="state"
class="gap-card bg-card p-card text-body text-card-foreground flex min-w-0 items-start rounded-md border"
:class="{
'border-destructive/40': state === 'error',
'border-success/40': state === 'success',
}"
>
<component
:is="icons[state]"
aria-hidden="true"
class="mt-0.5 size-5 shrink-0"
:class="{
'text-primary animate-spin motion-reduce:animate-none':
state === 'loading',
'text-destructive': state === 'error',
'text-success-foreground': state === 'success',
'text-muted-foreground': state === 'empty',
}"
/>
<div class="gap-stack grid min-w-0 flex-1 break-words">
<p class="font-semibold">{{ heading }}</p>
<p v-if="description" class="text-muted-foreground">
{{ description }}
</p>
<div v-if="$slots.default"><slot /></div>
<Button
v-if="retryable && state === 'error'"
type="button"
variant="outline"
class="justify-self-start"
@click="$emit('retry')"
>{{ t('common.retry') }}</Button
>
</div>
</div>
</template>
+76
View File
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { computed, nextTick, useTemplateRef, watch } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { useLocalization } from '@/composables/useLocalization';
import type { FormFeedbackState } from '@/lib/form-feedback';
const props = withDefaults(
defineProps<{
state: FormFeedbackState;
title?: string;
description?: string;
retryable?: boolean;
}>(),
{ retryable: true },
);
defineEmits<{ retry: [] }>();
const { t } = useLocalization();
const feedback = useTemplateRef<HTMLDivElement>('feedback');
watch(
() => props.state,
async (state) => {
if (['validation', 'network', 'server', 'cancelled'].includes(state)) {
await nextTick();
if (props.state === state) {
feedback.value?.focus();
}
}
},
);
const titles = {
idle: 'feedback.saved',
loading: 'forms.saving',
success: 'feedback.saved',
validation: 'forms.validationTitle',
network: 'forms.networkTitle',
server: 'forms.serverTitle',
cancelled: 'forms.cancelledTitle',
} as const;
const descriptions = {
validation: 'forms.validationDescription',
network: 'forms.networkDescription',
server: 'forms.serverDescription',
cancelled: 'forms.cancelledDescription',
} as const;
const description = computed(() =>
props.state in descriptions
? t(descriptions[props.state as keyof typeof descriptions])
: undefined,
);
</script>
<template>
<div
ref="feedback"
v-show="state !== 'idle'"
:data-form-state="state"
tabindex="-1"
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
>
<FeedbackState
v-if="state !== 'idle'"
:state="
state === 'loading' || state === 'success' ? state : 'error'
"
:title="props.title ?? t(titles[state])"
:description="props.description ?? description"
:retryable="
props.retryable !== false &&
(state === 'network' ||
state === 'server' ||
state === 'cancelled')
"
@retry="$emit('retry')"
/>
</div>
</template>
+2 -2
View File
@@ -5,8 +5,8 @@ defineProps<{
</script> </script>
<template> <template>
<div v-show="message"> <div v-show="message" role="alert">
<p class="text-sm text-red-600 dark:text-red-500"> <p class="text-body text-destructive">
{{ message }} {{ message }}
</p> </p>
</div> </div>
+29 -6
View File
@@ -12,23 +12,46 @@ import type { NavItem } from '@/types';
defineProps<{ defineProps<{
items: NavItem[]; items: NavItem[];
label: string;
}>(); }>();
const { isCurrentUrl } = useCurrentUrl(); const { isCurrentUrl } = useCurrentUrl();
</script> </script>
<template> <template>
<SidebarGroup class="px-2 py-0"> <SidebarGroup class="px-3 py-2">
<SidebarGroupLabel>Platform</SidebarGroupLabel> <SidebarGroupLabel class="text-label text-muted-foreground uppercase">{{
<SidebarMenu> label
}}</SidebarGroupLabel>
<SidebarMenu role="list" :aria-label="label">
<SidebarMenuItem v-for="item in items" :key="item.title"> <SidebarMenuItem v-for="item in items" :key="item.title">
<SidebarMenuButton <SidebarMenuButton
as-child as-child
:is-active="isCurrentUrl(item.href)" :is-active="
item.isActive ??
isCurrentUrl(
item.href,
undefined,
item.match === 'section',
)
"
:tooltip="item.title" :tooltip="item.title"
class="min-h-control data-[active=true]:border-brand border-l-2 border-transparent px-3 data-[active=true]:font-semibold group-data-[collapsible=icon]:min-h-8"
> >
<Link :href="item.href"> <Link
<component :is="item.icon" /> :href="item.href"
:aria-current="
(item.isActive ??
isCurrentUrl(
item.href,
undefined,
item.match === 'section',
))
? 'page'
: undefined
"
>
<component :is="item.icon" aria-hidden="true" />
<span>{{ item.title }}</span> <span>{{ item.title }}</span>
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
+11 -4
View File
@@ -2,6 +2,7 @@
import { Eye, EyeOff } from 'lucide-vue-next'; import { Eye, EyeOff } from 'lucide-vue-next';
import { ref, useTemplateRef } from 'vue'; import { ref, useTemplateRef } from 'vue';
import type { HTMLAttributes } from 'vue'; import type { HTMLAttributes } from 'vue';
import { useLocalization } from '@/composables/useLocalization';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -9,12 +10,14 @@ defineOptions({ inheritAttrs: false });
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes['class']; class?: HTMLAttributes['class'];
disabled?: boolean;
}>(); }>();
const model = defineModel<string | number | null>(); const model = defineModel<string | number | null>();
const showPassword = ref(false); const showPassword = ref(false);
const inputRef = useTemplateRef('inputRef'); const inputRef = useTemplateRef('inputRef');
const { t } = useLocalization();
defineExpose({ defineExpose({
$el: inputRef, $el: inputRef,
@@ -28,7 +31,8 @@ defineExpose({
ref="inputRef" ref="inputRef"
v-model="model" v-model="model"
:type="showPassword ? 'text' : 'password'" :type="showPassword ? 'text' : 'password'"
:class="cn('pr-10', props.class)" :class="cn('pr-12', props.class)"
:disabled="disabled"
v-bind="$attrs" v-bind="$attrs"
/> />
<button <button
@@ -36,11 +40,14 @@ defineExpose({
@click="showPassword = !showPassword" @click="showPassword = !showPassword"
:class=" :class="
cn( cn(
'text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 focus-visible:outline-none focus-visible:ring-[3px]', 'text-muted-foreground hover:text-foreground focus-visible:ring-ring w-control absolute inset-y-0 right-0 flex items-center justify-center rounded-r-md focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
) )
" "
:aria-label="showPassword ? 'Hide password' : 'Show password'" :aria-label="
:tabindex="-1" t(showPassword ? 'auth.password.hide' : 'auth.password.show')
"
:aria-pressed="showPassword"
:disabled="disabled"
> >
<EyeOff v-if="showPassword" class="size-4" /> <EyeOff v-if="showPassword" class="size-4" />
<Eye v-else class="size-4" /> <Eye v-else class="size-4" />
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ChevronDown, Settings2 } from 'lucide-vue-next';
import { computed } from 'vue';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAppNavigation } from '@/composables/useAppNavigation';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization';
const { projectItems, projectSettingsItems } = useAppNavigation();
const { isCurrentUrl } = useCurrentUrl();
const { t } = useLocalization();
const settingsActive = computed(() =>
projectSettingsItems.value.some((item) => isCurrentUrl(item.href)),
);
</script>
<template>
<div
v-if="projectItems.length"
class="bg-card md:px-page flex min-w-0 items-center gap-2 border-b px-4"
>
<nav
:aria-label="t('navigation.project')"
class="flex min-w-0 flex-1 gap-4 overflow-x-auto"
>
<Link
v-for="item in projectItems"
:key="item.title"
:href="item.href"
:aria-current="isCurrentUrl(item.href) ? 'page' : undefined"
:class="[
'min-h-control text-body flex shrink-0 items-center gap-2 border-b-2 px-1 py-3 font-medium outline-offset-[-3px]',
isCurrentUrl(item.href)
? 'border-brand text-primary'
: 'text-muted-foreground hover:border-border hover:text-foreground border-transparent',
]"
>
<component
:is="item.icon"
class="size-4"
aria-hidden="true"
/>{{ item.title }}
</Link>
</nav>
<DropdownMenu v-if="projectSettingsItems.length">
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="sm"
:aria-label="t('navigation.projectSettings')"
:class="{
'bg-accent text-accent-foreground': settingsActive,
}"
>
<Settings2 aria-hidden="true" /><span
class="hidden sm:inline"
>{{ t('navigation.projectSettings') }}</span
><ChevronDown class="hidden sm:block" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
v-for="item in projectSettingsItems"
:key="item.title"
as-child
>
<Link
:href="item.href"
:aria-current="
isCurrentUrl(item.href) ? 'page' : undefined
"
:class="{ 'bg-accent': isCurrentUrl(item.href) }"
>
<component :is="item.icon" aria-hidden="true" />{{
item.title
}}
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
+75 -27
View File
@@ -1,45 +1,93 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link } from '@inertiajs/vue3'; import { Link } from '@inertiajs/vue3';
import { ChevronDown, ChevronRight } from 'lucide-vue-next';
import { reactive } from 'vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { useLocalization } from '@/composables/useLocalization';
import { show as showTicket } from '@/routes/projects/tickets'; import { show as showTicket } from '@/routes/projects/tickets';
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model'; import type { TicketTreeListProps } from '@/types/ticket-tree';
defineProps<{ defineProps<TicketTreeListProps>();
nodes: TicketTreeNodeModel[];
projectId: number; const { t } = useLocalization();
}>(); const collapsedTicketIds = reactive(new Set<number>());
function toggleTicket(ticketId: number): void {
if (collapsedTicketIds.has(ticketId)) {
collapsedTicketIds.delete(ticketId);
} else {
collapsedTicketIds.add(ticketId);
}
}
</script> </script>
<template> <template>
<ul class="space-y-3"> <ul class="grid min-w-0 gap-3">
<li v-for="node in nodes" :key="node.id"> <li v-for="node in nodes" :key="node.id" class="min-w-0">
<div class="border-border rounded-xl border px-4 py-4"> <div class="grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] gap-2">
<div class="flex flex-wrap items-center gap-2"> <div class="flex justify-center pt-1">
<Link <button
:href=" v-if="node.children.length > 0"
showTicket({ type="button"
project: projectId, class="border-border bg-card text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/35 flex size-10 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2"
ticket: node.id, :aria-expanded="!collapsedTicketIds.has(node.id)"
}) :aria-controls="`ticket-tree-children-${node.id}`"
:aria-label="
t(
collapsedTicketIds.has(node.id)
? 'tickets.tree.expand'
: 'tickets.tree.collapse',
{ key: node.ticket_key },
)
" "
class="text-foreground hover:text-primary font-medium transition" @click="toggleTicket(node.id)"
> >
{{ node.ticket_key }} <ChevronRight
</Link> v-if="collapsedTicketIds.has(node.id)"
class="size-4"
<Badge v-if="node.ticket_type_name" variant="secondary"> aria-hidden="true"
{{ node.ticket_type_name }} />
</Badge> <ChevronDown v-else class="size-4" aria-hidden="true" />
</button>
<span
v-else
class="bg-border mt-4 size-1.5 rounded-full"
aria-hidden="true"
/>
</div> </div>
<div class="text-foreground mt-2 text-sm"> <Link
{{ node.title }} :href="
</div> showTicket({
project: projectId,
ticket: node.id,
})
"
class="bg-card text-card-foreground hover:border-primary focus-visible:ring-ring/35 gap-stack group grid min-w-0 rounded-md border p-4 transition-[border-color,box-shadow] focus-visible:outline-none focus-visible:ring-2"
>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<Badge variant="outline">
{{ node.ticket_key }}
</Badge>
<Badge v-if="node.ticket_type_name" variant="secondary">
{{ node.ticket_type_name }}
</Badge>
</div>
<span
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
>
{{ node.title }}
</span>
</Link>
</div> </div>
<div <div
v-if="node.children.length > 0" v-if="
class="border-border mt-3 border-l pl-4" node.children.length > 0 && !collapsedTicketIds.has(node.id)
"
:id="`ticket-tree-children-${node.id}`"
class="border-border ml-[1.375rem] mt-3 border-l pl-2 sm:pl-4"
> >
<TicketTreeList <TicketTreeList
:nodes="node.children" :nodes="node.children"
+12 -23
View File
@@ -1,33 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import { useInitials } from '@/composables/useInitials';
import type { User } from '@/types';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue'; import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import type { User } from '@/types';
type Props = { withDefaults(defineProps<{ user: User; showEmail?: boolean }>(), {
user: User;
showEmail?: boolean;
};
const props = withDefaults(defineProps<Props>(), {
showEmail: false, showEmail: false,
}); });
const { getInitials } = useInitials();
// Compute whether we should show the avatar image
const showAvatar = computed(
() => props.user.avatar && props.user.avatar !== '',
);
</script> </script>
<template> <template>
<UserAvatar /> <div class="flex min-w-0 items-center gap-2">
<UserAvatar :user="user" />
<div class="grid flex-1 text-left text-sm leading-tight"> <div class="grid min-w-0 flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{{ user.name }}</span> <span class="truncate font-medium">{{ user.name }}</span>
<span v-if="showEmail" class="text-muted-foreground truncate text-xs">{{ <span
user.email v-if="showEmail"
}}</span> class="text-muted-foreground truncate text-xs"
>{{ user.email }}</span
>
</div>
</div> </div>
</template> </template>
+4 -2
View File
@@ -11,6 +11,7 @@ import UserInfo from '@/components/UserInfo.vue';
import { logout } from '@/routes/index'; import { logout } from '@/routes/index';
import { edit } from '@/routes/profile/index'; import { edit } from '@/routes/profile/index';
import type { User } from '@/types'; import type { User } from '@/types';
import { useLocalization } from '@/composables/useLocalization';
type Props = { type Props = {
user: User; user: User;
@@ -21,6 +22,7 @@ const handleLogout = () => {
}; };
defineProps<Props>(); defineProps<Props>();
const { t } = useLocalization();
</script> </script>
<template> <template>
@@ -34,7 +36,7 @@ defineProps<Props>();
<DropdownMenuItem :as-child="true"> <DropdownMenuItem :as-child="true">
<Link class="block w-full cursor-pointer" :href="edit()" prefetch> <Link class="block w-full cursor-pointer" :href="edit()" prefetch>
<Settings class="mr-2 h-4 w-4" /> <Settings class="mr-2 h-4 w-4" />
Settings {{ t('navigation.settings') }}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
@@ -48,7 +50,7 @@ defineProps<Props>();
data-test="logout-button" data-test="logout-button"
> >
<LogOut class="mr-2 h-4 w-4" /> <LogOut class="mr-2 h-4 w-4" />
Log out {{ t('navigation.logout') }}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
</template> </template>
@@ -0,0 +1,107 @@
<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>(), {
layout: 'card',
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',
layout === 'card' ? 'shadow-sm' : '',
ticket.is_blocked ? 'border-warning' : 'border-border',
]"
>
<div
:class="
layout === 'list'
? '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 p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2',
layout === 'list'
? 'rounded-t-md lg:rounded-l-md lg:rounded-tr-none'
: 'rounded-t-md',
]"
@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',
layout === 'list'
? 'lg:flex lg:w-80 lg:items-center lg:border-l lg:border-t-0'
: '',
]"
>
<slot name="actions" />
</div>
</div>
</article>
</template>
@@ -0,0 +1,89 @@
<script setup lang="ts">
import InputError from '@/components/InputError.vue';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
withDefaults(
defineProps<{
id: string;
label: string;
kind?: 'text' | 'number' | 'textarea' | 'select';
options?: { value: string | number; label: string }[];
error?: string;
hint?: string;
disabled?: boolean;
required?: boolean;
autofocus?: boolean;
placeholder?: string;
rows?: number;
min?: number;
step?: string;
}>(),
{ kind: 'text', rows: 5 },
);
const model = defineModel<string | number | null>({ required: true });
const controlClass =
'min-h-control border-input bg-card focus-visible:border-ring focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 text-base shadow-xs outline-none transition-[border-color,box-shadow] hover:border-primary/45 focus-visible:ring-2 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25 md:text-body';
</script>
<template>
<div class="grid min-w-0 content-start gap-2">
<Label :for="id">{{ label }}</Label>
<select
v-if="kind === 'select'"
:id="id"
v-model="model"
:name="id"
:disabled="disabled"
:required="required"
:aria-invalid="Boolean(error)"
:aria-describedby="`${id}-hint ${id}-error`"
:class="controlClass"
>
<option
v-for="option in options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<textarea
v-else-if="kind === 'textarea'"
:id="id"
v-model="model"
:name="id"
:rows="rows"
:disabled="disabled"
:required="required"
:placeholder="placeholder"
:aria-invalid="Boolean(error)"
:aria-describedby="`${id}-hint ${id}-error`"
:class="controlClass"
/>
<Input
v-else
:id="id"
v-model="model"
:name="id"
:type="kind"
:disabled="disabled"
:required="required"
:autofocus="autofocus"
:placeholder="placeholder"
:min="min"
:step="step"
:maxlength="kind === 'text' ? 255 : undefined"
:invalid="Boolean(error)"
:aria-describedby="`${id}-hint ${id}-error`"
/>
<p
:id="`${id}-hint`"
class="text-muted-foreground text-xs empty:hidden"
>
{{ hint }}
</p>
<InputError :id="`${id}-error`" :message="error" />
</div>
</template>
@@ -0,0 +1,157 @@
<script setup lang="ts">
import { computed } from 'vue';
import TicketFormField from '@/components/tickets/TicketFormField.vue';
import { useLocalization } from '@/composables/useLocalization';
import { ticketStatusLabel } from '@/lib/tickets';
import type { ProjectTicketCreatePageProps } from '@/types';
const props = defineProps<
Pick<
ProjectTicketCreatePageProps,
| 'project'
| 'ticketTypes'
| 'ticketPriorities'
| 'users'
| 'parentTicketOptions'
> & {
errors: Record<string, string>;
disabled?: boolean;
autofocus?: boolean;
ticketStatusOptions?: ProjectTicketCreatePageProps['ticketStatusOptions'];
}
>();
const title = defineModel<string>('title', { required: true });
const description = defineModel<string>('description', { required: true });
const type = defineModel<string | number>('type', { required: true });
const priority = defineModel<string | number>('priority', { required: true });
const assignee = defineModel<string | number>('assignee', { required: true });
const parent = defineModel<string | number>('parent', { required: true });
const points = defineModel<string | number | null>('points', {
required: true,
});
const status = defineModel<string | number>('status');
const { t } = useLocalization();
const typeOptions = computed(() =>
props.ticketTypes.map((item) => ({ value: item.id, label: item.name })),
);
const priorityOptions = computed(() =>
props.ticketPriorities.map((item) => ({
value: item.id,
label: item.name,
})),
);
const userOptions = computed(() => [
{ value: '', label: t('tickets.unassigned') },
...props.users.map((item) => ({ value: item.id, label: item.name })),
]);
const parentOptions = computed(() => [
{ value: '', label: t('ticketDetail.noParent') },
...props.parentTicketOptions.map((item) => ({
value: item.id,
label: `${item.ticket_key} - ${item.title}`,
})),
]);
const statusOptions = computed(
() =>
props.ticketStatusOptions?.map((item) => ({
value: item.id,
label: ticketStatusLabel(item, t('ticketDetail.notSet')),
})) ?? [],
);
</script>
<template>
<div class="grid min-w-0 gap-6">
<TicketFormField
id="title"
v-model="title"
:label="t('ticketDetail.title')"
:error="errors.title"
:disabled="disabled"
:autofocus="autofocus"
:placeholder="t('ticketForm.titlePlaceholder')"
required
/>
<TicketFormField
id="description"
v-model="description"
kind="textarea"
:label="t('ticketDetail.description')"
:error="errors.description"
:disabled="disabled"
:placeholder="t('ticketForm.descriptionPlaceholder')"
/>
<slot name="content" />
<fieldset
class="bg-muted/20 grid min-w-0 gap-5 rounded-xl border p-4 sm:p-5"
:disabled="disabled"
>
<legend class="px-2 text-sm font-semibold">
{{ t('ticketDetail.metadata') }}
</legend>
<div class="grid min-w-0 gap-5 sm:grid-cols-2">
<TicketFormField
id="ticket_type_id"
v-model="type"
kind="select"
:label="t('ticketDetail.type')"
:options="typeOptions"
:error="errors.ticket_type_id"
:disabled="disabled"
required
/>
<TicketFormField
id="ticket_priority_id"
v-model="priority"
kind="select"
:label="t('ticketDetail.priority')"
:options="priorityOptions"
:error="errors.ticket_priority_id"
:disabled="disabled"
required
/>
<TicketFormField
v-if="ticketStatusOptions && status !== undefined"
id="ticket_status_id"
v-model="status"
kind="select"
:label="t('ticketDetail.status')"
:options="statusOptions"
:error="errors.ticket_status_id"
:disabled="disabled"
required
/>
<TicketFormField
id="assignee_user_id"
v-model="assignee"
kind="select"
:label="t('ticketDetail.assignee')"
:options="userOptions"
:error="errors.assignee_user_id"
:disabled="disabled"
/>
<TicketFormField
id="parent_ticket_id"
v-model="parent"
kind="select"
:label="t('ticketDetail.parent')"
:options="parentOptions"
:error="errors.parent_ticket_id"
:disabled="disabled"
/>
<TicketFormField
v-if="project.show_story_points"
id="story_points"
v-model="points"
kind="number"
:label="t('ticketDetail.storyPoints')"
:error="errors.story_points"
:disabled="disabled"
:min="0"
step="1"
/>
<slot name="metadata" />
</div>
</fieldset>
</div>
</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,613 @@
<script setup lang="ts">
import { useLocalization } from '@/composables/useLocalization';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { useForm, useRemember } from '@inertiajs/vue3';
import { computed, ref, toRef, unref } from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import InputError from '@/components/InputError.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import { Button } from '@/components/ui/button';
import TicketFormField from '@/components/tickets/TicketFormField.vue';
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import {
acceptanceCriteriaToEditorState,
buildAcceptanceCriteria,
formatChecklistLines,
normalizeAcceptanceCriteria,
syncAcceptanceCriteriaDescendantCheckedState,
} from '@/lib/acceptance-criteria';
import type { TicketDetailContentProps } from '@/types/ticket-detail';
const props = defineProps<TicketDetailContentProps>();
const { t } = useLocalization();
const canEditAuthor = computed(
() => props.canEditTicket && props.canManageProject,
);
const canViewAuthor = computed(
() => props.project.show_author || props.canManageProject,
);
const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState(
props.ticket.acceptance_criteria,
);
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
const acceptanceCriteriaDraft = useRemember(
{ checklist: acceptanceCriteriaEditorState.checklist },
`${draftKey}:checklist`,
);
const acceptanceCriteriaChecklist = toRef(
unref(acceptanceCriteriaDraft),
'checklist',
);
const savedAcceptanceCriteria = ref(
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
);
const isPersistingAcceptanceCriteriaToggle = ref(false);
const acceptanceCriteriaToggleError = ref('');
const savedFormData = () => ({
acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
),
assignee_user_id: props.ticket.assignee_user_id ?? '',
author_user_id: props.ticket.author_user_id ?? '',
blocked_comment: props.ticket.blocked_comment ?? '',
description: props.ticket.description ?? '',
field_values: props.ticketFieldDefinitions.map((ticketFieldDefinition) => {
const currentFieldValue = props.ticket.field_values?.find(
(fieldValue) =>
fieldValue.ticket_field_definition_id ===
ticketFieldDefinition.id,
);
return {
ticket_field_definition_id: ticketFieldDefinition.id,
value_number: currentFieldValue?.value_number ?? '',
value_user_id: currentFieldValue?.value_user_id ?? '',
};
}),
is_blocked: props.ticket.is_blocked,
parent_ticket_id: props.ticket.parent_ticket_id ?? '',
ticket_priority_id: props.ticket.ticket_priority_id,
ticket_type_id: props.ticket.ticket_type_id,
title: props.ticket.title,
story_points: props.ticket.story_points,
});
const form = useForm(draftKey, savedFormData());
const acceptanceCriteriaPreviewItems = computed(
() => savedAcceptanceCriteria.value,
);
const hasUnsavedAcceptanceCriteriaDraft = computed(
() =>
acceptanceCriteriaChecklist.value !==
formatChecklistLines(savedAcceptanceCriteria.value),
);
const isDirty = computed(
() =>
props.canEditTicket &&
(form.isDirty || hasUnsavedAcceptanceCriteriaDraft.value),
);
const protection = useFormProtection(form, { isDirty: () => isDirty.value });
const reset = (): void => {
if (
form.processing ||
isPersistingAcceptanceCriteriaToggle.value ||
!protection.confirmLeave()
) {
return;
}
form.reset();
form.clearErrors();
acceptanceCriteriaChecklist.value = formatChecklistLines(
savedAcceptanceCriteria.value,
);
protection.clearFeedback();
};
const acceptanceCriteriaError = computed(() => {
return (
form.errors.acceptance_criteria ??
Object.entries(form.errors).find(([key]) =>
key.startsWith('acceptance_criteria.'),
)?.[1] ??
''
);
});
const buildAcceptanceCriteriaForSubmit = () => {
const draftItems = buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
);
return draftItems.map((item, index) => ({
...item,
...(savedAcceptanceCriteria.value[index]?.id !== undefined
? { id: savedAcceptanceCriteria.value[index].id }
: {}),
}));
};
const buildSavedTicketPayload = (
acceptanceCriteria = savedAcceptanceCriteria.value,
) => ({
acceptance_criteria: acceptanceCriteria,
assignee_user_id: props.ticket.assignee_user_id,
author_user_id: props.ticket.author_user_id,
blocked_comment: props.ticket.blocked_comment,
description: props.ticket.description,
field_values: (props.ticket.field_values ?? []).map((fieldValue) => ({
ticket_field_definition_id: fieldValue.ticket_field_definition_id,
value_number: fieldValue.value_number,
value_user_id: fieldValue.value_user_id,
})),
is_blocked: props.ticket.is_blocked,
parent_ticket_id: props.ticket.parent_ticket_id,
story_points: props.ticket.story_points,
ticket_priority_id: props.ticket.ticket_priority_id,
ticket_type_id: props.ticket.ticket_type_id,
title: props.ticket.title,
});
const persistAcceptanceCriteriaToggle = async (
index: number,
checked: boolean,
): Promise<void> => {
if (
!props.canEditTicket ||
form.processing ||
isPersistingAcceptanceCriteriaToggle.value ||
hasUnsavedAcceptanceCriteriaDraft.value
) {
return;
}
const nextAcceptanceCriteria = syncAcceptanceCriteriaDescendantCheckedState(
savedAcceptanceCriteria.value,
index,
checked,
);
const previousChecklist = formatChecklistLines(
savedAcceptanceCriteria.value,
);
const nextChecklist = formatChecklistLines(nextAcceptanceCriteria);
const csrfToken = document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content');
isPersistingAcceptanceCriteriaToggle.value = true;
acceptanceCriteriaToggleError.value = '';
try {
const response = await fetch(
ProjectTicketController.update.url({
project: props.project.id,
ticket: props.ticket.id,
}),
{
method: 'PATCH',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken ?? '',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(
buildSavedTicketPayload(nextAcceptanceCriteria),
),
},
);
if (!response.ok) {
throw new Error('Checklist toggle update failed.');
}
savedAcceptanceCriteria.value = nextAcceptanceCriteria;
if (acceptanceCriteriaChecklist.value === previousChecklist) {
acceptanceCriteriaChecklist.value = nextChecklist;
form.acceptance_criteria = nextAcceptanceCriteria;
form.defaults('acceptance_criteria', nextAcceptanceCriteria);
}
} catch (_error) {
acceptanceCriteriaToggleError.value = t(
'ticketDetail.checklistSaveFailed',
);
} finally {
isPersistingAcceptanceCriteriaToggle.value = false;
}
};
const onAcceptanceCriteriaCheckboxChange = async (
index: number,
event: Event,
): Promise<void> => {
const target = event.target;
if (!(target instanceof HTMLInputElement)) {
return;
}
await persistAcceptanceCriteriaToggle(index, target.checked);
target.checked = savedAcceptanceCriteria.value[index]?.checked ?? false;
};
const submit = (): void => {
if (
!props.canEditTicket ||
form.processing ||
isPersistingAcceptanceCriteriaToggle.value
) {
return;
}
form.acceptance_criteria = buildAcceptanceCriteriaForSubmit();
protection.submit(
ProjectTicketController.update({
project: props.project.id,
ticket: props.ticket.id,
}),
{
preserveScroll: true,
onSuccess: () => {
savedAcceptanceCriteria.value = normalizeAcceptanceCriteria(
props.ticket.acceptance_criteria,
);
acceptanceCriteriaChecklist.value = formatChecklistLines(
savedAcceptanceCriteria.value,
);
Object.assign(form, savedFormData());
form.defaults();
},
},
);
};
</script>
<template>
<div class="flex min-w-0 flex-col gap-6">
<section
id="ticket-description"
aria-labelledby="ticket-description-title"
class="scroll-mt-6"
>
<Card>
<CardHeader
><h2
id="ticket-description-title"
class="text-lg font-semibold"
>
{{ t('ticketDetail.description') }}
</h2></CardHeader
>
<CardContent>
<p
class="wrap-anywhere whitespace-pre-wrap text-sm leading-7"
:class="{
'text-muted-foreground': !ticket.description,
}"
>
{{
ticket.description ||
t('ticketDetail.noDescription')
}}
</p>
</CardContent>
</Card>
</section>
<section
id="ticket-checklist"
aria-labelledby="ticket-checklist-title"
class="scroll-mt-6"
>
<Card>
<CardHeader
><h2
id="ticket-checklist-title"
class="text-lg font-semibold"
>
{{ t('ticketDetail.checklist') }}
</h2></CardHeader
>
<CardContent class="space-y-3">
<div
v-if="acceptanceCriteriaPreviewItems.length > 0"
class="border-border rounded-xl border p-4 text-sm"
>
<ul class="space-y-2">
<li
v-for="(
item, index
) in acceptanceCriteriaPreviewItems"
:key="item.id ?? index"
:style="{
marginInlineStart: `${item.indent * 0.75}rem`,
}"
class="flex min-w-0 items-start gap-2"
>
<input
:id="`acceptance-criteria-${item.id ?? index}`"
:checked="item.checked"
type="checkbox"
:disabled="
!canEditTicket ||
form.processing ||
isPersistingAcceptanceCriteriaToggle ||
hasUnsavedAcceptanceCriteriaDraft
"
class="border-input text-primary focus:ring-ring mt-3.5 size-4 shrink-0 rounded"
@change="
onAcceptanceCriteriaCheckboxChange(
index,
$event,
)
"
/>
<label
:for="`acceptance-criteria-${item.id ?? index}`"
class="min-h-control wrap-anywhere flex min-w-0 flex-1 items-center py-2"
>
{{ item.text }}
</label>
</li>
</ul>
</div>
<p v-else class="text-muted-foreground text-sm">
{{ t('ticketDetail.noChecklist') }}
</p>
<p
v-if="hasUnsavedAcceptanceCriteriaDraft"
class="text-muted-foreground text-sm"
>
{{ t('ticketDetail.saveChecklistFirst') }}
</p>
<p
v-if="acceptanceCriteriaToggleError"
role="alert"
class="text-destructive text-sm"
>
{{ acceptanceCriteriaToggleError }}
</p>
</CardContent>
</Card>
</section>
<details
v-if="canEditTicket"
id="ticket-edit"
class="bg-card group rounded-xl border shadow-sm"
>
<summary
class="min-h-control focus-visible:ring-ring cursor-pointer rounded-xl px-6 py-4 font-semibold focus-visible:outline-none focus-visible:ring-2"
>
{{ t('ticketDetail.edit') }}
</summary>
<div class="border-t p-4 sm:p-6">
<p class="text-muted-foreground mb-6 text-sm">
{{ t('ticketDetail.editDescription') }}
</p>
<form class="grid min-w-0 gap-6" @submit.prevent="submit">
<FormFeedback
:state="protection.state.value"
@retry="submit"
/>
<TicketFormFields
v-model:title="form.title"
v-model:description="form.description"
v-model:type="form.ticket_type_id"
v-model:priority="form.ticket_priority_id"
v-model:assignee="form.assignee_user_id"
v-model:parent="form.parent_ticket_id"
v-model:points="form.story_points"
:project="project"
:ticket-types="ticketTypes"
:ticket-priorities="ticketPriorities"
:users="users"
:parent-ticket-options="parentTicketOptions"
:errors="form.errors"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle
"
>
<template #content>
<TicketFormField
id="acceptance_criteria_checklist"
v-model="acceptanceCriteriaChecklist"
kind="textarea"
:label="t('ticketDetail.checklistEditor')"
:hint="t('ticketForm.checklistHelp')"
:placeholder="
t('ticketDetail.checklistPlaceholder')
"
:error="acceptanceCriteriaError"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle
"
/>
</template>
<template #metadata>
<TicketFormField
v-if="canViewAuthor"
id="author_user_id"
v-model="form.author_user_id"
kind="select"
:label="t('ticketDetail.author')"
:hint="
!canEditAuthor
? t('ticketForm.authorRestricted')
: undefined
"
:options="[
{
value: '',
label: t('ticketDetail.noAuthor'),
},
...users.map((user) => ({
value: user.id,
label: user.name,
})),
]"
:error="form.errors.author_user_id"
:disabled="!canEditAuthor || form.processing"
/>
</template>
</TicketFormFields>
<fieldset
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle
"
>
<legend class="px-2 text-sm font-semibold">
{{ t('ticketForm.blocker') }}
</legend>
<div class="min-h-control flex items-center gap-3">
<input
id="is_blocked"
v-model="form.is_blocked"
type="checkbox"
:aria-invalid="Boolean(form.errors.is_blocked)"
aria-describedby="is_blocked-error"
class="border-input text-primary focus-visible:ring-ring size-4 rounded focus-visible:ring-2"
/>
<Label for="is_blocked">{{
t('ticketDetail.blocked')
}}</Label>
</div>
<InputError
id="is_blocked-error"
:message="form.errors.is_blocked"
/>
<TicketFormField
id="blocked_comment"
v-model="form.blocked_comment"
kind="textarea"
:rows="3"
:label="t('ticketDetail.blockerComment')"
:placeholder="t('ticketDetail.blockerPlaceholder')"
:error="form.errors.blocked_comment"
:required="form.is_blocked"
/>
</fieldset>
<fieldset
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle
"
>
<legend class="px-2 text-sm font-semibold">
{{ t('ticketDetail.customFields') }}
</legend>
<p class="text-muted-foreground text-sm">
{{ t('ticketDetail.customFieldsDescription') }}
</p>
<InputError :message="form.errors.field_values" />
<p
v-if="!ticketFieldDefinitions.length"
class="text-muted-foreground text-sm"
>
{{ t('ticketDetail.noCustomFields') }}
</p>
<div v-else class="grid min-w-0 gap-5 sm:grid-cols-2">
<template
v-for="(
definition, index
) in ticketFieldDefinitions"
:key="definition.id"
>
<TicketFormField
v-if="definition.field_type === 'user'"
:id="`field-${definition.id}`"
v-model="
form.field_values[index].value_user_id
"
kind="select"
:label="definition.label"
:options="[
{
value: '',
label: t('tickets.unassigned'),
},
...users.map((user) => ({
value: user.id,
label: user.name,
})),
]"
:error="
form.errors[
`field_values.${index}.value_user_id`
] ??
form.errors[
`field_values.${index}.ticket_field_definition_id`
]
"
/>
<TicketFormField
v-else
:id="`field-${definition.id}`"
v-model="
form.field_values[index].value_number
"
kind="number"
step="0.1"
:label="definition.label"
:error="
form.errors[
`field_values.${index}.value_number`
] ??
form.errors[
`field_values.${index}.ticket_field_definition_id`
]
"
/>
</template>
</div>
</fieldset>
<div
class="flex flex-wrap items-center gap-3 border-t pt-5"
>
<Button
type="submit"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle
"
>{{
form.processing
? t('ticketForm.saving')
: t('ticketDetail.saveChanges')
}}</Button
>
<Button
type="button"
variant="outline"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle ||
!isDirty
"
@click="reset"
>{{ t('ticketForm.reset') }}</Button
>
<p role="status" class="text-muted-foreground text-sm">
{{
isDirty
? t('ticketForm.unsaved')
: t('ticketForm.saved')
}}
</p>
</div>
</form>
</div>
</details>
</div>
</template>
@@ -0,0 +1,658 @@
<script setup lang="ts">
import { useLocalization } from '@/composables/useLocalization';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Link, useForm, usePage } from '@inertiajs/vue3';
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
import FormFeedback from '@/components/FormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useFormProtection } from '@/composables/useFormProtection';
import { edit as editGitConnections } from '@/routes/git-connections';
import type { BranchNameValidationModel } from '@/types/models/branch-name-validation-model';
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
import type {
TicketDetailGitProps,
TicketBranchFormData,
} from '@/types/ticket-detail';
const props = defineProps<TicketDetailGitProps>();
const { t } = useLocalization();
const appName = String(usePage().props.name);
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
const visibleRepositoryBranches = computed(() =>
props.repositoryBranches.filter(
(repositoryBranch) =>
repositoryBranch.branches.length > 0 ||
repositoryBranch.error !== null ||
!repositoryBranch.repository_connection.has_personal_access,
),
);
const visibleRepositoryPullRequests = computed(() =>
props.repositoryPullRequests.filter(
(repositoryPullRequest) =>
repositoryPullRequest.pull_requests.length > 0 ||
repositoryPullRequest.error !== null ||
!repositoryPullRequest.repository_connection.has_personal_access,
),
);
const linkedRepositoryConnections = computed<RepositoryConnectionModel[]>(() =>
props.repositoryBranches.map(
(repositoryBranch) => repositoryBranch.repository_connection,
),
);
const canCreateBranches = computed(
() => props.canEditTicket && linkedRepositoryConnections.value.length > 0,
);
const defaultBranchRepositoryConnectionId = computed(() =>
String(
linkedRepositoryConnections.value.find(
(repositoryConnection) => repositoryConnection.has_personal_access,
)?.id ??
linkedRepositoryConnections.value[0]?.id ??
'',
),
);
const isCreateBranchDialogOpen = ref(false);
const isCheckingBranchName = ref(false);
const branchNameValidation = ref<BranchNameValidationModel | null>(null);
const branchCreationForm = useForm<TicketBranchFormData>(`${draftKey}:branch`, {
branch_name: props.suggestedBranchName,
repository_connection_id: defaultBranchRepositoryConnectionId.value,
});
const branchProtection = useFormProtection(branchCreationForm, {
isDirty: () => isCreateBranchDialogOpen.value && branchCreationForm.isDirty,
});
const updateBranchDialogOpen = (open: boolean): void => {
if (!open && !branchProtection.confirmLeave()) {
return;
}
isCreateBranchDialogOpen.value = open;
};
const selectedBranchRepositoryConnection = computed(
(): RepositoryConnectionModel | null =>
linkedRepositoryConnections.value.find(
(repositoryConnection) =>
String(repositoryConnection.id) ===
branchCreationForm.repository_connection_id,
) ?? null,
);
const selectedBranchRepositoryHasPersonalAccess = computed(
() =>
selectedBranchRepositoryConnection.value?.has_personal_access ?? false,
);
const initializeBranchCreationForm = (): void => {
if (branchCreationForm.isDirty) {
return;
}
branchCreationForm.clearErrors();
branchCreationForm.branch_name = props.suggestedBranchName;
branchCreationForm.repository_connection_id =
defaultBranchRepositoryConnectionId.value;
branchNameValidation.value = null;
isCheckingBranchName.value = false;
};
watch(
() => isCreateBranchDialogOpen.value,
(isOpen) => {
if (!isOpen) {
branchCreationForm.clearErrors();
branchNameValidation.value = null;
isCheckingBranchName.value = false;
return;
}
initializeBranchCreationForm();
},
);
watch(
[
() => isCreateBranchDialogOpen.value,
() => branchCreationForm.repository_connection_id,
() => branchCreationForm.branch_name,
],
(
[isOpen, repositoryConnectionId, branchName],
_previousValues,
onCleanup,
) => {
if (!isOpen) {
return;
}
const repositoryConnection = selectedBranchRepositoryConnection.value;
if (repositoryConnection === null || repositoryConnectionId === '') {
branchNameValidation.value = null;
isCheckingBranchName.value = false;
return;
}
if (!repositoryConnection.has_personal_access) {
branchNameValidation.value = {
is_valid: false,
message: t('ticketDetail.connectGitFirst'),
};
isCheckingBranchName.value = false;
return;
}
if (branchName.trim() === '') {
branchNameValidation.value = {
is_valid: false,
message: t('ticketDetail.branchRequired'),
};
isCheckingBranchName.value = false;
return;
}
const abortController = new AbortController();
const timeoutId = window.setTimeout(async () => {
isCheckingBranchName.value = true;
try {
const response = await fetch(
ProjectTicketBranchController.validate.url(
{
project: props.project.id,
ticket: props.ticket.id,
},
{
query: {
branch_name: branchName,
repository_connection_id:
repositoryConnectionId,
},
},
),
{
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
method: 'GET',
signal: abortController.signal,
},
);
if (!response.ok) {
throw new Error('Branch validation failed.');
}
branchNameValidation.value =
(await response.json()) as BranchNameValidationModel;
} catch (error) {
if (
error instanceof DOMException &&
error.name === 'AbortError'
) {
return;
}
branchNameValidation.value = {
is_valid: false,
message: t('ticketDetail.branchValidationFailed', {
app: appName,
}),
};
} finally {
if (!abortController.signal.aborted) {
isCheckingBranchName.value = false;
}
}
}, 300);
onCleanup(() => {
abortController.abort();
window.clearTimeout(timeoutId);
isCheckingBranchName.value = false;
});
},
{ immediate: true },
);
const submitBranchCreation = (): void => {
if (
!canCreateBranches.value ||
!selectedBranchRepositoryHasPersonalAccess.value ||
branchNameValidation.value?.is_valid !== true
) {
return;
}
branchProtection.submit(
ProjectTicketBranchController.store({
project: props.project.id,
ticket: props.ticket.id,
}),
{
onSuccess: () => {
isCreateBranchDialogOpen.value = false;
},
preserveScroll: true,
},
);
};
</script>
<template>
<section
id="ticket-git"
aria-labelledby="ticket-git-title"
class="wrap-anywhere flex min-w-0 scroll-mt-6 flex-col gap-4"
>
<h2 id="ticket-git-title" class="text-lg font-semibold">
{{ t('ticketDetail.git') }}
</h2>
<div class="grid min-w-0 gap-6">
<Card class="h-fit">
<CardHeader
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="space-y-1.5">
<CardTitle>{{ t('ticketDetail.branches') }}</CardTitle>
<CardDescription>{{
t('ticketDetail.branchesDescription')
}}</CardDescription>
</div>
<Dialog
:open="isCreateBranchDialogOpen"
@update:open="updateBranchDialogOpen"
>
<Button
v-if="canCreateBranches"
type="button"
variant="outline"
class="gap-2"
@click="isCreateBranchDialogOpen = true"
>
<GitBranchPlus class="size-4" />{{
t('ticketDetail.createBranch')
}}</Button
>
<DialogContent class="sm:max-w-lg">
<DialogHeader class="space-y-3">
<DialogTitle>{{
t('ticketDetail.createBranchTitle')
}}</DialogTitle>
<DialogDescription>{{
t('ticketDetail.createBranchDescription')
}}</DialogDescription>
</DialogHeader>
<form
class="grid gap-4"
@submit.prevent="submitBranchCreation"
>
<FormFeedback
:state="branchProtection.state.value"
@retry="submitBranchCreation"
/>
<div class="grid min-w-0 gap-2">
<Label for="repository_connection_id">{{
t('ticketDetail.repository')
}}</Label>
<select
id="repository_connection_id"
v-model="
branchCreationForm.repository_connection_id
"
:disabled="
branchCreationForm.processing
"
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-for="repositoryConnection in linkedRepositoryConnections"
:key="repositoryConnection.id"
:value="
String(repositoryConnection.id)
"
>
{{
`${repositoryConnection.repository_full_name}${repositoryConnection.has_personal_access ? '' : ' (connect personal Git access)'}`
}}
</option>
</select>
<InputError
:message="
branchCreationForm.errors
.repository_connection_id
"
/>
</div>
<div class="grid min-w-0 gap-2">
<Label for="branch_name">{{
t('ticketDetail.branchName')
}}</Label>
<Input
id="branch_name"
v-model="branchCreationForm.branch_name"
:disabled="
branchCreationForm.processing ||
!selectedBranchRepositoryHasPersonalAccess
"
/>
<InputError
:message="
branchCreationForm.errors
.branch_name
"
/>
</div>
<div
class="rounded-xl border px-4 py-3"
:class="
branchNameValidation?.is_valid
? 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100'
: 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100'
"
>
<div class="flex items-start gap-3">
<CheckCircle2
v-if="
branchNameValidation?.is_valid
"
class="mt-0.5 size-4 shrink-0"
/>
<XCircle
v-else-if="
branchNameValidation !== null
"
class="mt-0.5 size-4 shrink-0"
/>
<p class="text-sm">
{{
isCheckingBranchName
? t(
'ticketDetail.checkingBranch',
)
: (branchNameValidation?.message ??
t(
'ticketDetail.selectRepository',
))
}}
</p>
</div>
</div>
<div
v-if="
!selectedBranchRepositoryHasPersonalAccess &&
selectedBranchRepositoryConnection !==
null
"
class="border-border rounded-xl border border-dashed px-4 py-4"
>
<p class="text-muted-foreground text-sm">
{{ t('ticketDetail.branchAccess') }}
</p>
<Button
as-child
variant="outline"
size="sm"
class="mt-3"
>
<Link :href="editGitConnections()">{{
t('ticketDetail.openGitConnections')
}}</Link>
</Button>
</div>
<DialogFooter class="gap-2">
<Button
type="button"
variant="secondary"
@click="updateBranchDialogOpen(false)"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
:disabled="
branchCreationForm.processing ||
isCheckingBranchName ||
branchNameValidation?.is_valid !==
true
"
>{{
t('ticketDetail.createNow')
}}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent class="space-y-4">
<FeedbackState
v-if="visibleRepositoryBranches.length === 0"
state="empty"
:title="t('tickets.noBranches')"
:description="t('tickets.noBranchesDescription')"
/>
<div v-else class="space-y-3">
<div
v-for="repositoryBranch in visibleRepositoryBranches"
:key="repositoryBranch.repository_connection.id"
class="border-border rounded-xl border px-4 py-4"
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="secondary">
{{
repositoryBranch.repository_connection
.provider_label
}}
</Badge>
<span class="font-medium">
{{
repositoryBranch.repository_connection
.repository_full_name
}}
</span>
</div>
<p class="text-muted-foreground mt-2 text-sm">
{{
repositoryBranch.repository_connection
.base_url
}}
</p>
<div
v-if="
!repositoryBranch.repository_connection
.has_personal_access
"
class="mt-3 space-y-3"
>
<p class="text-muted-foreground text-sm">
{{ t('ticketDetail.inspectBranches') }}
</p>
<Button as-child variant="outline" size="sm">
<Link :href="editGitConnections()">{{
t('ticketDetail.openGitConnections')
}}</Link>
</Button>
</div>
<div
v-else-if="repositoryBranch.error"
class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
>
{{ repositoryBranch.error }}
</div>
<div v-else class="mt-3 flex flex-wrap gap-2">
<Badge
v-for="branch in repositoryBranch.branches"
:key="branch.name"
variant="outline"
class="px-3 py-1"
>
{{ branch.name }}
</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
<Card class="h-fit">
<CardHeader>
<CardTitle>{{ t('ticketDetail.pullRequests') }}</CardTitle>
<CardDescription>{{
t('ticketDetail.pullRequestsDescription')
}}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<FeedbackState
v-if="visibleRepositoryPullRequests.length === 0"
state="empty"
:title="t('tickets.noPullRequests')"
:description="t('tickets.noPullRequestsDescription')"
/>
<div v-else class="space-y-3">
<div
v-for="repositoryPullRequest in visibleRepositoryPullRequests"
:key="
repositoryPullRequest.repository_connection.id
"
class="border-border rounded-xl border px-4 py-4"
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="secondary">
{{
repositoryPullRequest
.repository_connection
.provider_label
}}
</Badge>
<span class="font-medium">
{{
repositoryPullRequest
.repository_connection
.repository_full_name
}}
</span>
</div>
<p class="text-muted-foreground mt-2 text-sm">
{{
repositoryPullRequest.repository_connection
.base_url
}}
</p>
<div
v-if="
!repositoryPullRequest.repository_connection
.has_personal_access
"
class="mt-3 space-y-3"
>
<p class="text-muted-foreground text-sm">
{{ t('ticketDetail.inspectPullRequests') }}
</p>
<Button as-child variant="outline" size="sm">
<Link :href="editGitConnections()">{{
t('ticketDetail.openGitConnections')
}}</Link>
</Button>
</div>
<div
v-else-if="repositoryPullRequest.error"
class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
>
{{ repositoryPullRequest.error }}
</div>
<div v-else class="mt-3 space-y-3">
<div
v-for="pullRequest in repositoryPullRequest.pull_requests"
:key="pullRequest.html_url"
class="border-border/70 rounded-xl border px-4 py-3"
>
<div
class="flex flex-wrap items-center gap-2"
>
<Badge variant="outline">
#{{ pullRequest.number }}
</Badge>
<Badge variant="secondary">
{{ pullRequest.status }}
</Badge>
<a
:href="pullRequest.html_url"
target="_blank"
rel="noreferrer"
class="text-foreground hover:text-primary font-medium transition"
>
{{ pullRequest.title }}
</a>
</div>
<p
class="text-muted-foreground mt-2 text-sm"
>
{{
t('ticketDetail.sourceBranch', {
name: pullRequest.source_branch,
})
}}
</p>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</section>
</template>
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ArrowLeft, Columns3, ListTree } from 'lucide-vue-next';
import TicketMetadata from '@/components/tickets/TicketMetadata.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as boardIndex } from '@/routes/projects/board';
import { show } from '@/routes/projects';
import type { TicketDetailHeaderProps } from '@/types/ticket-detail';
defineProps<TicketDetailHeaderProps>();
const { t } = useLocalization();
const sections = [
'description',
'checklist',
'metadata',
'relationships',
'history',
'git',
] as const;
</script>
<template>
<header class="flex min-w-0 flex-col gap-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<Button as-child variant="ghost" class="max-w-full"
><Link :href="show(project.id)"
><ArrowLeft aria-hidden="true" />{{
t('ticketDetail.backToProject')
}}</Link
></Button
>
<div class="flex flex-wrap gap-2">
<Button as-child variant="outline"
><Link :href="backlogIndex(project.id)"
><ListTree aria-hidden="true" />{{
t('ticketDetail.openBacklog')
}}</Link
></Button
>
<Button as-child variant="outline"
><Link :href="boardIndex(project.id)"
><Columns3 aria-hidden="true" />{{
t('ticketDetail.openBoard')
}}</Link
></Button
>
</div>
</div>
<div class="flex min-w-0 flex-col gap-3">
<div class="flex flex-wrap items-center gap-2">
<span class="text-primary font-mono text-sm font-semibold">{{
ticket.ticket_key
}}</span
><Badge v-if="!canEditTicket" variant="outline">{{
t('ticketDetail.readOnly')
}}</Badge>
</div>
<h1
class="wrap-anywhere text-2xl font-semibold leading-tight tracking-tight sm:text-3xl"
>
{{ ticket.title }}
</h1>
<TicketMetadata :ticket="ticket" />
</div>
<nav
:aria-label="t('ticketDetail.sections')"
class="flex flex-wrap gap-1 border-b pb-3"
>
<a
v-for="section in sections"
:key="section"
:href="`#ticket-${section}`"
class="min-h-control text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring inline-flex items-center rounded-lg px-3 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2"
>{{ t(`ticketDetail.${section}`) }}</a
>
</nav>
</header>
</template>
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { useLocalization } from '@/composables/useLocalization';
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import type { TicketDetailHistoryProps } from '@/types/ticket-detail';
defineProps<TicketDetailHistoryProps>();
const { t, date } = useLocalization();
const formatDateTime = (value: string): string =>
date(new Date(value), { dateStyle: 'medium', timeStyle: 'short' });
</script>
<template>
<section
id="ticket-history"
aria-labelledby="ticket-history-title"
class="wrap-anywhere min-w-0 scroll-mt-6"
>
<Card class="h-fit">
<CardHeader>
<h2 id="ticket-history-title" class="text-lg font-semibold">
{{ t('ticketDetail.history') }}
</h2>
<CardDescription>{{
t('ticketDetail.historyDescription')
}}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div
v-if="(ticket.worklog_entries?.length ?? 0) === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
>
{{ t('ticketDetail.noHistory') }}
</div>
<div v-else class="flex flex-col gap-4">
<div
v-for="worklogEntry in ticket.worklog_entries"
:key="worklogEntry.id"
class="border-border rounded-xl border px-4 py-4"
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="outline">
{{ worklogEntry.entry_type }}
</Badge>
<span class="text-foreground text-sm font-medium">
{{
worklogEntry.actor?.name ??
t('ticketDetail.system')
}}
</span>
</div>
<p class="text-foreground mt-3 text-sm">
{{ worklogEntry.summary }}
</p>
<p class="text-muted-foreground mt-2 text-xs">
{{ formatDateTime(worklogEntry.created_at) }}
</p>
</div>
</div>
</CardContent>
</Card>
</section>
</template>
@@ -0,0 +1,165 @@
<script setup lang="ts">
import { computed } from 'vue';
import { UserAvatar } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { useLocalization } from '@/composables/useLocalization';
import { ticketStatusLabel } from '@/lib/tickets';
import type { TicketDetailMetadataProps } from '@/types/ticket-detail';
const props = defineProps<TicketDetailMetadataProps>();
const { t, date, number } = useLocalization();
const fields = computed(() =>
props.ticketFieldDefinitions.map((definition) => {
const value = props.ticket.field_values?.find(
(field) => field.ticket_field_definition_id === definition.id,
);
return {
id: definition.id,
label: definition.label,
value:
definition.field_type === 'user'
? (value?.user_value?.name ?? t('tickets.unassigned'))
: value?.value_number != null
? number(value.value_number)
: t('ticketDetail.notSet'),
};
}),
);
</script>
<template>
<section
id="ticket-metadata"
aria-labelledby="ticket-metadata-title"
class="min-w-0 scroll-mt-6"
>
<Card class="bg-muted/30">
<CardHeader
><h2 id="ticket-metadata-title" class="text-lg font-semibold">
{{ t('ticketDetail.metadata') }}
</h2></CardHeader
>
<CardContent class="wrap-anywhere flex min-w-0 flex-col gap-6">
<dl
class="grid min-w-0 gap-5 text-sm sm:grid-cols-2 xl:grid-cols-1"
>
<div class="space-y-2">
<dt class="text-muted-foreground">
{{ t('ticketDetail.status') }}
</dt>
<dd>
{{
ticketStatusLabel(
ticket.ticket_status,
t('tickets.noStatus'),
)
}}
</dd>
</div>
<div class="space-y-2">
<dt class="text-muted-foreground">
{{ t('ticketDetail.type') }}
</dt>
<dd>{{ ticket.ticket_type.name }}</dd>
</div>
<div class="space-y-2">
<dt class="text-muted-foreground">
{{ t('ticketDetail.priority') }}
</dt>
<dd>
<Badge variant="outline">{{
ticket.ticket_priority.name
}}</Badge>
</dd>
</div>
<div class="space-y-2">
<dt class="text-muted-foreground">
{{ t('ticketDetail.assignee') }}
</dt>
<dd class="flex items-center gap-2">
<UserAvatar
v-if="ticket.assignee"
:user="ticket.assignee"
size="sm"
/>{{
ticket.assignee?.name ?? t('tickets.unassigned')
}}
</dd>
</div>
<div
v-if="project.show_author || canManageProject"
class="space-y-2"
>
<dt class="text-muted-foreground">
{{ t('ticketDetail.author') }}
</dt>
<dd class="flex items-center gap-2">
<UserAvatar
v-if="ticket.author"
:user="ticket.author"
size="sm"
/>{{
ticket.author?.name ??
t('ticketDetail.noAuthor')
}}
</dd>
</div>
<div v-if="project.show_story_points" class="space-y-2">
<dt class="text-muted-foreground">
{{ t('ticketDetail.storyPoints') }}
</dt>
<dd>
{{
ticket.story_points != null
? number(ticket.story_points)
: t('ticketDetail.notSet')
}}
</dd>
</div>
<div
v-for="field in fields"
:key="field.id"
class="space-y-2"
>
<dt class="text-muted-foreground">{{ field.label }}</dt>
<dd>{{ field.value }}</dd>
</div>
</dl>
<div
v-if="ticket.is_blocked"
class="border-warning/30 bg-warning/10 space-y-2 rounded-lg border p-4 text-sm"
>
<p class="font-semibold">{{ t('tickets.blocked') }}</p>
<p class="whitespace-pre-wrap">
{{ ticket.blocked_comment }}
</p>
</div>
<dl
class="text-muted-foreground grid gap-4 border-t pt-5 text-xs"
>
<div class="space-y-1">
<dt>{{ t('ticketDetail.created') }}</dt>
<dd>
<time :datetime="ticket.created_at">{{
date(new Date(ticket.created_at), {
dateStyle: 'medium',
timeStyle: 'short',
})
}}</time>
</dd>
</div>
<div class="space-y-1">
<dt>{{ t('ticketDetail.updated') }}</dt>
<dd>
<time :datetime="ticket.updated_at">{{
date(new Date(ticket.updated_at), {
dateStyle: 'medium',
timeStyle: 'short',
})
}}</time>
</dd>
</div>
</dl>
</CardContent>
</Card>
</section>
</template>
@@ -0,0 +1,223 @@
<script setup lang="ts">
import { useLocalization } from '@/composables/useLocalization';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Form, Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
import InputError from '@/components/InputError.vue';
import TicketTreeList from '@/components/TicketTreeList.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { show as showTicket } from '@/routes/projects/tickets';
import type { TicketDetailRelationshipsProps } from '@/types/ticket-detail';
const props = defineProps<TicketDetailRelationshipsProps>();
const { t } = useLocalization();
const currentParentTicket = computed(
() =>
props.ticket.parent_ticket ??
props.parentTicketOptions.find(
(parentTicket) => parentTicket.id === props.ticket.parent_ticket_id,
) ??
null,
);
</script>
<template>
<section
id="ticket-relationships"
aria-labelledby="ticket-relationships-title"
class="wrap-anywhere flex min-w-0 scroll-mt-6 flex-col gap-4"
>
<h2 id="ticket-relationships-title" class="text-lg font-semibold">
{{ t('ticketDetail.relationships') }}
</h2>
<Card v-if="ticket.ticket_type?.is_parent" class="h-fit">
<CardHeader>
<CardTitle>{{ t('ticketDetail.children') }}</CardTitle>
<CardDescription>{{
t('ticketDetail.childrenDescription')
}}</CardDescription>
</CardHeader>
<CardContent>
<div
v-if="childTicketTree.length === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
>
{{ t('ticketDetail.noChildren') }}
</div>
<TicketTreeList
v-else
:nodes="childTicketTree"
:project-id="project.id"
/>
</CardContent>
</Card>
<Card v-else-if="currentParentTicket !== null" class="h-fit">
<CardHeader>
<CardTitle>{{ t('ticketDetail.parent') }}</CardTitle>
<CardDescription>{{
t('ticketDetail.parentDescription')
}}</CardDescription>
</CardHeader>
<CardContent>
<Link
:href="
showTicket({
project: project.id,
ticket: currentParentTicket.id,
})
"
class="border-border hover:border-primary/60 block rounded-xl border px-4 py-4 transition"
>
<div class="font-medium">
{{ currentParentTicket.ticket_key }}
</div>
<div class="text-muted-foreground mt-1 text-sm">
{{ currentParentTicket.title }}
</div>
</Link>
</CardContent>
</Card>
<Card class="h-fit">
<CardHeader>
<CardTitle>{{ t('ticketDetail.linkedTickets') }}</CardTitle>
<CardDescription>{{
t('ticketDetail.linkedTicketsDescription')
}}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<Form
v-if="
canEditTicket &&
ticketLinkTypes.length > 0 &&
ticketLinkOptions.length > 0
"
v-bind="
ProjectTicketLinkController.store.form({
project: project.id,
ticket: ticket.id,
})
"
class="border-border grid gap-4 rounded-xl border border-dashed p-4"
v-slot="{ errors, processing }"
>
<div class="grid min-w-0 gap-2">
<Label for="ticket_link_type_id">{{
t('ticketDetail.linkType')
}}</Label>
<select
id="ticket_link_type_id"
name="ticket_link_type_id"
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="ticketLinkType in ticketLinkTypes"
:key="ticketLinkType.id"
:value="ticketLinkType.id"
>
{{ ticketLinkType.label }}
</option>
</select>
<InputError :message="errors.ticket_link_type_id" />
</div>
<div class="grid min-w-0 gap-2">
<Label for="linked_ticket_id">{{
t('ticketDetail.linkedTicket')
}}</Label>
<select
id="linked_ticket_id"
name="linked_ticket_id"
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="ticketLinkOption in ticketLinkOptions"
:key="ticketLinkOption.id"
:value="ticketLinkOption.id"
>
{{
`${ticketLinkOption.ticket_key} - ${ticketLinkOption.title}`
}}
</option>
</select>
<InputError :message="errors.linked_ticket_id" />
</div>
<Button type="submit" :disabled="processing">{{
t('ticketDetail.saveLink')
}}</Button>
</Form>
<div
v-if="linkedTickets.length === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
>
{{ t('ticketDetail.noLinks') }}
</div>
<div v-else class="space-y-3">
<div
v-for="linkedTicket in linkedTickets"
:key="linkedTicket.id"
class="border-border rounded-xl border px-4 py-4"
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="secondary">
{{ linkedTicket.ticket_link_type.label }}
</Badge>
<Link
:href="
showTicket({
project: project.id,
ticket: linkedTicket.linked_ticket.id,
})
"
class="text-foreground hover:text-primary font-medium transition"
>
{{ linkedTicket.linked_ticket.ticket_key }}
</Link>
</div>
<p class="text-muted-foreground mt-2 text-sm">
{{ linkedTicket.linked_ticket.title }}
</p>
<Form
v-if="canEditTicket"
v-bind="
ProjectTicketLinkController.destroy.form({
project: project.id,
ticket: ticket.id,
ticketLink: linkedTicket.id,
})
"
class="mt-3 flex justify-end"
v-slot="{ processing: deleting }"
>
<Button
type="submit"
variant="destructive"
size="sm"
:disabled="deleting"
>{{ t('ticketDetail.removeLink') }}</Button
>
</Form>
</div>
</div>
</CardContent>
</Card>
</section>
</template>
+2
View File
@@ -0,0 +1,2 @@
export { default as TicketCard } from './TicketCard.vue';
export { default as TicketListItem } from './TicketListItem.vue';
+29 -12
View File
@@ -1,18 +1,35 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { AvatarRootProps } from "reka-ui";
import { AvatarRoot } from "reka-ui" import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils" import type { AvatarVariants } from ".";
import { reactiveOmit } from "@vueuse/core";
import { AvatarRoot, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
import { avatarVariants } from ".";
const props = defineProps<{ const props = withDefaults(
class?: HTMLAttributes["class"] defineProps<
}>() AvatarRootProps & {
class?: HTMLAttributes["class"];
size?: AvatarVariants["size"];
shape?: AvatarVariants["shape"];
}
>(),
{ size: "default", shape: "circle" },
);
const delegatedProps = reactiveOmit(props, "class", "size", "shape");
const forwardedProps = useForwardProps(delegatedProps);
</script> </script>
<template> <template>
<AvatarRoot <AvatarRoot
data-slot="avatar" data-slot="avatar"
:class="cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', props.class)" :data-size="size"
> :data-shape="shape"
<slot /> v-bind="forwardedProps"
</AvatarRoot> :class="cn(avatarVariants({ size, shape }), props.class)"
>
<slot />
</AvatarRoot>
</template> </template>
@@ -1,21 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AvatarFallbackProps } from "reka-ui" import type { AvatarFallbackProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { AvatarFallback } from "reka-ui" import { AvatarFallback } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<AvatarFallbackProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<
AvatarFallbackProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class");
</script> </script>
<template> <template>
<AvatarFallback <AvatarFallback
data-slot="avatar-fallback" data-slot="avatar-fallback"
v-bind="delegatedProps" v-bind="delegatedProps"
:class="cn('bg-muted flex size-full items-center justify-center rounded-full', props.class)" :class="
> cn(
<slot /> 'bg-accent text-accent-foreground flex size-full items-center justify-center font-semibold',
</AvatarFallback> props.class,
)
"
>
<slot />
</AvatarFallback>
</template> </template>
@@ -1,41 +1,39 @@
<script setup lang="ts"> <script setup lang="ts">
import { getInitials } from "@/composables/useInitials"; import type { AvatarVariants } from "@/components/ui/avatar";
import { import type { UserModel } from "@/types/models/user-model";
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar/index";
import { usePage } from "@inertiajs/vue3"; import { usePage } from "@inertiajs/vue3";
import { computed, PropType } from "vue"; import { computed } from "vue";
import { UserModel } from "@/types/models/user-model"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { getInitials } from "@/composables/useInitials";
const props = defineProps({ const props = withDefaults(
user: { defineProps<{
type: Object as PropType<UserModel>, user?: UserModel;
rounded?: boolean;
size?: AvatarVariants["size"];
label?: string;
}>(),
{
rounded: true,
size: "default",
}, },
rounded: { );
type: Boolean,
default: false,
},
})
const page = usePage(); const page = usePage();
const auth = computed(() => page.props.auth); const user = computed(() => props.user ?? page.props.auth.user);
const accessibleLabel = computed(() => props.label ?? user.value.name);
const user = computed(() => props.user ?? auth.value.user) const initials = computed(() => getInitials(user.value.name));
</script> </script>
<template> <template>
<Avatar class="size-8 overflow-hidden" :class="rounded ? 'rounded-full' : 'rounded-lg'"> <Avatar :size="size" :shape="rounded ? 'circle' : 'rounded'">
<AvatarImage <AvatarImage
v-if="user.avatar" v-if="user.avatar"
:src="user.avatar" :src="user.avatar"
:alt="user.name" :alt="accessibleLabel"
/> />
<AvatarFallback <AvatarFallback :aria-label="accessibleLabel">
class="rounded-lg bg-neutral-200 font-semibold text-black dark:bg-neutral-700 dark:text-white" {{ initials }}
>
{{ getInitials(auth.user?.name) }}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
</template> </template>
+31 -3
View File
@@ -1,3 +1,31 @@
export { default as Avatar } from "./Avatar.vue" import type { VariantProps } from "class-variance-authority";
export { default as AvatarFallback } from "./AvatarFallback.vue" import { cva } from "class-variance-authority";
export { default as AvatarImage } from "./AvatarImage.vue"
export { default as Avatar } from "./Avatar.vue";
export { default as AvatarFallback } from "./AvatarFallback.vue";
export { default as AvatarImage } from "./AvatarImage.vue";
export { default as UserAvatar } from "./UserAvatar.vue";
export const avatarVariants = cva(
"border-border bg-muted relative flex shrink-0 overflow-hidden border",
{
variants: {
size: {
sm: "size-6 text-label",
default: "size-8 text-caption",
lg: "size-10 text-body",
xl: "size-16 text-title",
},
shape: {
circle: "rounded-full",
rounded: "rounded-lg",
square: "rounded-sm",
},
},
defaultVariants: {
size: "default",
shape: "circle",
},
},
);
export type AvatarVariants = VariantProps<typeof avatarVariants>;
+21 -19
View File
@@ -1,26 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PrimitiveProps } from "reka-ui" import type { PrimitiveProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import type { BadgeVariants } from "." import type { BadgeVariants } from ".";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { Primitive } from "reka-ui" import { Primitive } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { badgeVariants } from "." import { badgeVariants } from ".";
const props = defineProps<PrimitiveProps & { const props = defineProps<
variant?: BadgeVariants["variant"] PrimitiveProps & {
class?: HTMLAttributes["class"] variant?: BadgeVariants["variant"];
}>() class?: HTMLAttributes["class"];
}
>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class", "variant");
</script> </script>
<template> <template>
<Primitive <Primitive
data-slot="badge" data-slot="badge"
:class="cn(badgeVariants({ variant }), props.class)" :class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps" v-bind="delegatedProps"
> >
<slot /> <slot />
</Primitive> </Primitive>
</template> </template>
+27 -22
View File
@@ -1,26 +1,31 @@
import type { VariantProps } from "class-variance-authority" import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority" import { cva } from "class-variance-authority";
export { default as Badge } from "./Badge.vue" export { default as Badge } from "./Badge.vue";
export const badgeVariants = cva( export const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2.5 py-1 text-label whitespace-nowrap transition-[background-color,border-color,color,box-shadow] outline-none [&>svg]:pointer-events-none [&>svg]:size-3 [&>svg]:shrink-0 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
{ {
variants: { variants: {
variant: { variant: {
default: default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary: secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/80",
destructive: destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", "border-transparent bg-destructive text-destructive-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/35",
outline: outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", "border-border bg-card text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
}, success:
"border-transparent bg-success text-success-foreground [a&]:hover:brightness-95",
warning:
"border-transparent bg-warning text-warning-foreground [a&]:hover:brightness-95",
info: "border-transparent bg-info text-info-foreground [a&]:hover:brightness-95",
},
},
defaultVariants: {
variant: "default",
},
}, },
defaultVariants: { );
variant: "default", export type BadgeVariants = VariantProps<typeof badgeVariants>;
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>
+37 -19
View File
@@ -1,29 +1,47 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PrimitiveProps } from "reka-ui" import type { PrimitiveProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import type { ButtonVariants } from "." import type { ButtonVariants } from ".";
import { Primitive } from "reka-ui" import { Primitive } from "reka-ui";
import { cn } from "@/lib/utils" import { computed } from "vue";
import { buttonVariants } from "." import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
import { buttonVariants } from ".";
interface Props extends PrimitiveProps { interface Props extends PrimitiveProps {
variant?: ButtonVariants["variant"] variant?: ButtonVariants["variant"];
size?: ButtonVariants["size"] size?: ButtonVariants["size"];
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"];
disabled?: boolean;
loading?: boolean;
loadingLabel?: string;
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
as: "button", as: "button",
}) disabled: false,
loading: false,
});
const isDisabled = computed(() => props.disabled || props.loading);
</script> </script>
<template> <template>
<Primitive <Primitive
data-slot="button" data-slot="button"
:as="as" :data-loading="loading ? '' : undefined"
:as-child="asChild" :as="as"
:class="cn(buttonVariants({ variant, size }), props.class)" :as-child="asChild"
> :disabled="isDisabled"
<slot /> :aria-disabled="isDisabled ? 'true' : undefined"
</Primitive> :aria-busy="loading ? 'true' : undefined"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<Spinner
v-if="loading"
data-slot="button-spinner"
:label="loadingLabel"
/>
<slot />
</Primitive>
</template> </template>
+33 -34
View File
@@ -1,38 +1,37 @@
import type { VariantProps } from "class-variance-authority" import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority" import { cva } from "class-variance-authority";
export { default as Button } from "./Button.vue" export { default as Button } from "./Button.vue";
export const buttonVariants = cva( export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-body font-semibold transition-[background-color,border-color,color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[loading]:cursor-wait data-[loading]:opacity-80 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 focus-visible:ring-offset-2 focus-visible:ring-offset-background [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
variant: { variant: {
default: default:
"bg-primary text-primary-foreground hover:bg-primary/90", "border border-transparent bg-brand text-brand-foreground shadow-xs hover:brightness-95",
destructive: destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", "border border-transparent bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/35",
outline: outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", "border border-input bg-card text-foreground shadow-xs hover:border-primary/45 hover:bg-accent hover:text-accent-foreground",
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80", "border border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: ghost: "border border-transparent hover:bg-accent hover:text-accent-foreground",
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "min-h-0 border-0 px-0 text-primary underline-offset-4 shadow-none hover:underline",
link: "text-primary underline-offset-4 hover:underline", },
}, size: {
size: { default: "min-h-control px-4 py-2 has-[>svg]:px-3",
"default": "h-9 px-4 py-2 has-[>svg]:px-3", sm: "min-h-9 gap-1.5 px-3 py-1.5 has-[>svg]:px-2.5",
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", lg: "min-h-12 px-6 py-3 has-[>svg]:px-4",
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4", icon: "size-control",
"icon": "size-9", "icon-sm": "size-9",
"icon-sm": "size-8", "icon-lg": "size-12",
"icon-lg": "size-10", },
}, },
defaultVariants: {
variant: "default",
size: "default",
},
}, },
defaultVariants: { );
variant: "default", export type ButtonVariants = VariantProps<typeof buttonVariants>;
size: "default",
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
@@ -1,35 +1,48 @@
<script setup lang="ts"> <script setup lang="ts">
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui" import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { Check } from "lucide-vue-next" import { Check } from "lucide-vue-next";
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui" import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>() const props = withDefaults(
const emits = defineEmits<CheckboxRootEmits>() defineProps<
CheckboxRootProps & {
class?: HTMLAttributes["class"];
invalid?: boolean;
}
>(),
{ invalid: false },
);
const emits = defineEmits<CheckboxRootEmits>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class", "invalid");
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script> </script>
<template> <template>
<CheckboxRoot <CheckboxRoot
v-slot="slotProps" v-slot="slotProps"
data-slot="checkbox" data-slot="checkbox"
v-bind="forwarded" :data-invalid="invalid ? '' : undefined"
:class=" :aria-invalid="invalid ? 'true' : undefined"
cn('peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50', v-bind="forwarded"
props.class)" :class="
> cn(
<CheckboxIndicator 'peer border-input bg-card size-5 shrink-0 rounded-md border shadow-xs transition-[background-color,border-color,box-shadow] outline-none hover:border-primary/55 data-[state=checked]:border-brand data-[state=checked]:bg-brand data-[state=checked]:text-brand-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25',
data-slot="checkbox-indicator" props.class,
class="grid place-content-center text-current transition-none" )
"
> >
<slot v-bind="slotProps"> <CheckboxIndicator
<Check class="size-3.5" /> data-slot="checkbox-indicator"
</slot> class="grid place-content-center text-current transition-none"
</CheckboxIndicator> >
</CheckboxRoot> <slot v-bind="slotProps">
<Check class="size-4" stroke-width="2.5" />
</slot>
</CheckboxIndicator>
</CheckboxRoot>
</template> </template>
@@ -1,53 +1,67 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui" import type { DialogContentEmits, DialogContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { X } from "lucide-vue-next" import { X } from "lucide-vue-next";
import { import {
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogPortal, DialogPortal,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import DialogOverlay from "./DialogOverlay.vue" import DialogOverlay from "./DialogOverlay.vue";
import { useDialogFocus } from "@/composables/useDialogFocus";
import { useLocalization } from "@/composables/useLocalization";
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) });
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), { const props = withDefaults(
showCloseButton: true, defineProps<
}) DialogContentProps & {
const emits = defineEmits<DialogContentEmits>() class?: HTMLAttributes["class"];
showCloseButton?: boolean;
}
>(),
{
showCloseButton: true,
},
);
const emits = defineEmits<DialogContentEmits>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class", "showCloseButton");
const { restoreFocus } = useDialogFocus();
const { t } = useLocalization();
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script> </script>
<template> <template>
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay />
<DialogContent <DialogContent
data-slot="dialog-content" data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }" v-bind="{ ...$attrs, ...forwarded }"
:class=" @close-auto-focus="restoreFocus"
cn( :class="
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg', cn(
props.class, 'bg-card text-card-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-column overflow-y-auto overscroll-contain rounded-md border p-page shadow-lg duration-200 motion-reduce:animate-none sm:max-w-lg',
)" props.class,
> )
<slot /> "
>
<slot />
<DialogClose <DialogClose
v-if="showCloseButton" v-if="showCloseButton"
data-slot="dialog-close" data-slot="dialog-close"
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" class="ring-offset-background focus-visible:ring-ring absolute top-1 right-1 flex size-control items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
> >
<X /> <X />
<span class="sr-only">Close</span> <span class="sr-only">{{ t("common.close") }}</span>
</DialogClose> </DialogClose>
</DialogContent> </DialogContent>
</DialogPortal> </DialogPortal>
</template> </template>
@@ -1,17 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"];
}>() }>();
</script> </script>
<template> <template>
<div <div
data-slot="dialog-header" data-slot="dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)" :class="cn('flex flex-col gap-stack pr-6 text-left', props.class)"
> >
<slot /> <slot />
</div> </div>
</template> </template>
@@ -1,59 +1,71 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui" import type { DialogContentEmits, DialogContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { X } from "lucide-vue-next" import { X } from "lucide-vue-next";
import { import {
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogOverlay, DialogOverlay,
DialogPortal, DialogPortal,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { useDialogFocus } from "@/composables/useDialogFocus";
import { useLocalization } from "@/composables/useLocalization";
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) });
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<
const emits = defineEmits<DialogContentEmits>() DialogContentProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DialogContentEmits>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
const { restoreFocus } = useDialogFocus();
const { t } = useLocalization();
</script> </script>
<template> <template>
<DialogPortal> <DialogPortal>
<DialogOverlay <DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class,
)
"
v-bind="{ ...$attrs, ...forwarded }"
@pointer-down-outside="(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
event.preventDefault();
}
}"
>
<slot />
<DialogClose
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
> >
<X class="w-4 h-4" /> <DialogContent
<span class="sr-only">Close</span> :class="
</DialogClose> cn(
</DialogContent> 'relative z-50 my-4 grid w-full max-w-[calc(100%-2rem)] gap-column rounded-md border bg-card p-page text-card-foreground shadow-lg duration-200 sm:max-w-lg',
</DialogOverlay> props.class,
</DialogPortal> )
"
v-bind="{ ...$attrs, ...forwarded }"
@close-auto-focus="restoreFocus"
@pointer-down-outside="
(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (
originalEvent.offsetX > target.clientWidth ||
originalEvent.offsetY > target.clientHeight
) {
event.preventDefault();
}
}
"
>
<slot />
<DialogClose
class="absolute top-1 right-1 flex size-control items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"
>
<X class="w-4 h-4" />
<span class="sr-only">{{ t("common.close") }}</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template> </template>
@@ -1,39 +1,48 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from "reka-ui" import type {
import type { HTMLAttributes } from "vue" DropdownMenuCheckboxItemEmits,
import { reactiveOmit } from "@vueuse/core" DropdownMenuCheckboxItemProps,
import { Check } from "lucide-vue-next" } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { Check } from "lucide-vue-next";
import { import {
DropdownMenuCheckboxItem, DropdownMenuCheckboxItem,
DropdownMenuItemIndicator, DropdownMenuItemIndicator,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<
const emits = defineEmits<DropdownMenuCheckboxItemEmits>() DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DropdownMenuCheckboxItemEmits>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script> </script>
<template> <template>
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
v-bind="forwarded" v-bind="forwarded"
:class=" cn( :class="
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', cn(
props.class, 'focus:bg-accent focus:text-accent-foreground relative flex min-h-control cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
)" props.class,
> )
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> "
<DropdownMenuItemIndicator> >
<slot name="indicator-icon"> <span
<Check class="size-4" /> class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"
</slot> >
</DropdownMenuItemIndicator> <DropdownMenuItemIndicator>
</span> <slot name="indicator-icon">
<slot /> <Check class="size-4" />
</DropdownMenuCheckboxItem> </slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuCheckboxItem>
</template> </template>
@@ -1,39 +1,49 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui" import type {
import type { HTMLAttributes } from "vue" DropdownMenuContentEmits,
import { reactiveOmit } from "@vueuse/core" DropdownMenuContentProps,
} from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuPortal, DropdownMenuPortal,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) });
const props = withDefaults( const props = withDefaults(
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(), defineProps<
{ DropdownMenuContentProps & { class?: HTMLAttributes["class"] }
sideOffset: 4, >(),
}, {
) sideOffset: 4,
const emits = defineEmits<DropdownMenuContentEmits>() },
);
const emits = defineEmits<DropdownMenuContentEmits>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script> </script>
<template> <template>
<DropdownMenuPortal> <DropdownMenuPortal>
<DropdownMenuContent <DropdownMenuContent
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
v-bind="{ ...$attrs, ...forwarded }" v-bind="{ ...$attrs, ...forwarded }"
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', props.class)" :class="
> cn(
<slot /> 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] max-w-[calc(100vw-2rem)] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
</DropdownMenuContent> props.class,
</DropdownMenuPortal> )
"
>
<slot />
</DropdownMenuContent>
</DropdownMenuPortal>
</template> </template>
@@ -1,31 +1,41 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuItemProps } from "reka-ui" import type { DropdownMenuItemProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { DropdownMenuItem, useForwardProps } from "reka-ui" import { DropdownMenuItem, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = withDefaults(defineProps<DropdownMenuItemProps & { const props = withDefaults(
class?: HTMLAttributes["class"] defineProps<
inset?: boolean DropdownMenuItemProps & {
variant?: "default" | "destructive" class?: HTMLAttributes["class"];
}>(), { inset?: boolean;
variant: "default", variant?: "default" | "destructive";
}) }
>(),
{
variant: "default",
},
);
const delegatedProps = reactiveOmit(props, "inset", "variant", "class") const delegatedProps = reactiveOmit(props, "inset", "variant", "class");
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps);
</script> </script>
<template> <template>
<DropdownMenuItem <DropdownMenuItem
data-slot="dropdown-menu-item" data-slot="dropdown-menu-item"
:data-inset="inset ? '' : undefined" :data-inset="inset ? '' : undefined"
:data-variant="variant" :data-variant="variant"
v-bind="forwardedProps" v-bind="forwardedProps"
:class="cn('focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', props.class)" :class="
> cn(
<slot /> 'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex min-h-control cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
</DropdownMenuItem> props.class,
)
"
>
<slot />
</DropdownMenuItem>
</template> </template>
@@ -1,40 +1,49 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from "reka-ui" import type {
import type { HTMLAttributes } from "vue" DropdownMenuRadioItemEmits,
import { reactiveOmit } from "@vueuse/core" DropdownMenuRadioItemProps,
import { Circle } from "lucide-vue-next" } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { Circle } from "lucide-vue-next";
import { import {
DropdownMenuItemIndicator, DropdownMenuItemIndicator,
DropdownMenuRadioItem, DropdownMenuRadioItem,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<
DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DropdownMenuRadioItemEmits>() const emits = defineEmits<DropdownMenuRadioItemEmits>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script> </script>
<template> <template>
<DropdownMenuRadioItem <DropdownMenuRadioItem
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
v-bind="forwarded" v-bind="forwarded"
:class="cn( :class="
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', cn(
props.class, 'focus:bg-accent focus:text-accent-foreground relative flex min-h-control cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
)" props.class,
> )
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> "
<DropdownMenuItemIndicator> >
<slot name="indicator-icon"> <span
<Circle class="size-2 fill-current" /> class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"
</slot> >
</DropdownMenuItemIndicator> <DropdownMenuItemIndicator>
</span> <slot name="indicator-icon">
<slot /> <Circle class="size-2 fill-current" />
</DropdownMenuRadioItem> </slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuRadioItem>
</template> </template>
@@ -1,27 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui" import type {
import type { HTMLAttributes } from "vue" DropdownMenuSubContentEmits,
import { reactiveOmit } from "@vueuse/core" DropdownMenuSubContentProps,
import { } from "reka-ui";
DropdownMenuSubContent, import type { HTMLAttributes } from "vue";
useForwardPropsEmits, import { reactiveOmit } from "@vueuse/core";
} from "reka-ui" import { DropdownMenuSubContent, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<
const emits = defineEmits<DropdownMenuSubContentEmits>() DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DropdownMenuSubContentEmits>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script> </script>
<template> <template>
<DropdownMenuSubContent <DropdownMenuSubContent
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
v-bind="forwarded" v-bind="forwarded"
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', props.class)" :class="
> cn(
<slot /> 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] max-w-[calc(100vw-2rem)] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-lg',
</DropdownMenuSubContent> props.class,
)
"
>
<slot />
</DropdownMenuSubContent>
</template> </template>
@@ -1,30 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuSubTriggerProps } from "reka-ui" import type { DropdownMenuSubTriggerProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { ChevronRight } from "lucide-vue-next" import { ChevronRight } from "lucide-vue-next";
import { import { DropdownMenuSubTrigger, useForwardProps } from "reka-ui";
DropdownMenuSubTrigger, import { cn } from "@/lib/utils";
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes["class"], inset?: boolean }>() const props = defineProps<
DropdownMenuSubTriggerProps & {
class?: HTMLAttributes["class"];
inset?: boolean;
}
>();
const delegatedProps = reactiveOmit(props, "class", "inset") const delegatedProps = reactiveOmit(props, "class", "inset");
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps);
</script> </script>
<template> <template>
<DropdownMenuSubTrigger <DropdownMenuSubTrigger
data-slot="dropdown-menu-sub-trigger" data-slot="dropdown-menu-sub-trigger"
v-bind="forwardedProps" v-bind="forwardedProps"
:class="cn( :class="
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8', cn(
props.class, 'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex min-h-control cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
)" props.class,
> )
<slot /> "
<ChevronRight class="ml-auto size-4" /> >
</DropdownMenuSubTrigger> <slot />
<ChevronRight class="ml-auto size-4" />
</DropdownMenuSubTrigger>
</template> </template>
+27 -22
View File
@@ -1,33 +1,38 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { useVModel } from "@vueuse/core" import { useVModel } from "@vueuse/core";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<{ const props = defineProps<{
defaultValue?: string | number defaultValue?: string | number;
modelValue?: string | number | null modelValue?: string | number | null;
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"];
}>() invalid?: boolean;
}>();
const emits = defineEmits<{ const emits = defineEmits<{
(e: "update:modelValue", payload: string | number): void (e: "update:modelValue", payload: string | number): void;
}>() }>();
const modelValue = useVModel(props, "modelValue", emits, { const modelValue = useVModel(props, "modelValue", emits, {
passive: true, passive: true,
defaultValue: props.defaultValue, defaultValue: props.defaultValue,
}) });
</script> </script>
<template> <template>
<input <input
v-model="modelValue" v-model="modelValue"
data-slot="input" data-slot="input"
:class="cn( :data-invalid="invalid ? '' : undefined"
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', :aria-invalid="invalid ? 'true' : undefined"
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]', :class="
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', cn(
props.class, 'border-input bg-card placeholder:text-muted-foreground selection:bg-brand selection:text-brand-foreground min-h-control w-full min-w-0 rounded-md border px-3 py-2 text-base shadow-xs transition-[border-color,box-shadow] outline-none file:mr-3 file:inline-flex file:min-h-8 file:border-0 file:bg-transparent file:text-body file:font-semibold disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 md:text-body',
)" 'hover:border-primary/45 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35',
> 'aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25',
props.class,
)
"
/>
</template> </template>
@@ -1,51 +1,60 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectContentEmits, SelectContentProps } from "reka-ui" import type { SelectContentEmits, SelectContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { import {
SelectContent, SelectContent,
SelectPortal, SelectPortal,
SelectViewport, SelectViewport,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { SelectScrollDownButton, SelectScrollUpButton } from "." import { SelectScrollDownButton, SelectScrollUpButton } from ".";
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) });
const props = withDefaults( const props = withDefaults(
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(), defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
{ {
position: "popper", position: "popper",
}, },
) );
const emits = defineEmits<SelectContentEmits>() const emits = defineEmits<SelectContentEmits>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script> </script>
<template> <template>
<SelectPortal> <SelectPortal>
<SelectContent <SelectContent
data-slot="select-content" data-slot="select-content"
v-bind="{ ...$attrs, ...forwarded }" v-bind="{ ...$attrs, ...forwarded }"
:class="cn( :class="
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md', cn(
position === 'popper' 'bg-popover text-popover-foreground border-border data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-lg',
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', position === 'popper' &&
props.class, 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
) props.class,
" )
> "
<SelectScrollUpButton /> >
<SelectViewport :class="cn('p-1', position === 'popper' && 'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1')"> <SelectScrollUpButton />
<slot /> <SelectViewport
</SelectViewport> :class="
<SelectScrollDownButton /> cn(
</SelectContent> 'p-1',
</SelectPortal> position === 'popper' &&
'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1',
)
"
>
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template> </template>
@@ -1,44 +1,48 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectItemProps } from "reka-ui" import type { SelectItemProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { Check } from "lucide-vue-next" import { Check } from "lucide-vue-next";
import { import {
SelectItem, SelectItem,
SelectItemIndicator, SelectItemIndicator,
SelectItemText, SelectItemText,
useForwardProps, useForwardProps,
} from "reka-ui" } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<SelectItemProps & { class?: HTMLAttributes["class"] }>() const props = defineProps<
SelectItemProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps);
</script> </script>
<template> <template>
<SelectItem <SelectItem
data-slot="select-item" data-slot="select-item"
v-bind="forwardedProps" v-bind="forwardedProps"
:class=" :class="
cn( cn(
'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2', 'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex min-h-9 w-full cursor-default items-center gap-2 rounded-sm py-2 pr-8 pl-2 text-body outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
props.class, props.class,
) )
" "
> >
<span class="absolute right-2 flex size-3.5 items-center justify-center"> <span
<SelectItemIndicator> class="absolute right-2 flex size-3.5 items-center justify-center"
<slot name="indicator-icon"> >
<Check class="size-4" /> <SelectItemIndicator>
</slot> <slot name="indicator-icon">
</SelectItemIndicator> <Check class="size-4" />
</span> </slot>
</SelectItemIndicator>
</span>
<SelectItemText> <SelectItemText>
<slot /> <slot />
</SelectItemText> </SelectItemText>
</SelectItem> </SelectItem>
</template> </template>
@@ -1,33 +1,45 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectTriggerProps } from "reka-ui" import type { SelectTriggerProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { ChevronDown } from "lucide-vue-next" import { ChevronDown } from "lucide-vue-next";
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui" import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = withDefaults( const props = withDefaults(
defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"], size?: "sm" | "default" }>(), defineProps<
{ size: "default" }, SelectTriggerProps & {
) class?: HTMLAttributes["class"];
size?: "sm" | "default";
invalid?: boolean;
}
>(),
{ size: "default", invalid: false },
);
const delegatedProps = reactiveOmit(props, "class", "size") const delegatedProps = reactiveOmit(props, "class", "size", "invalid");
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps);
</script> </script>
<template> <template>
<SelectTrigger <SelectTrigger
data-slot="select-trigger" data-slot="select-trigger"
:data-size="size" :data-size="size"
v-bind="forwardedProps" :data-invalid="invalid ? '' : undefined"
:class="cn( :aria-invalid="invalid ? 'true' : undefined"
'border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', v-bind="forwardedProps"
props.class, :class="
)" cn(
> 'group/select-trigger border-input bg-card data-[placeholder]:text-muted-foreground flex w-fit items-center justify-between gap-2 rounded-md border px-3 py-2 text-body whitespace-nowrap shadow-xs transition-[border-color,box-shadow] outline-none hover:border-primary/45 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 data-[state=open]:border-primary data-[size=default]:min-h-control data-[size=sm]:min-h-9 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 [&_svg:not([class*=\'text-\'])]:text-muted-foreground',
<slot /> props.class,
<SelectIcon as-child> )
<ChevronDown class="size-4 opacity-50" /> "
</SelectIcon> >
</SelectTrigger> <slot />
<SelectIcon as-child>
<ChevronDown
class="size-4 transition-transform duration-150 group-data-[state=open]/select-trigger:rotate-180"
/>
</SelectIcon>
</SelectTrigger>
</template> </template>
@@ -1,62 +1,70 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui" import type { DialogContentEmits, DialogContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { X } from "lucide-vue-next" import { X } from "lucide-vue-next";
import { import {
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogPortal, DialogPortal,
useForwardPropsEmits, useForwardPropsEmits,
} from "reka-ui" } from "reka-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import SheetOverlay from "./SheetOverlay.vue" import SheetOverlay from "./SheetOverlay.vue";
import { useDialogFocus } from "@/composables/useDialogFocus";
import { useLocalization } from "@/composables/useLocalization";
interface SheetContentProps extends DialogContentProps { interface SheetContentProps extends DialogContentProps {
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"];
side?: "top" | "right" | "bottom" | "left" side?: "top" | "right" | "bottom" | "left";
} }
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) });
const props = withDefaults(defineProps<SheetContentProps>(), { const props = withDefaults(defineProps<SheetContentProps>(), {
side: "right", side: "right",
}) });
const emits = defineEmits<DialogContentEmits>() const emits = defineEmits<DialogContentEmits>();
const delegatedProps = reactiveOmit(props, "class", "side") const delegatedProps = reactiveOmit(props, "class", "side");
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits);
const { restoreFocus } = useDialogFocus();
const { t } = useLocalization();
</script> </script>
<template> <template>
<DialogPortal> <DialogPortal>
<SheetOverlay /> <SheetOverlay />
<DialogContent <DialogContent
data-slot="sheet-content" data-slot="sheet-content"
:class="cn( :class="
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500', cn(
side === 'right' 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
&& 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm', side === 'right' &&
side === 'left' 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
&& 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm', side === 'left' &&
side === 'top' 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
&& 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b', side === 'top' &&
side === 'bottom' 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
&& 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t', side === 'bottom' &&
props.class)" 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
v-bind="{ ...$attrs, ...forwarded }" props.class,
> )
<slot /> "
v-bind="{ ...$attrs, ...forwarded }"
@close-auto-focus="restoreFocus"
>
<slot />
<DialogClose <DialogClose
class="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none" class="ring-offset-background focus-visible:ring-ring absolute top-1 right-1 flex size-control items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none"
> >
<X class="size-4" /> <X class="size-4" />
<span class="sr-only">Close</span> <span class="sr-only">{{ t("common.close") }}</span>
</DialogClose> </DialogClose>
</DialogContent> </DialogContent>
</DialogPortal> </DialogPortal>
</template> </template>
+104 -82
View File
@@ -1,96 +1,118 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SidebarProps } from "." import type { SidebarProps } from ".";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Sheet, SheetContent } from '@/components/ui/sheet' import { Sheet, SheetContent } from "@/components/ui/sheet";
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue' import SheetDescription from "@/components/ui/sheet/SheetDescription.vue";
import SheetHeader from '@/components/ui/sheet/SheetHeader.vue' import SheetHeader from "@/components/ui/sheet/SheetHeader.vue";
import SheetTitle from '@/components/ui/sheet/SheetTitle.vue' import SheetTitle from "@/components/ui/sheet/SheetTitle.vue";
import { SIDEBAR_WIDTH_MOBILE, useSidebar } from "./utils" import { SIDEBAR_WIDTH_MOBILE, useSidebar } from "./utils";
import { useLocalization } from "@/composables/useLocalization";
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) });
const props = withDefaults(defineProps<SidebarProps>(), { const props = withDefaults(defineProps<SidebarProps>(), {
side: "left", side: "left",
variant: "sidebar", variant: "sidebar",
collapsible: "offcanvas", collapsible: "offcanvas",
}) });
const { isMobile, state, openMobile, setOpenMobile } = useSidebar() const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
const { t } = useLocalization();
function restoreTriggerFocus(event: Event) {
event.preventDefault();
document
.querySelector<HTMLButtonElement>('[data-sidebar="trigger"]')
?.focus();
}
</script> </script>
<template> <template>
<div
v-if="collapsible === 'none'"
data-slot="sidebar"
:class="cn('bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col', props.class)"
v-bind="$attrs"
>
<slot />
</div>
<Sheet v-else-if="isMobile" :open="openMobile" v-bind="$attrs" @update:open="setOpenMobile">
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
:side="side"
class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
:style="{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
}"
>
<SheetHeader class="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div class="flex h-full w-full flex-col">
<slot />
</div>
</SheetContent>
</Sheet>
<div
v-else
class="group peer text-sidebar-foreground hidden md:block"
data-slot="sidebar"
:data-state="state"
:data-collapsible="state === 'collapsed' ? collapsible : ''"
:data-variant="variant"
:data-side="side"
>
<!-- This is what handles the sidebar gap on desktop -->
<div <div
:class="cn( v-if="collapsible === 'none'"
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear', data-slot="sidebar"
'group-data-[collapsible=offcanvas]:w-0', :class="
'group-data-[side=right]:rotate-180', cn(
variant === 'floating' || variant === 'inset' 'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]' props.class,
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)', )
)" "
/> v-bind="$attrs"
<div
:class="cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
props.class,
)"
v-bind="$attrs"
> >
<div
data-sidebar="sidebar"
class="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
<slot /> <slot />
</div>
</div> </div>
</div>
<Sheet v-else-if="isMobile" :open="openMobile" @update:open="setOpenMobile">
<SheetContent
v-bind="$attrs"
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
:side="side"
class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) max-w-[calc(100vw-2rem)] gap-0 p-0 [&>button]:hidden"
@close-auto-focus="restoreTriggerFocus"
:style="{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
}"
>
<SheetHeader class="sr-only">
<SheetTitle>{{ t("navigation.main") }}</SheetTitle>
<SheetDescription>{{
t("navigation.mobileDescription")
}}</SheetDescription>
</SheetHeader>
<div class="flex min-h-0 h-full w-full flex-col">
<slot />
</div>
</SheetContent>
</Sheet>
<div
v-else
class="group peer text-sidebar-foreground hidden md:block"
data-slot="sidebar"
:data-state="state"
:data-collapsible="state === 'collapsed' ? collapsible : ''"
:data-variant="variant"
:data-side="side"
>
<!-- This is what handles the sidebar gap on desktop -->
<div
:class="
cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
)
"
/>
<div
:class="
cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
props.class,
)
"
v-bind="$attrs"
>
<div
data-sidebar="sidebar"
class="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
<slot />
</div>
</div>
</div>
</template> </template>
@@ -1,18 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"];
}>() }>();
</script> </script>
<template> <template>
<div <div
data-slot="sidebar-content" data-slot="sidebar-content"
data-sidebar="content" data-sidebar="content"
:class="cn('flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden', props.class)" :class="
> cn(
<slot /> 'flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto',
</div> props.class,
)
"
>
<slot />
</div>
</template> </template>
@@ -1,21 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"];
}>() }>();
</script> </script>
<template> <template>
<main <main
data-slot="sidebar-inset" data-slot="sidebar-inset"
:class="cn( :class="
'bg-background relative flex w-full flex-1 flex-col', cn(
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2', 'bg-background relative flex min-w-0 w-full flex-1 flex-col',
props.class, 'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
)" props.class,
> )
<slot /> "
</main> >
<slot />
</main>
</template> </template>
@@ -1,48 +1,60 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Component } from "vue" import type { Component } from "vue";
import type { SidebarMenuButtonProps } from "./SidebarMenuButtonChild.vue" import type { SidebarMenuButtonProps } from "./SidebarMenuButtonChild.vue";
import { reactiveOmit } from "@vueuse/core" import { reactiveOmit } from "@vueuse/core";
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import {
import SidebarMenuButtonChild from "./SidebarMenuButtonChild.vue" Tooltip,
import { useSidebar } from "./utils" TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import SidebarMenuButtonChild from "./SidebarMenuButtonChild.vue";
import { useSidebar } from "./utils";
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) });
const props = withDefaults(defineProps<SidebarMenuButtonProps & { const props = withDefaults(
tooltip?: string | Component defineProps<
}>(), { SidebarMenuButtonProps & {
as: "button", tooltip?: string | Component;
variant: "default", }
size: "default", >(),
}) {
as: "button",
variant: "default",
size: "default",
},
);
const { isMobile, state } = useSidebar() const { isMobile, state } = useSidebar();
const delegatedProps = reactiveOmit(props, "tooltip") const delegatedProps = reactiveOmit(props, "tooltip");
</script> </script>
<template> <template>
<SidebarMenuButtonChild v-if="!tooltip" v-bind="{ ...delegatedProps, ...$attrs }"> <SidebarMenuButtonChild
<slot /> v-if="!tooltip || isMobile || state !== 'collapsed'"
</SidebarMenuButtonChild> v-bind="{ ...delegatedProps, ...$attrs }"
<Tooltip v-else>
<TooltipTrigger as-child>
<SidebarMenuButtonChild v-bind="{ ...delegatedProps, ...$attrs }">
<slot />
</SidebarMenuButtonChild>
</TooltipTrigger>
<TooltipContent
side="right"
align="center"
:hidden="state !== 'collapsed' || isMobile"
> >
<template v-if="typeof tooltip === 'string'"> <slot />
{{ tooltip }} </SidebarMenuButtonChild>
</template>
<component :is="tooltip" v-else /> <Tooltip v-else>
</TooltipContent> <TooltipTrigger as-child>
</Tooltip> <SidebarMenuButtonChild v-bind="{ ...delegatedProps, ...$attrs }">
<slot />
</SidebarMenuButtonChild>
</TooltipTrigger>
<TooltipContent
side="right"
align="center"
:hidden="state !== 'collapsed' || isMobile"
>
<template v-if="typeof tooltip === 'string'">
{{ tooltip }}
</template>
<component :is="tooltip" v-else />
</TooltipContent>
</Tooltip>
</template> </template>
@@ -1,82 +1,112 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes, Ref } from "vue" import type { HTMLAttributes, Ref } from "vue";
import { defaultDocument, useEventListener, useMediaQuery, useVModel } from "@vueuse/core" import {
import { TooltipProvider } from "reka-ui" defaultDocument,
import { computed, ref } from "vue" useEventListener,
import { cn } from "@/lib/utils" useMediaQuery,
import { provideSidebarContext, SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, SIDEBAR_KEYBOARD_SHORTCUT, SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON } from "./utils" useVModel,
} from "@vueuse/core";
import { TooltipProvider } from "reka-ui";
import { computed, ref, watch } from "vue";
import { cn } from "@/lib/utils";
import {
provideSidebarContext,
SIDEBAR_COOKIE_MAX_AGE,
SIDEBAR_COOKIE_NAME,
SIDEBAR_KEYBOARD_SHORTCUT,
SIDEBAR_WIDTH,
SIDEBAR_WIDTH_ICON,
} from "./utils";
const props = withDefaults(defineProps<{ const props = withDefaults(
defaultOpen?: boolean defineProps<{
open?: boolean defaultOpen?: boolean;
class?: HTMLAttributes["class"] open?: boolean;
}>(), { class?: HTMLAttributes["class"];
defaultOpen: !defaultDocument?.cookie.includes(`${SIDEBAR_COOKIE_NAME}=false`), }>(),
open: undefined, {
}) defaultOpen: !defaultDocument?.cookie.includes(
`${SIDEBAR_COOKIE_NAME}=false`,
),
open: undefined,
},
);
const emits = defineEmits<{ const emits = defineEmits<{
"update:open": [open: boolean] "update:open": [open: boolean];
}>() }>();
const isMobile = useMediaQuery("(max-width: 768px)") const isMobile = useMediaQuery("(width < 768px)");
const openMobile = ref(false) const openMobile = ref(false);
watch(isMobile, () => {
openMobile.value = false;
});
const open = useVModel(props, "open", emits, { const open = useVModel(props, "open", emits, {
defaultValue: props.defaultOpen ?? false, defaultValue: props.defaultOpen ?? false,
passive: (props.open === undefined) as false, passive: (props.open === undefined) as false,
}) as Ref<boolean> }) as Ref<boolean>;
function setOpen(value: boolean) { function setOpen(value: boolean) {
open.value = value // emits('update:open', value) open.value = value; // emits('update:open', value)
// This sets the cookie to keep the sidebar state. // This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
} }
function setOpenMobile(value: boolean) { function setOpenMobile(value: boolean) {
openMobile.value = value openMobile.value = value;
} }
// Helper to toggle the sidebar. // Helper to toggle the sidebar.
function toggleSidebar() { function toggleSidebar() {
return isMobile.value ? setOpenMobile(!openMobile.value) : setOpen(!open.value) return isMobile.value
? setOpenMobile(!openMobile.value)
: setOpen(!open.value);
} }
useEventListener("keydown", (event: KeyboardEvent) => { useEventListener("keydown", (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { if (
event.preventDefault() event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
toggleSidebar() (event.metaKey || event.ctrlKey)
} ) {
}) event.preventDefault();
toggleSidebar();
}
});
// We add a state so that we can do data-state="expanded" or "collapsed". // We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes. // This makes it easier to style the sidebar with Tailwind classes.
const state = computed(() => open.value ? "expanded" : "collapsed") const state = computed(() => (open.value ? "expanded" : "collapsed"));
provideSidebarContext({ provideSidebarContext({
state, state,
open, open,
setOpen, setOpen,
isMobile, isMobile,
openMobile, openMobile,
setOpenMobile, setOpenMobile,
toggleSidebar, toggleSidebar,
}) });
</script> </script>
<template> <template>
<TooltipProvider :delay-duration="0"> <TooltipProvider :delay-duration="0">
<div <div
data-slot="sidebar-wrapper" data-slot="sidebar-wrapper"
:style="{ :style="{
'--sidebar-width': SIDEBAR_WIDTH, '--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON, '--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
}" }"
:class="cn('group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full', props.class)" :class="
v-bind="$attrs" cn(
> 'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
<slot /> props.class,
</div> )
</TooltipProvider> "
v-bind="$attrs"
>
<slot />
</div>
</TooltipProvider>
</template> </template>
@@ -1,28 +1,44 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { PanelLeftClose, PanelLeftOpen } from "lucide-vue-next" import { PanelLeftClose, PanelLeftOpen } from "lucide-vue-next";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { useSidebar } from "./utils" import { useSidebar } from "./utils";
import { useLocalization } from "@/composables/useLocalization";
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"];
}>() }>();
const { isMobile, state, toggleSidebar } = useSidebar() const { isMobile, state, openMobile, toggleSidebar } = useSidebar();
const { t } = useLocalization();
</script> </script>
<template> <template>
<Button <Button
data-sidebar="trigger" data-sidebar="trigger"
data-slot="sidebar-trigger" data-slot="sidebar-trigger"
variant="ghost" variant="ghost"
size="icon" size="icon"
:class="cn('h-7 w-7', props.class)" type="button"
@click="toggleSidebar" aria-controls="app-navigation"
> :aria-expanded="isMobile ? openMobile : state === 'expanded'"
<PanelLeftOpen v-if="isMobile || state === 'collapsed'" /> :aria-label="
<PanelLeftClose v-else /> isMobile
<span class="sr-only">Toggle sidebar</span> ? t(openMobile ? 'navigation.close' : 'navigation.open')
</Button> : t(
state === 'collapsed'
? 'navigation.expand'
: 'navigation.collapse',
)
"
:class="cn('size-control', props.class)"
@click="toggleSidebar"
>
<PanelLeftOpen
v-if="isMobile || state === 'collapsed'"
aria-hidden="true"
/>
<PanelLeftClose v-else aria-hidden="true" />
</Button>
</template> </template>
+11 -10
View File
@@ -1,8 +1,8 @@
import { createContext } from "reka-ui" import { createContext } from "reka-ui";
import type { ComputedRef, Ref } from 'vue'; import type { ComputedRef, Ref } from "vue";
export type SidebarContext = { export type SidebarContext = {
state: ComputedRef<'expanded' | 'collapsed'>; state: ComputedRef<"expanded" | "collapsed">;
open: Ref<boolean>; open: Ref<boolean>;
setOpen: (value: boolean) => void; setOpen: (value: boolean) => void;
isMobile: Ref<boolean>; isMobile: Ref<boolean>;
@@ -11,11 +11,12 @@ export type SidebarContext = {
toggleSidebar: () => void; toggleSidebar: () => void;
}; };
export const SIDEBAR_COOKIE_NAME = "sidebar_state" export const SIDEBAR_COOKIE_NAME = "sidebar_state";
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
export const SIDEBAR_WIDTH = "16rem" export const SIDEBAR_WIDTH = "16rem";
export const SIDEBAR_WIDTH_MOBILE = "18rem" export const SIDEBAR_WIDTH_MOBILE = "18rem";
export const SIDEBAR_WIDTH_ICON = "3rem" export const SIDEBAR_WIDTH_ICON = "4rem";
export const SIDEBAR_KEYBOARD_SHORTCUT = "b" export const SIDEBAR_KEYBOARD_SHORTCUT = "b";
export const [useSidebar, provideSidebarContext] = createContext<SidebarContext>("Sidebar") export const [useSidebar, provideSidebarContext] =
createContext<SidebarContext>("Sidebar");
+16 -10
View File
@@ -1,17 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from "vue" import type { HTMLAttributes } from "vue";
import { Loader2Icon } from "lucide-vue-next" import { Loader2Icon } from "lucide-vue-next";
import { cn } from "@/lib/utils" import { computed } from "vue";
import { useLocalization } from "@/composables/useLocalization";
import { cn } from "@/lib/utils";
const props = defineProps<{ const props = defineProps<{
class?: HTMLAttributes["class"] class?: HTMLAttributes["class"];
}>() label?: string;
}>();
const { t } = useLocalization();
const accessibleLabel = computed(() => props.label ?? t("common.loading"));
</script> </script>
<template> <template>
<Loader2Icon <Loader2Icon
role="status" role="status"
aria-label="Loading" :aria-label="accessibleLabel"
:class="cn('size-4 animate-spin', props.class)" :class="cn('size-4 animate-spin', props.class)"
/> />
</template> </template>
@@ -0,0 +1,25 @@
import { usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
import { useLocalization } from '@/composables/useLocalization';
import {
projectNavigation,
projectSettingsNavigation,
workspaceNavigation,
} from '@/lib/navigation';
export function useAppNavigation() {
const page = usePage();
const { t } = useLocalization();
const project = computed(() => page.props.navigation?.project ?? null);
return {
project,
workspaceItems: computed(() =>
workspaceNavigation(page.props.auth.permissions, t),
),
projectItems: computed(() => projectNavigation(project.value, t)),
projectSettingsItems: computed(() =>
projectSettingsNavigation(project.value, t),
),
};
}
+80 -97
View File
@@ -1,124 +1,107 @@
import type { ComputedRef, Ref } from 'vue'; import type { App, ComputedRef, InjectionKey, Ref } from 'vue';
import { computed, onMounted, ref } from 'vue'; import { computed, inject, readonly, ref } from 'vue';
import type { Appearance, ResolvedAppearance } from '@/types'; import type { Appearance, ResolvedAppearance } from '@/types';
export type { Appearance, ResolvedAppearance }; export type { Appearance, ResolvedAppearance };
export type UseAppearanceReturn = { export type UseAppearanceReturn = {
appearance: Ref<Appearance>; appearance: Readonly<Ref<Appearance>>;
resolvedAppearance: ComputedRef<ResolvedAppearance>; resolvedAppearance: ComputedRef<ResolvedAppearance>;
updateAppearance: (value: Appearance) => void; updateAppearance: (value: Appearance) => void;
}; };
export function updateTheme(value: Appearance): void { const appearanceKey: InjectionKey<UseAppearanceReturn> = Symbol('appearance');
if (typeof window === 'undefined') {
return;
}
if (value === 'system') { export function normalizeAppearance(value: unknown): Appearance {
const mediaQueryList = window.matchMedia( return value === 'light' || value === 'dark' ? value : 'system';
'(prefers-color-scheme: dark)', }
);
const systemTheme = mediaQueryList.matches ? 'dark' : 'light';
/** Each Vue app owns its state, including each server-rendered request. */
export function createAppearance(initialAppearance: Appearance = 'system') {
const appearance = ref<Appearance>(normalizeAppearance(initialAppearance));
const systemDark = ref(false);
const resolvedAppearance = computed<ResolvedAppearance>(() =>
appearance.value === 'system'
? systemDark.value
? 'dark'
: 'light'
: appearance.value,
);
function applyTheme(): void {
document.documentElement.classList.toggle( document.documentElement.classList.toggle(
'dark', 'dark',
systemTheme === 'dark', resolvedAppearance.value === 'dark',
); );
} else { document.documentElement.style.colorScheme = resolvedAppearance.value;
document.documentElement.classList.toggle('dark', value === 'dark'); document.documentElement.dataset.appearance = appearance.value;
}
}
const setCookie = (name: string, value: string, days = 365) => {
if (typeof document === 'undefined') {
return;
} }
const maxAge = days * 24 * 60 * 60; function updateAppearance(value: Appearance): void {
appearance.value = normalizeAppearance(value);
document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`; if (typeof window === 'undefined') {
}; return;
const mediaQuery = () => {
if (typeof window === 'undefined') {
return null;
}
return window.matchMedia('(prefers-color-scheme: dark)');
};
const getStoredAppearance = () => {
if (typeof window === 'undefined') {
return null;
}
return localStorage.getItem('appearance') as Appearance | null;
};
const prefersDark = (): boolean => {
if (typeof window === 'undefined') {
return false;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches;
};
const handleSystemThemeChange = () => {
const currentAppearance = getStoredAppearance();
updateTheme(currentAppearance || 'system');
};
export function initializeTheme(): void {
if (typeof window === 'undefined') {
return;
}
// Initialize theme from saved preference or default to system...
const savedAppearance = getStoredAppearance();
updateTheme(savedAppearance || 'system');
// Set up system theme change listener...
mediaQuery()?.addEventListener('change', handleSystemThemeChange);
}
const appearance = ref<Appearance>('system');
export function useAppearance(): UseAppearanceReturn {
onMounted(() => {
const savedAppearance = localStorage.getItem(
'appearance',
) as Appearance | null;
if (savedAppearance) {
appearance.value = savedAppearance;
}
});
const resolvedAppearance = computed<ResolvedAppearance>(() => {
if (appearance.value === 'system') {
return prefersDark() ? 'dark' : 'light';
} }
return appearance.value; applyTheme();
}); try {
window.localStorage.setItem('appearance', appearance.value);
} catch {
// Private or embedded browsers may deny storage; the current theme still works.
}
document.cookie = `appearance=${appearance.value};path=/;max-age=31536000;SameSite=Lax`;
}
function updateAppearance(value: Appearance) { function handleSystemChange(event: MediaQueryListEvent): void {
appearance.value = value; systemDark.value = event.matches;
applyTheme();
}
// Store in localStorage for client-side persistence... function handleStorageChange(event: StorageEvent): void {
localStorage.setItem('appearance', value); if (event.key === 'appearance' || event.key === null) {
updateAppearance(normalizeAppearance(event.newValue));
// Store in cookie for SSR... }
setCookie('appearance', value);
updateTheme(value);
} }
return { return {
appearance, appearance: readonly(appearance),
resolvedAppearance, resolvedAppearance,
updateAppearance, updateAppearance,
install(app: App): void {
app.provide(appearanceKey, {
appearance: readonly(appearance),
resolvedAppearance,
updateAppearance,
});
if (typeof window === 'undefined') {
return;
}
// The blocking head script already reconciled local storage and the SSR cookie.
appearance.value = normalizeAppearance(
document.documentElement.dataset.appearance ??
initialAppearance,
);
const media = window.matchMedia('(prefers-color-scheme: dark)');
systemDark.value = media.matches;
applyTheme();
media.addEventListener('change', handleSystemChange);
window.addEventListener('storage', handleStorageChange);
app.onUnmount(() => {
media.removeEventListener('change', handleSystemChange);
window.removeEventListener('storage', handleStorageChange);
});
},
}; };
} }
export function useAppearance(): UseAppearanceReturn {
const appearance = inject(appearanceKey);
if (!appearance) {
throw new Error(
'The appearance plugin must be installed before useAppearance().',
);
}
return appearance;
}
+154
View File
@@ -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 };
}
+14 -32
View File
@@ -3,6 +3,7 @@ import { usePage } from '@inertiajs/vue3';
import type { ComputedRef, DeepReadonly } from 'vue'; import type { ComputedRef, DeepReadonly } from 'vue';
import { computed, readonly } from 'vue'; import { computed, readonly } from 'vue';
import { toUrl } from '@/lib/utils'; import { toUrl } from '@/lib/utils';
import { matchesNavigationUrl } from '@/lib/navigation';
export type UseCurrentUrlReturn = { export type UseCurrentUrlReturn = {
currentUrl: DeepReadonly<ComputedRef<string>>; currentUrl: DeepReadonly<ComputedRef<string>>;
@@ -22,40 +23,21 @@ export type UseCurrentUrlReturn = {
) => T | F; ) => T | F;
}; };
const page = usePage();
const currentUrlReactive = computed(
() =>
new URL(
page.url,
typeof window !== 'undefined'
? window.location.origin
: 'http://localhost',
).pathname,
);
export function useCurrentUrl(): UseCurrentUrlReturn { export function useCurrentUrl(): UseCurrentUrlReturn {
const page = usePage();
const currentUrlReactive = computed(
() => new URL(page.url, 'http://localhost').pathname,
);
function isCurrentUrl( function isCurrentUrl(
urlToCheck: NonNullable<InertiaLinkProps['href']>, urlToCheck: NonNullable<InertiaLinkProps['href']>,
currentUrl?: string, currentUrl?: string,
startsWith: boolean = false, startsWith: boolean = false,
) { ) {
const urlToCompare = currentUrl ?? currentUrlReactive.value; return matchesNavigationUrl(
const urlString = toUrl(urlToCheck); toUrl(urlToCheck),
currentUrl ?? currentUrlReactive.value,
const comparePath = (path: string): boolean => startsWith,
startsWith ? urlToCompare.startsWith(path) : path === urlToCompare; );
if (!urlString.startsWith('http')) {
return comparePath(urlString);
}
try {
const absoluteUrl = new URL(urlString);
return comparePath(absoluteUrl.pathname);
} catch {
return false;
}
} }
function isCurrentOrParentUrl( function isCurrentOrParentUrl(
@@ -65,11 +47,11 @@ export function useCurrentUrl(): UseCurrentUrlReturn {
return isCurrentUrl(urlToCheck, currentUrl, true); return isCurrentUrl(urlToCheck, currentUrl, true);
} }
function whenCurrentUrl( function whenCurrentUrl<T, F = null>(
urlToCheck: NonNullable<InertiaLinkProps['href']>, urlToCheck: NonNullable<InertiaLinkProps['href']>,
ifTrue: any, ifTrue: T,
ifFalse: any = null, ifFalse: F = null as F,
) { ): T | F {
return isCurrentUrl(urlToCheck) ? ifTrue : ifFalse; return isCurrentUrl(urlToCheck) ? ifTrue : ifFalse;
} }
@@ -0,0 +1,34 @@
import { injectDialogRootContext } from 'reka-ui';
import { watch } from 'vue';
/** Reka restores registered triggers; also support dialogs opened programmatically. */
export function useDialogFocus() {
const context = injectDialogRootContext();
let previousFocus: HTMLElement | null = null;
watch(
context.open,
(open) => {
if (open && typeof document !== 'undefined') {
previousFocus =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
}
},
{ flush: 'sync', immediate: true },
);
function restoreFocus(event: Event): void {
if (
!event.defaultPrevented &&
!context.triggerElement.value &&
previousFocus?.isConnected
) {
event.preventDefault();
previousFocus.focus({ preventScroll: true });
}
}
return { restoreFocus };
}
@@ -0,0 +1,122 @@
import type {
PendingVisit,
UrlMethodPair,
UseFormSubmitOptions,
} from '@inertiajs/core';
import { router } from '@inertiajs/vue3';
import type { InertiaForm } from '@inertiajs/vue3';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useUnsavedChanges } from '@/composables/useUnsavedChanges';
import {
formRequestHeader,
isFormNetworkFailure,
matchesFormRequest,
} from '@/lib/form-feedback';
import type { FormFeedbackState } from '@/lib/form-feedback';
/** Pair with a keyed useForm/useRemember for restoration on browser Back/Forward. */
export function useFormProtection<T extends object>(
form: InertiaForm<T>,
options: { isDirty?: () => boolean } = {},
) {
const failure = ref<FormFeedbackState | null>(null);
const responseStatus = ref<number | null>(null);
let ownVisit: PendingVisit | null = null;
let requestId: string | null = null;
const removeListeners: Array<() => void> = [];
const { confirmLeave } = useUnsavedChanges(
() => form.processing || (options.isDirty?.() ?? form.isDirty),
(visit) => visit === ownVisit,
);
const state = computed<FormFeedbackState>(() => {
if (form.processing) {
return 'loading';
}
if (failure.value) {
return failure.value;
}
if (Object.keys(form.errors).length > 0) {
return 'validation';
}
return form.recentlySuccessful ? 'success' : 'idle';
});
onMounted(() => {
removeListeners.push(
router.on('exception', (event) => {
if (isFormNetworkFailure(event.detail.exception, requestId)) {
event.preventDefault();
failure.value = 'network';
}
}),
router.on('invalid', (event) => {
if (
matchesFormRequest(event.detail.response.config, requestId)
) {
event.preventDefault();
failure.value = 'server';
responseStatus.value = event.detail.response.status;
}
}),
);
});
onUnmounted(() => removeListeners.forEach((remove) => remove()));
function submit(
target: UrlMethodPair,
visitOptions: UseFormSubmitOptions = {},
): void {
if (form.processing) {
return;
}
const nextRequestId = crypto.randomUUID();
form.submit(target, {
...visitOptions,
preserveState: true,
headers: {
...visitOptions.headers,
[formRequestHeader]: nextRequestId,
},
onBefore: (visit) => {
if (visitOptions.onBefore?.(visit) === false) {
return false;
}
ownVisit = visit;
},
onStart: (visit) => {
requestId = nextRequestId;
failure.value = null;
responseStatus.value = null;
visitOptions.onStart?.(visit);
},
onError: (errors) => {
failure.value = 'validation';
return visitOptions.onError?.(errors);
},
onSuccess: (page) => {
failure.value = null;
return visitOptions.onSuccess?.(page);
},
onCancel: () => {
failure.value = 'cancelled';
visitOptions.onCancel?.();
},
onFinish: (visit) => {
requestId = null;
ownVisit = null;
visitOptions.onFinish?.(visit);
},
});
}
function clearFeedback(): void {
failure.value = null;
responseStatus.value = null;
}
return { state, responseStatus, submit, confirmLeave, clearFeedback };
}

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