Compare commits
21
Commits
v0.2.2
..
149748b03e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
149748b03e | ||
|
|
3122e00ed8 | ||
|
|
0f125f17a3 | ||
|
|
5e7be28daf | ||
|
|
d250f0b380 | ||
|
|
c19cf34505 | ||
|
|
37b2e6034f | ||
|
|
84e427ef5d | ||
|
|
95b53ba74b | ||
|
|
a8952d7867 | ||
|
|
2390daf2de | ||
|
|
b1357cf854 | ||
|
|
2c229c9b39 | ||
|
|
9a7dd7fb59 | ||
|
|
39a039cd1e | ||
|
|
e38c77198d | ||
|
|
ee9b9a2765 | ||
|
|
57711b9659 | ||
|
|
5e6426067a | ||
|
|
58d2edcacd | ||
|
|
3b17c9ee14 |
@@ -1,96 +0,0 @@
|
|||||||
---
|
|
||||||
name: mcp-development
|
|
||||||
description: "Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP."
|
|
||||||
license: MIT
|
|
||||||
metadata:
|
|
||||||
author: laravel
|
|
||||||
---
|
|
||||||
|
|
||||||
# MCP Development
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
Use `search-docs` for detailed Laravel MCP patterns and documentation.
|
|
||||||
|
|
||||||
## Basic Usage
|
|
||||||
|
|
||||||
Register MCP servers in `routes/ai.php`:
|
|
||||||
|
|
||||||
<!-- Register MCP Server -->
|
|
||||||
```php
|
|
||||||
use Laravel\Mcp\Facades\Mcp;
|
|
||||||
|
|
||||||
Mcp::web();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Creating MCP Primitives
|
|
||||||
|
|
||||||
Create MCP tools, resources, prompts, and servers using artisan commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php artisan make:mcp-tool ToolName # Create a tool
|
|
||||||
|
|
||||||
php artisan make:mcp-resource ResourceName # Create a resource
|
|
||||||
|
|
||||||
php artisan make:mcp-prompt PromptName # Create a prompt
|
|
||||||
|
|
||||||
php artisan make:mcp-server ServerName # Create a server
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After creating primitives, register them in your server's `$tools`, `$resources`, or `$prompts` properties.
|
|
||||||
|
|
||||||
### Tools
|
|
||||||
|
|
||||||
<!-- MCP Tool Example -->
|
|
||||||
```php
|
|
||||||
use Laravel\Mcp\Server\Tool;
|
|
||||||
use Laravel\Mcp\Server\Request;
|
|
||||||
use Laravel\Mcp\Server\Response;
|
|
||||||
|
|
||||||
class MyTool extends Tool
|
|
||||||
{
|
|
||||||
public function handle(Request $request): Response
|
|
||||||
{
|
|
||||||
return new Response(['result' => 'success']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Registering Primitives in a Server
|
|
||||||
|
|
||||||
Each MCP server must explicitly declare the tools, resources, and prompts it exposes.
|
|
||||||
|
|
||||||
<!-- Register Primitives in MCP Server -->
|
|
||||||
```php
|
|
||||||
use Laravel\Mcp\Server;
|
|
||||||
|
|
||||||
class AppServer extends Server
|
|
||||||
{
|
|
||||||
protected array $tools = [
|
|
||||||
\App\Mcp\Tools\MyTool::class,
|
|
||||||
];
|
|
||||||
|
|
||||||
protected array $resources = [
|
|
||||||
\App\Mcp\Resources\MyResource::class,
|
|
||||||
];
|
|
||||||
|
|
||||||
protected array $prompts = [
|
|
||||||
\App\Mcp\Prompts\MyPrompt::class,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
1. Check `routes/ai.php` for proper registration
|
|
||||||
2. Test tool via MCP client
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
- Running `mcp:start` command (it hangs waiting for input)
|
|
||||||
- Using HTTPS locally with Node-based MCP clients
|
|
||||||
- Not using `search-docs` for the latest MCP documentation
|
|
||||||
- Not registering MCP server routes in `routes/ai.php`
|
|
||||||
- Do not register `ai.php` in `bootstrap.php`; it is registered automatically.
|
|
||||||
- OAuth registration supports custom URI schemes (e.g., `cursor://`, `vscode://`) for native desktop clients via `mcp.custom_schemes` config
|
|
||||||
+7
-6
@@ -25,6 +25,7 @@
|
|||||||
**/compose.*.y*ml
|
**/compose.*.y*ml
|
||||||
!**/composer.json
|
!**/composer.json
|
||||||
!**/composer.lock
|
!**/composer.lock
|
||||||
|
**/Dockerfile*
|
||||||
**/node_modules
|
**/node_modules
|
||||||
**/npm-debug.log
|
**/npm-debug.log
|
||||||
**/obj
|
**/obj
|
||||||
@@ -40,9 +41,9 @@ vendor
|
|||||||
LICENSE
|
LICENSE
|
||||||
README.md
|
README.md
|
||||||
|
|
||||||
# Runtime-generated data must not become part of an image layer.
|
# Ignore development-related files and directories
|
||||||
storage/framework/cache/data/*
|
# For development environment, we will use mount bind to mount the source code to the container
|
||||||
storage/framework/sessions/*
|
# So we don't need to copy the source code to the container for development
|
||||||
storage/framework/testing/*
|
tests/
|
||||||
storage/framework/views/*
|
phpunit.xml
|
||||||
storage/logs/*
|
phpunit.xml.dist
|
||||||
|
|||||||
+1
-17
@@ -10,7 +10,7 @@ APP_LOCALE=en
|
|||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
APP_FAKER_LOCALE=en_US
|
APP_FAKER_LOCALE=en_US
|
||||||
|
|
||||||
LOG_STACK=stderr
|
LOG_STACK=daily
|
||||||
|
|
||||||
# Optional Discord error logging (append `,discord_errors` to LOG_STACK to enable it)
|
# Optional Discord error logging (append `,discord_errors` to LOG_STACK to enable it)
|
||||||
LOG_DISCORD_WEBHOOK_URL=
|
LOG_DISCORD_WEBHOOK_URL=
|
||||||
@@ -38,22 +38,6 @@ MAIL_ENCRYPTION=tls
|
|||||||
MAIL_FROM_ADDRESS=no-reply@example.com
|
MAIL_FROM_ADDRESS=no-reply@example.com
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
CACHE_STORE=database
|
|
||||||
QUEUE_CONNECTION=database
|
|
||||||
SESSION_DRIVER=database
|
|
||||||
|
|
||||||
FILESYSTEM_DISK=public
|
FILESYSTEM_DISK=public
|
||||||
AVATAR_FILESYSTEM_DISK=public
|
|
||||||
AVATAR_URLS_TEMPORARY=false
|
|
||||||
AVATAR_URL_EXPIRATION_MINUTES=15
|
|
||||||
|
|
||||||
# Production: set AVATAR_FILESYSTEM_DISK=s3 and inject these as runtime secrets.
|
|
||||||
AWS_ACCESS_KEY_ID=
|
|
||||||
AWS_SECRET_ACCESS_KEY=
|
|
||||||
AWS_DEFAULT_REGION=eu-central-1
|
|
||||||
AWS_BUCKET=
|
|
||||||
AWS_URL=
|
|
||||||
AWS_ENDPOINT=
|
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
name: "Lint and Test API"
|
|
||||||
description: "Run Pint and the Pest test suite for the Laravel API"
|
|
||||||
|
|
||||||
runs:
|
|
||||||
using: composite
|
|
||||||
steps:
|
|
||||||
- name: Setup PHP
|
|
||||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 #v2.37.2
|
|
||||||
with:
|
|
||||||
php-version: "8.5"
|
|
||||||
extensions: mbstring, pdo_sqlite
|
|
||||||
coverage: none
|
|
||||||
tools: composer:v2
|
|
||||||
|
|
||||||
- name: Verify Composer lockfile
|
|
||||||
run: |
|
|
||||||
if [ ! -f composer.lock ]; then
|
|
||||||
echo "::error::api/composer.lock not found"
|
|
||||||
exit 1
|
|
||||||
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
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
name: CI/CD
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: git.twyco.de
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
preconditions:
|
|
||||||
if: >
|
|
||||||
!(contains(github.event.head_commit.message, '[no-ci]') || contains(github.event.head_commit.message, '[skip-ci]'))
|
|
||||||
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:
|
|
||||||
- name: Check out repository
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
|
||||||
|
|
||||||
- name: Run required application checks
|
|
||||||
uses: ./.github/tests/test_and_check_app
|
|
||||||
|
|
||||||
publish:
|
|
||||||
name: Publish immutable image
|
|
||||||
if: >-
|
|
||||||
github.event_name == 'push' &&
|
|
||||||
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
|
|
||||||
needs:
|
|
||||||
- preconditions
|
|
||||||
- test_and_check_app
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Check out repository
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Validate ref and calculate image name
|
|
||||||
id: image
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -Eeuo pipefail
|
|
||||||
|
|
||||||
image_path="$(printf '%s' "${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]')"
|
|
||||||
image="${REGISTRY}/${image_path}"
|
|
||||||
|
|
||||||
case "${GITHUB_REF}" in
|
|
||||||
refs/heads/main)
|
|
||||||
printf -v run_attempt '%03d' "${GITHUB_RUN_ATTEMPT}"
|
|
||||||
tag="main-${GITHUB_RUN_NUMBER}-${run_attempt}-${GITHUB_SHA:0:12}"
|
|
||||||
environment="staging"
|
|
||||||
;;
|
|
||||||
refs/tags/*)
|
|
||||||
tag="${GITHUB_REF#refs/tags/}"
|
|
||||||
|
|
||||||
if [[ ! "${tag}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
|
|
||||||
echo "Production tags must match vMAJOR.MINOR.PATCH." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
git fetch --no-tags origin main:refs/remotes/origin/main
|
|
||||||
|
|
||||||
if ! git merge-base --is-ancestor "${GITHUB_SHA}" origin/main; then
|
|
||||||
echo "Production tag must point to a commit contained in main." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
environment="production"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unsupported ref: ${GITHUB_REF}" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
{
|
|
||||||
echo "image=${image}"
|
|
||||||
echo "tag=${tag}"
|
|
||||||
echo "environment=${environment}"
|
|
||||||
} >> "${GITHUB_OUTPUT}"
|
|
||||||
|
|
||||||
- name: Log in to private registry
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
|
||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -Eeuo pipefail
|
|
||||||
|
|
||||||
if [[ -z "${REGISTRY_USERNAME}" || -z "${REGISTRY_TOKEN}" ]]; then
|
|
||||||
echo "Registry credentials are missing." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf '%s' "${REGISTRY_TOKEN}" |
|
|
||||||
docker login \
|
|
||||||
--username "${REGISTRY_USERNAME}" \
|
|
||||||
--password-stdin \
|
|
||||||
"${REGISTRY}"
|
|
||||||
|
|
||||||
- name: Build and publish runtime image
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
IMAGE: ${{ steps.image.outputs.image }}
|
|
||||||
IMAGE_TAG: ${{ steps.image.outputs.tag }}
|
|
||||||
DEPLOYMENT_ENVIRONMENT: ${{ steps.image.outputs.environment }}
|
|
||||||
run: |
|
|
||||||
set -Eeuo pipefail
|
|
||||||
|
|
||||||
if [[ "${DEPLOYMENT_ENVIRONMENT}" == "production" ]] &&
|
|
||||||
docker buildx imagetools inspect "${IMAGE}:${IMAGE_TAG}" >/dev/null 2>&1; then
|
|
||||||
echo "Production image ${IMAGE}:${IMAGE_TAG} already exists and will not be overwritten." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
docker buildx build \
|
|
||||||
--file Dockerfile \
|
|
||||||
--target runtime \
|
|
||||||
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
||||||
--label "org.opencontainers.image.revision=${GITHUB_SHA}" \
|
|
||||||
--label "org.opencontainers.image.source=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \
|
|
||||||
--label "de.twyco.environment=${DEPLOYMENT_ENVIRONMENT}" \
|
|
||||||
--tag "${IMAGE}:${IMAGE_TAG}" \
|
|
||||||
--push \
|
|
||||||
.
|
|
||||||
|
|
||||||
- name: Remove registry credentials
|
|
||||||
if: ${{ always() }}
|
|
||||||
shell: bash
|
|
||||||
run: docker logout "${REGISTRY}" >/dev/null 2>&1 || true
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
name: linter
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- staging
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.4'
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: |
|
||||||
|
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
|
||||||
|
npm install
|
||||||
|
|
||||||
|
- name: Run Pint
|
||||||
|
run: composer lint
|
||||||
|
|
||||||
|
- name: Lint Frontend
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
# - name: Commit Changes
|
||||||
|
# uses: stefanzweifel/git-auto-commit-action@v7
|
||||||
|
# with:
|
||||||
|
# commit_message: fix code style
|
||||||
|
# commit_options: '--no-verify'
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
name: tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- staging
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
php-version: ['8.4']
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: ${{ matrix.php-version }}
|
||||||
|
tools: composer:v2
|
||||||
|
coverage: xdebug
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Install Node Dependencies
|
||||||
|
run: npm i
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||||
|
|
||||||
|
- name: Copy Environment File
|
||||||
|
run: cp .env.example .env
|
||||||
|
|
||||||
|
- name: Generate Application Key
|
||||||
|
run: php artisan key:generate
|
||||||
|
|
||||||
|
- name: Build Assets
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Tests
|
||||||
|
run: ./vendor/bin/phpunit
|
||||||
@@ -13,10 +13,10 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||||||
- inertiajs/inertia-laravel (INERTIA_LARAVEL) - v2
|
- inertiajs/inertia-laravel (INERTIA_LARAVEL) - v2
|
||||||
- laravel/fortify (FORTIFY) - v1
|
- laravel/fortify (FORTIFY) - v1
|
||||||
- laravel/framework (LARAVEL) - v13
|
- laravel/framework (LARAVEL) - v13
|
||||||
- laravel/mcp (MCP) - v0
|
|
||||||
- laravel/prompts (PROMPTS) - v0
|
- laravel/prompts (PROMPTS) - v0
|
||||||
- laravel/wayfinder (WAYFINDER) - v0
|
- laravel/wayfinder (WAYFINDER) - v0
|
||||||
- laravel/boost (BOOST) - v2
|
- laravel/boost (BOOST) - v2
|
||||||
|
- laravel/mcp (MCP) - v0
|
||||||
- laravel/pail (PAIL) - v1
|
- laravel/pail (PAIL) - v1
|
||||||
- laravel/pint (PINT) - v1
|
- laravel/pint (PINT) - v1
|
||||||
- laravel/sail (SAIL) - v1
|
- laravel/sail (SAIL) - v1
|
||||||
@@ -34,7 +34,7 @@ This project has domain-specific skills available. You MUST activate the relevan
|
|||||||
|
|
||||||
- `fortify-development` — ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.
|
- `fortify-development` — ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.
|
||||||
- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
|
- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
|
||||||
- `mcp-development` — Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP.
|
- `project-route-structure` — Always apply in this project when creating, moving, or refactoring Laravel route definitions in routes/**/*.php; changing route prefixes, names, middleware, scoped bindings, or route files; or adding frontend-facing endpoints that Wayfinder generates. Documents the KanbanGit route-file layout and grouping conventions.
|
||||||
- `wayfinder-development` — Use this skill for Laravel Wayfinder which auto-generates typed functions for Laravel controllers and routes. ALWAYS use this skill when frontend code needs to call backend routes or controller actions. Trigger when: connecting any React/Vue/Svelte/Inertia frontend to Laravel controllers, routes, building end-to-end features with both frontend and backend, wiring up forms or links to backend endpoints, fixing route-related TypeScript errors, importing from @/actions or @/routes, or running wayfinder:generate. Use Wayfinder route functions instead of hardcoded URLs. Covers: wayfinder() vite plugin, .url()/.get()/.post()/.form(), query params, route model binding, tree-shaking. Do not use for backend-only task
|
- `wayfinder-development` — Use this skill for Laravel Wayfinder which auto-generates typed functions for Laravel controllers and routes. ALWAYS use this skill when frontend code needs to call backend routes or controller actions. Trigger when: connecting any React/Vue/Svelte/Inertia frontend to Laravel controllers, routes, building end-to-end features with both frontend and backend, wiring up forms or links to backend endpoints, fixing route-related TypeScript errors, importing from @/actions or @/routes, or running wayfinder:generate. Use Wayfinder route functions instead of hardcoded URLs. Covers: wayfinder() vite plugin, .url()/.get()/.post()/.form(), query params, route model binding, tree-shaking. Do not use for backend-only task
|
||||||
- `inertia-vue-development` — Develops Inertia.js v2 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation.
|
- `inertia-vue-development` — Develops Inertia.js v2 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation.
|
||||||
- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
|
- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
|
||||||
|
|||||||
-110
@@ -1,110 +0,0 @@
|
|||||||
ARG COMPOSER_IMAGE=composer:2.9.5@sha256:698d3801b2a622ace460c4743c781282fcbcb733a4cbf8b31c44731e846585e8
|
|
||||||
ARG NODE_IMAGE=node:22-bookworm-slim@sha256:d649c27dae7ba0137b3cef5dd75baa422c08dc3d9e3fc0c23dfb172dc3cc6436
|
|
||||||
ARG PHP_IMAGE=php:8.4-fpm-bookworm@sha256:c5fb7a0c02f4efe280691910c8b734995fa83598cdcf3115ef5dcb2e4617681c
|
|
||||||
|
|
||||||
FROM ${COMPOSER_IMAGE} AS composer_binary
|
|
||||||
FROM ${NODE_IMAGE} AS node_binary
|
|
||||||
|
|
||||||
FROM ${PHP_IMAGE} AS php_base
|
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends \
|
|
||||||
ca-certificates \
|
|
||||||
curl \
|
|
||||||
gettext-base \
|
|
||||||
git \
|
|
||||||
libfcgi-bin \
|
|
||||||
libfreetype6-dev \
|
|
||||||
libicu-dev \
|
|
||||||
libjpeg62-turbo-dev \
|
|
||||||
libmagickwand-dev \
|
|
||||||
libonig-dev \
|
|
||||||
libpng-dev \
|
|
||||||
libpq-dev \
|
|
||||||
libsqlite3-dev \
|
|
||||||
libssl-dev \
|
|
||||||
libxml2-dev \
|
|
||||||
libzip-dev \
|
|
||||||
nginx \
|
|
||||||
procps \
|
|
||||||
unzip \
|
|
||||||
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
|
||||||
&& docker-php-ext-install -j"$(nproc)" \
|
|
||||||
bcmath \
|
|
||||||
gd \
|
|
||||||
intl \
|
|
||||||
opcache \
|
|
||||||
pcntl \
|
|
||||||
pdo_mysql \
|
|
||||||
pdo_pgsql \
|
|
||||||
pdo_sqlite \
|
|
||||||
pgsql \
|
|
||||||
soap \
|
|
||||||
zip \
|
|
||||||
&& pecl install imagick \
|
|
||||||
&& docker-php-ext-enable imagick \
|
|
||||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
|
||||||
|
|
||||||
COPY --from=composer_binary /usr/bin/composer /usr/local/bin/composer
|
|
||||||
|
|
||||||
WORKDIR /var/www
|
|
||||||
|
|
||||||
FROM php_base AS build
|
|
||||||
|
|
||||||
COPY --from=node_binary /usr/local/ /usr/local/
|
|
||||||
COPY . /var/www
|
|
||||||
|
|
||||||
RUN composer install --prefer-dist --no-interaction --no-progress \
|
|
||||||
&& corepack enable pnpm \
|
|
||||||
&& pnpm install --frozen-lockfile \
|
|
||||||
&& pnpm run build
|
|
||||||
|
|
||||||
FROM build AS test
|
|
||||||
|
|
||||||
RUN touch .env \
|
|
||||||
&& composer audit --locked --no-interaction \
|
|
||||||
&& COMPOSER_ALLOW_SUPERUSER=1 composer ci:check
|
|
||||||
|
|
||||||
FROM build AS production_builder
|
|
||||||
|
|
||||||
RUN composer install \
|
|
||||||
--classmap-authoritative \
|
|
||||||
--no-dev \
|
|
||||||
--no-interaction \
|
|
||||||
--no-progress \
|
|
||||||
--prefer-dist \
|
|
||||||
&& rm -rf node_modules tests phpunit.xml
|
|
||||||
|
|
||||||
FROM php_base AS runtime
|
|
||||||
|
|
||||||
COPY docker/entrypoint.sh /usr/local/bin/kanbangit-entrypoint
|
|
||||||
COPY docker/nginx/nginx.conf.template /etc/nginx/nginx.conf.template
|
|
||||||
COPY docker/php-fpm-healthcheck.sh /usr/local/bin/php-fpm-healthcheck
|
|
||||||
COPY --from=production_builder --chown=www-data:www-data /var/www /var/www
|
|
||||||
|
|
||||||
RUN chmod +x /usr/local/bin/kanbangit-entrypoint /usr/local/bin/php-fpm-healthcheck \
|
|
||||||
&& mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \
|
|
||||||
&& printf '%s\n' \
|
|
||||||
'[global]' \
|
|
||||||
'daemonize = no' \
|
|
||||||
'' \
|
|
||||||
'[www]' \
|
|
||||||
'pm.status_path = /status' \
|
|
||||||
> /usr/local/etc/php-fpm.d/zz-kanbangit.conf \
|
|
||||||
&& mkdir -p \
|
|
||||||
/var/www/bootstrap/cache \
|
|
||||||
/var/www/storage/app/public \
|
|
||||||
/var/www/storage/framework/cache \
|
|
||||||
/var/www/storage/framework/sessions \
|
|
||||||
/var/www/storage/framework/testing \
|
|
||||||
/var/www/storage/framework/views \
|
|
||||||
/var/www/storage/logs \
|
|
||||||
&& ln -s ../storage/app/public /var/www/public/storage \
|
|
||||||
&& chown -R www-data:www-data /var/www/bootstrap/cache /var/www/storage
|
|
||||||
|
|
||||||
USER www-data
|
|
||||||
|
|
||||||
EXPOSE 8080 9000
|
|
||||||
|
|
||||||
ENTRYPOINT ["kanbangit-entrypoint"]
|
|
||||||
CMD ["php-fpm"]
|
|
||||||
@@ -47,7 +47,6 @@ class BuildProjectBacklogPayload
|
|||||||
'tickets' => TicketResource::collection(
|
'tickets' => TicketResource::collection(
|
||||||
Ticket::query()
|
Ticket::query()
|
||||||
->with([
|
->with([
|
||||||
'assignee',
|
|
||||||
'ticketPriority',
|
'ticketPriority',
|
||||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||||
'ticketStatus.boardColumn',
|
'ticketStatus.boardColumn',
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ class BuildProjectBoardPayload
|
|||||||
'ticketStatuses',
|
'ticketStatuses',
|
||||||
'tickets' => fn ($query) => $query
|
'tickets' => fn ($query) => $query
|
||||||
->with([
|
->with([
|
||||||
'assignee',
|
|
||||||
'ticketPriority',
|
'ticketPriority',
|
||||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||||
'ticketStatus.boardColumn',
|
'ticketStatus.boardColumn',
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ use App\Http\Resources\TicketPriorityResource;
|
|||||||
use App\Http\Resources\TicketReferenceResource;
|
use App\Http\Resources\TicketReferenceResource;
|
||||||
use App\Http\Resources\TicketStatusResource;
|
use App\Http\Resources\TicketStatusResource;
|
||||||
use App\Http\Resources\TicketTypeResource;
|
use App\Http\Resources\TicketTypeResource;
|
||||||
|
use App\Models\Ticket;
|
||||||
use App\Http\Resources\UserResource;
|
use App\Http\Resources\UserResource;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Ticket;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|
||||||
class BuildProjectTicketOptionsPayload
|
class BuildProjectTicketOptionsPayload
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ 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,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,15 +29,7 @@ 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,
|
||||||
@@ -59,14 +50,11 @@ 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' => Arr::get($attributes, 'author_user_id', $actor?->id),
|
'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,
|
||||||
@@ -80,17 +68,6 @@ 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',
|
||||||
|
|||||||
@@ -30,10 +30,9 @@ class UpdateUserAvatar
|
|||||||
$color = $this->colorFor($user);
|
$color = $this->colorFor($user);
|
||||||
$path = sprintf('avatars/generated/%d-%s.svg', $user->id, (string) Str::uuid());
|
$path = sprintf('avatars/generated/%d-%s.svg', $user->id, (string) Str::uuid());
|
||||||
|
|
||||||
$stored = Storage::disk($this->avatarDisk())->put(
|
$stored = Storage::disk('public')->put(
|
||||||
$path,
|
$path,
|
||||||
$this->svg($this->initials($user->name), $color),
|
$this->svg($this->initials($user->name), $color),
|
||||||
['ContentType' => 'image/svg+xml'],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (! $stored) {
|
if (! $stored) {
|
||||||
@@ -54,7 +53,7 @@ class UpdateUserAvatar
|
|||||||
public function upload(User $user, UploadedFile $avatar): User
|
public function upload(User $user, UploadedFile $avatar): User
|
||||||
{
|
{
|
||||||
$oldPath = $user->avatar_path;
|
$oldPath = $user->avatar_path;
|
||||||
$path = $avatar->store('avatars/uploads', $this->avatarDisk());
|
$path = $avatar->store('avatars/uploads', 'public');
|
||||||
|
|
||||||
if (! is_string($path)) {
|
if (! is_string($path)) {
|
||||||
throw new RuntimeException('Uploaded avatar could not be stored.');
|
throw new RuntimeException('Uploaded avatar could not be stored.');
|
||||||
@@ -91,14 +90,7 @@ class UpdateUserAvatar
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Storage::disk($this->avatarDisk())->delete($path);
|
Storage::disk('public')->delete($path);
|
||||||
}
|
|
||||||
|
|
||||||
private function avatarDisk(): string
|
|
||||||
{
|
|
||||||
$disk = config('filesystems.avatar_disk', 'public');
|
|
||||||
|
|
||||||
return is_string($disk) && $disk !== '' ? $disk : 'public';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function initials(string $name): string
|
private function initials(string $name): string
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use App\Models\User;
|
|||||||
use Illuminate\Console\Attributes\Description;
|
use Illuminate\Console\Attributes\Description;
|
||||||
use Illuminate\Console\Attributes\Signature;
|
use Illuminate\Console\Attributes\Signature;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
#[Signature('app:create-admin-user {name=Admin} {email=admin@kanbangit.test} {password=test}')]
|
#[Signature('app:create-admin-user {name=Admin} {email=admin@kanbangit.test} {password=test}')]
|
||||||
@@ -26,30 +25,20 @@ class CreateAdminUser extends Command
|
|||||||
*/
|
*/
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
DB::beginTransaction();
|
$user = User::create([
|
||||||
|
'name' => $this->argument('name'),
|
||||||
|
'email' => $this->argument('email'),
|
||||||
|
'password' => Hash::make($this->argument('password')),
|
||||||
|
]);
|
||||||
|
|
||||||
try {
|
$this->avatars->generate($user);
|
||||||
$user = User::create([
|
|
||||||
'name' => $this->argument('name'),
|
|
||||||
'email' => $this->argument('email'),
|
|
||||||
'password' => Hash::make($this->argument('password')),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->avatars->generate($user);
|
$user->permissionAssignments()->create([
|
||||||
|
'permission' => Permission::GlobalAdmin,
|
||||||
|
]);
|
||||||
|
|
||||||
$user->permissionAssignments()->create([
|
$this->info(sprintf('Admin User created with emil=\'%s\' and password=\'%s\'', $this->argument('email'), $this->argument('password')));
|
||||||
'permission' => Permission::GlobalAdmin,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->info(sprintf('Admin User created with emil=\'%s\' and password=\'%s\'', $this->argument('email'), $this->argument('password')));
|
return self::SUCCESS;
|
||||||
DB::commit();
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
} catch (\Exception $exception) {
|
|
||||||
$this->error('Admin User could not be created: ', $exception->getMessage());
|
|
||||||
DB::rollBack();
|
|
||||||
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Actions\Tickets\ListAssignedTicketsByVisibleProject;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Inertia\Inertia;
|
|
||||||
use Inertia\Response;
|
|
||||||
|
|
||||||
class DashboardController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly ListAssignedTicketsByVisibleProject $listAssignedTicketsByVisibleProject,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function __invoke(Request $request): Response
|
|
||||||
{
|
|
||||||
$user = $request->user();
|
|
||||||
|
|
||||||
return Inertia::render('Dashboard', [
|
|
||||||
'assignedTicketsByProject' => $this->listAssignedTicketsByVisibleProject->handle($user, $user),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,11 +16,7 @@ class HandleAppearance
|
|||||||
*/
|
*/
|
||||||
public function handle(Request $request, Closure $next): Response
|
public function handle(Request $request, Closure $next): Response
|
||||||
{
|
{
|
||||||
$appearance = $request->cookie('appearance');
|
View::share('appearance', $request->cookie('appearance') ?? 'system');
|
||||||
$appearance = in_array($appearance, ['light', 'dark', 'system'], true) ? $appearance : 'system';
|
|
||||||
|
|
||||||
$request->attributes->set('appearance', $appearance);
|
|
||||||
View::share('appearance', $appearance);
|
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ namespace App\Http\Middleware;
|
|||||||
|
|
||||||
use App\Actions\Permissions\PermissionService;
|
use App\Actions\Permissions\PermissionService;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
use App\Models\Project;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Middleware;
|
use Inertia\Middleware;
|
||||||
|
|
||||||
@@ -47,11 +46,6 @@ class HandleInertiaRequests extends Middleware
|
|||||||
return [
|
return [
|
||||||
...parent::share($request),
|
...parent::share($request),
|
||||||
'name' => config('app.name'),
|
'name' => config('app.name'),
|
||||||
'appearance' => $request->attributes->get('appearance', 'system'),
|
|
||||||
'localization' => [
|
|
||||||
'locale' => app()->getLocale(),
|
|
||||||
'timeZone' => config('app.timezone'),
|
|
||||||
],
|
|
||||||
'auth' => [
|
'auth' => [
|
||||||
'permissions' => [
|
'permissions' => [
|
||||||
'canManageTeams' => $user !== null && ($this->permissionService->canManageAnyTeam($user) || $this->permissionService->allows($user, Permission::TeamCreate)),
|
'canManageTeams' => $user !== null && ($this->permissionService->canManageAnyTeam($user) || $this->permissionService->allows($user, Permission::TeamCreate)),
|
||||||
@@ -61,36 +55,6 @@ class HandleInertiaRequests extends Middleware
|
|||||||
'user' => $user,
|
'user' => $user,
|
||||||
],
|
],
|
||||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||||
'navigation' => fn (): array => [
|
|
||||||
'project' => $this->projectNavigation($request),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{id: int, name: string, key: string, canManage: bool, canCreateTickets: bool}|null
|
|
||||||
*/
|
|
||||||
private function projectNavigation(Request $request): ?array
|
|
||||||
{
|
|
||||||
$project = $request->route('project');
|
|
||||||
$user = $request->user();
|
|
||||||
|
|
||||||
if (! $project instanceof Project || $user === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$permissions = $this->permissionService->effectivePermissions($user, $project);
|
|
||||||
|
|
||||||
if (! in_array(Permission::ProjectRead, $permissions, true)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'id' => $project->id,
|
|
||||||
'name' => $project->name,
|
|
||||||
'key' => $project->key,
|
|
||||||
'canManage' => in_array(Permission::ProjectAdmin, $permissions, true),
|
|
||||||
'canCreateTickets' => in_array(Permission::ProjectWrite, $permissions, true),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,9 @@
|
|||||||
|
|
||||||
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;
|
||||||
@@ -17,7 +15,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, 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.')]
|
#[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.')]
|
||||||
class KanbanGitServer extends Server
|
class KanbanGitServer extends Server
|
||||||
{
|
{
|
||||||
protected array $tools = [
|
protected array $tools = [
|
||||||
@@ -27,8 +25,6 @@ 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 = [
|
||||||
|
|||||||
@@ -120,24 +120,6 @@ 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
|
||||||
@@ -207,12 +189,10 @@ 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',
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
<?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.'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Mcp\Tools;
|
|
||||||
|
|
||||||
use App\Http\Resources\ProjectResource;
|
|
||||||
use App\Http\Resources\TicketTypeResource;
|
|
||||||
use App\Mcp\Support\KanbanGitMcp;
|
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
|
||||||
use Illuminate\JsonSchema\Types\Type;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Laravel\Mcp\Request;
|
|
||||||
use Laravel\Mcp\Response;
|
|
||||||
use Laravel\Mcp\ResponseFactory;
|
|
||||||
use Laravel\Mcp\Server\Attributes\Description;
|
|
||||||
use Laravel\Mcp\Server\Attributes\Name;
|
|
||||||
use Laravel\Mcp\Server\Tool;
|
|
||||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
|
||||||
|
|
||||||
#[Name('list_ticket_types')]
|
|
||||||
#[Description('List all ticket types in a visible project in their configured order, including IDs, names, and is_parent. Use a returned ID as ticket_type_id when creating a ticket. Types with is_parent=true allow tickets of that type to have child tickets; set parent_ticket_id on create_ticket to attach a child to an existing parent ticket.')]
|
|
||||||
#[IsReadOnly]
|
|
||||||
class ListTicketTypes extends Tool
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function handle(Request $request): Response|ResponseFactory
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'project_id' => ['required', 'integer'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user = $this->kanbanGitMcp->user($request);
|
|
||||||
$project = $this->kanbanGitMcp->projectForRead($user, (int) Arr::get($validated, 'project_id'));
|
|
||||||
|
|
||||||
return Response::structured([
|
|
||||||
'data' => [
|
|
||||||
'project' => ProjectResource::make($project)->resolve(),
|
|
||||||
'ticket_types' => TicketTypeResource::collection($project->ticketTypes()->get())->resolve(),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, Type>
|
|
||||||
*/
|
|
||||||
public function schema(JsonSchema $schema): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'project_id' => $schema->integer()
|
|
||||||
->description('Project ID. Requires permission to view this project.')
|
|
||||||
->required(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,6 @@ 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
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ class NotificationAction extends Model
|
|||||||
{
|
{
|
||||||
/** @use HasFactory<NotificationActionFactory> */
|
/** @use HasFactory<NotificationActionFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
use SyncsProjectTemplateOwnership;
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ class RepositoryConnection extends Model
|
|||||||
{
|
{
|
||||||
/** @use HasFactory<RepositoryConnectionFactory> */
|
/** @use HasFactory<RepositoryConnectionFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
use SyncsProjectTemplateOwnership;
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ 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
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ 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
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ 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
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ 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
|
||||||
|
|||||||
+3
-19
@@ -77,28 +77,12 @@ class User extends Authenticatable
|
|||||||
protected function avatar(): Attribute
|
protected function avatar(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::get(
|
return Attribute::get(
|
||||||
fn (): ?string => $this->avatarUrl(),
|
fn (): ?string => filled($this->avatar_path)
|
||||||
|
? Storage::disk('public')->url($this->avatar_path)
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function avatarUrl(): ?string
|
|
||||||
{
|
|
||||||
if (blank($this->avatar_path)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$diskName = config('filesystems.avatar_disk', 'public');
|
|
||||||
$disk = Storage::disk(is_string($diskName) && $diskName !== '' ? $diskName : 'public');
|
|
||||||
|
|
||||||
if ((bool) config('filesystems.avatar_urls_temporary', false)) {
|
|
||||||
$expirationMinutes = max(1, (int) config('filesystems.avatar_url_expiration_minutes', 15));
|
|
||||||
|
|
||||||
return $disk->temporaryUrl($this->avatar_path, now()->addMinutes($expirationMinutes));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $disk->url($this->avatar_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function permissionAssignments(): MorphMany
|
public function permissionAssignments(): MorphMany
|
||||||
{
|
{
|
||||||
return $this->morphMany(PermissionAssignment::class, 'assignee');
|
return $this->morphMany(PermissionAssignment::class, 'assignee');
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
"skills": [
|
"skills": [
|
||||||
"fortify-development",
|
"fortify-development",
|
||||||
"laravel-best-practices",
|
"laravel-best-practices",
|
||||||
"mcp-development",
|
|
||||||
"wayfinder-development",
|
"wayfinder-development",
|
||||||
"inertia-vue-development",
|
"inertia-vue-development",
|
||||||
"tailwindcss-development"
|
"tailwindcss-development"
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
$middleware->trustProxies(at: '*');
|
|
||||||
|
|
||||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
||||||
|
|
||||||
$middleware->web(append: [
|
$middleware->web(append: [
|
||||||
|
|||||||
+8
-9
@@ -12,11 +12,10 @@
|
|||||||
"php": "^8.4",
|
"php": "^8.4",
|
||||||
"inertiajs/inertia-laravel": "^2.0",
|
"inertiajs/inertia-laravel": "^2.0",
|
||||||
"laravel/fortify": "^1.34",
|
"laravel/fortify": "^1.34",
|
||||||
"laravel/framework": "^v13.26.1",
|
"laravel/framework": "^13.0",
|
||||||
"laravel/mcp": "^0.6",
|
"laravel/mcp": "^0.6",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
"laravel/wayfinder": "^0.1.14",
|
"laravel/wayfinder": "^0.1.14",
|
||||||
"league/flysystem-aws-s3-v3": "^3.0",
|
|
||||||
"scalar/laravel": "^0.2.1"
|
"scalar/laravel": "^0.2.1"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
@@ -47,15 +46,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",
|
||||||
"pnpm install",
|
"npm install",
|
||||||
"pnpm run build"
|
"npm run build"
|
||||||
],
|
],
|
||||||
"dev": [
|
"dev": [
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
"@php artisan dev"
|
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
||||||
],
|
],
|
||||||
"dev:ssr": [
|
"dev:ssr": [
|
||||||
"pnpm run build:ssr",
|
"npm 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 +66,9 @@
|
|||||||
],
|
],
|
||||||
"ci:check": [
|
"ci:check": [
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
"pnpm audit --audit-level=high",
|
"npm run lint:check",
|
||||||
"pnpm run lint:check",
|
"npm run format:check",
|
||||||
"pnpm run types:check",
|
"npm run types:check",
|
||||||
"@test"
|
"@test"
|
||||||
],
|
],
|
||||||
"test": [
|
"test": [
|
||||||
|
|||||||
Generated
+332
-791
File diff suppressed because it is too large
Load Diff
@@ -15,12 +15,6 @@ return [
|
|||||||
|
|
||||||
'default' => env('FILESYSTEM_DISK', 'local'),
|
'default' => env('FILESYSTEM_DISK', 'local'),
|
||||||
|
|
||||||
'avatar_disk' => env('AVATAR_FILESYSTEM_DISK', env('FILESYSTEM_DISK', 'public')),
|
|
||||||
|
|
||||||
'avatar_urls_temporary' => env('AVATAR_URLS_TEMPORARY', false),
|
|
||||||
|
|
||||||
'avatar_url_expiration_minutes' => env('AVATAR_URL_EXPIRATION_MINUTES', 15),
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Filesystem Disks
|
| Filesystem Disks
|
||||||
|
|||||||
+12
-32
@@ -1,38 +1,33 @@
|
|||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
container_name: kanbangit-web
|
container_name: kanbangit-web
|
||||||
image: kanbangit-app:local
|
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: ./docker/nginx/Dockerfile
|
||||||
target: runtime
|
|
||||||
command: nginx
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:80"
|
||||||
|
volumes:
|
||||||
|
- kanbangit-storage:/var/www/storage:ro
|
||||||
|
- kanbangit-public-assets:/var/www/public/build:ro
|
||||||
env_file:
|
env_file:
|
||||||
- .env.production
|
- .env.production
|
||||||
environment:
|
|
||||||
PHP_FPM_UPSTREAM: php-fpm:9000
|
|
||||||
networks:
|
networks:
|
||||||
- kanbangit-web
|
- kanbangit-web
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:8080/up"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
depends_on:
|
depends_on:
|
||||||
php-fpm:
|
php-fpm:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
php-fpm:
|
php-fpm:
|
||||||
container_name: kanbangit-php-fpm
|
container_name: kanbangit-php-fpm
|
||||||
image: kanbangit-app:local
|
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: ./docker/php-fpm/Dockerfile
|
||||||
target: runtime
|
target: production
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- kanbangit-storage:/var/www/storage
|
||||||
|
- kanbangit-public-assets:/var/www/public/build
|
||||||
env_file:
|
env_file:
|
||||||
- .env.production
|
- .env.production
|
||||||
networks:
|
networks:
|
||||||
@@ -47,23 +42,6 @@ services:
|
|||||||
mariadb:
|
mariadb:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
worker:
|
|
||||||
container_name: kanbangit-worker
|
|
||||||
image: kanbangit-app:local
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
target: runtime
|
|
||||||
command: php artisan queue:work --sleep=3 --tries=3 --timeout=60 --max-time=3600
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
|
||||||
- .env.production
|
|
||||||
networks:
|
|
||||||
- kanbangit-db
|
|
||||||
depends_on:
|
|
||||||
mariadb:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
mariadb:
|
mariadb:
|
||||||
container_name: kanbangit-db
|
container_name: kanbangit-db
|
||||||
image: mariadb:11.4
|
image: mariadb:11.4
|
||||||
@@ -87,3 +65,5 @@ networks:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mariadb-data-production:
|
mariadb-data-production:
|
||||||
|
kanbangit-storage:
|
||||||
|
kanbangit-public-assets:
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
if [ "${1:-}" = "nginx" ]; then
|
|
||||||
: "${PHP_FPM_UPSTREAM:=127.0.0.1:9000}"
|
|
||||||
export PHP_FPM_UPSTREAM
|
|
||||||
|
|
||||||
envsubst '${PHP_FPM_UPSTREAM}' \
|
|
||||||
< /etc/nginx/nginx.conf.template \
|
|
||||||
> /tmp/nginx.conf
|
|
||||||
|
|
||||||
exec nginx -c /tmp/nginx.conf -g 'daemon off;'
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p \
|
|
||||||
bootstrap/cache \
|
|
||||||
storage/framework/cache \
|
|
||||||
storage/framework/sessions \
|
|
||||||
storage/framework/testing \
|
|
||||||
storage/framework/views \
|
|
||||||
storage/logs
|
|
||||||
|
|
||||||
php artisan config:cache --no-ansi
|
|
||||||
php artisan route:cache --no-ansi
|
|
||||||
|
|
||||||
exec "$@"
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
FROM php:8.4-cli-bookworm AS builder
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl ca-certificates git unzip \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||||
|
&& apt-get update && apt-get install -y --no-install-recommends nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN curl -sS https://getcomposer.org/installer | php -- \
|
||||||
|
--install-dir=/usr/local/bin --filename=composer
|
||||||
|
WORKDIR /var/www
|
||||||
|
COPY . /var/www
|
||||||
|
RUN composer install --no-dev --prefer-dist --no-interaction --no-progress --optimize-autoloader
|
||||||
|
RUN npm ci && npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY ./docker/nginx/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
COPY --from=builder /var/www/public /var/www/public
|
||||||
|
WORKDIR /var/www/public
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
sendfile on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
client_max_body_size 100m;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /var/www/public;
|
||||||
|
index index.php index.html;
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.php?$query_string;
|
||||||
|
}
|
||||||
|
location ^~ /storage/ {
|
||||||
|
alias /var/www/storage/app/public/;
|
||||||
|
try_files $uri =404;
|
||||||
|
access_log off;
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
}
|
||||||
|
location ~ \.php$ {
|
||||||
|
fastcgi_pass php-fpm:9000;
|
||||||
|
fastcgi_index index.php;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
include fastcgi_params;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
pid /tmp/nginx.pid;
|
|
||||||
error_log /dev/stderr warn;
|
|
||||||
|
|
||||||
events {
|
|
||||||
worker_connections 1024;
|
|
||||||
}
|
|
||||||
|
|
||||||
http {
|
|
||||||
include /etc/nginx/mime.types;
|
|
||||||
default_type application/octet-stream;
|
|
||||||
|
|
||||||
access_log /dev/stdout;
|
|
||||||
client_body_temp_path /tmp/client_body;
|
|
||||||
proxy_temp_path /tmp/proxy;
|
|
||||||
fastcgi_temp_path /tmp/fastcgi;
|
|
||||||
uwsgi_temp_path /tmp/uwsgi;
|
|
||||||
scgi_temp_path /tmp/scgi;
|
|
||||||
|
|
||||||
sendfile on;
|
|
||||||
keepalive_timeout 65;
|
|
||||||
client_max_body_size 3m;
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 8080;
|
|
||||||
server_name _;
|
|
||||||
root /var/www/public;
|
|
||||||
index index.php;
|
|
||||||
|
|
||||||
location = /nginx-health {
|
|
||||||
access_log off;
|
|
||||||
default_type text/plain;
|
|
||||||
return 200 'ok';
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.php?$query_string;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /index.php {
|
|
||||||
include /etc/nginx/fastcgi_params;
|
|
||||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
|
||||||
fastcgi_param HTTP_PROXY '';
|
|
||||||
fastcgi_param HTTP_X_FORWARDED_FOR $http_x_forwarded_for;
|
|
||||||
fastcgi_param HTTP_X_FORWARDED_HOST $http_x_forwarded_host;
|
|
||||||
fastcgi_param HTTP_X_FORWARDED_PORT $http_x_forwarded_port;
|
|
||||||
fastcgi_param HTTP_X_FORWARDED_PROTO $http_x_forwarded_proto;
|
|
||||||
fastcgi_pass ${PHP_FPM_UPSTREAM};
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ \.php$ {
|
|
||||||
return 404;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ /\. {
|
|
||||||
deny all;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
SCRIPT_NAME=/status \
|
|
||||||
SCRIPT_FILENAME=/status \
|
|
||||||
REQUEST_METHOD=GET \
|
|
||||||
cgi-fcgi -bind -connect 127.0.0.1:9000 2>/dev/null | grep -q '^pool:'
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
FROM php:8.4-fpm AS builder
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
unzip \
|
||||||
|
libpq-dev \
|
||||||
|
libonig-dev \
|
||||||
|
libssl-dev \
|
||||||
|
libxml2-dev \
|
||||||
|
libcurl4-openssl-dev \
|
||||||
|
libicu-dev \
|
||||||
|
libzip-dev \
|
||||||
|
libmagickwand-dev \
|
||||||
|
libpng-dev \
|
||||||
|
libjpeg-dev \
|
||||||
|
libfreetype6-dev \
|
||||||
|
&& docker-php-ext-install -j$(nproc) \
|
||||||
|
pdo_mysql \
|
||||||
|
pdo_pgsql \
|
||||||
|
pgsql \
|
||||||
|
opcache \
|
||||||
|
intl \
|
||||||
|
zip \
|
||||||
|
bcmath \
|
||||||
|
soap \
|
||||||
|
&& docker-php-ext-configure gd --with-jpeg --with-freetype \
|
||||||
|
&& docker-php-ext-install -j$(nproc) gd \
|
||||||
|
&& pecl install imagick \
|
||||||
|
&& docker-php-ext-enable imagick \
|
||||||
|
&& apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||||
|
WORKDIR /var/www
|
||||||
|
COPY . /var/www
|
||||||
|
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
|
||||||
|
&& composer install --no-dev --optimize-autoloader --no-interaction --no-progress --prefer-dist
|
||||||
|
FROM php:8.4-fpm AS production
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libpq-dev \
|
||||||
|
libicu-dev \
|
||||||
|
libzip-dev \
|
||||||
|
libfcgi-bin \
|
||||||
|
procps \
|
||||||
|
libmagickwand-dev \
|
||||||
|
&& apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||||
|
RUN curl -o /usr/local/bin/php-fpm-healthcheck \
|
||||||
|
https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/master/php-fpm-healthcheck \
|
||||||
|
&& chmod +x /usr/local/bin/php-fpm-healthcheck
|
||||||
|
COPY ./docker/php-fpm/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||||
|
COPY ./storage /var/www/storage-init
|
||||||
|
COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/
|
||||||
|
COPY --from=builder /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/
|
||||||
|
COPY --from=builder /usr/local/bin/docker-php-ext-* /usr/local/bin/
|
||||||
|
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
|
||||||
|
RUN printf '%s\n' \
|
||||||
|
'[global]' \
|
||||||
|
'daemonize = no' \
|
||||||
|
'' \
|
||||||
|
'[www]' \
|
||||||
|
'pm.status_path = /status' \
|
||||||
|
> /usr/local/etc/php-fpm.d/zz-docker.conf
|
||||||
|
COPY --from=builder /var/www /var/www
|
||||||
|
WORKDIR /var/www
|
||||||
|
RUN chown -R www-data:www-data /var/www
|
||||||
|
USER www-data
|
||||||
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
|
EXPOSE 9000
|
||||||
|
CMD ["php-fpm"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
if [ ! "$(ls -A /var/www/storage)" ]; then
|
||||||
|
echo "Initializing storage directory..."
|
||||||
|
cp -R /var/www/storage-init/. /var/www/storage
|
||||||
|
chown -R www-data:www-data /var/www/storage
|
||||||
|
fi
|
||||||
|
rm -rf /var/www/storage-init
|
||||||
|
php artisan config:cache
|
||||||
|
php artisan route:cache
|
||||||
|
exec "$@"
|
||||||
@@ -25,7 +25,6 @@ const prettierOptions = {
|
|||||||
const eslintConfig = [
|
const eslintConfig = [
|
||||||
{
|
{
|
||||||
ignores: [
|
ignores: [
|
||||||
'bootstrap/ssr/**',
|
|
||||||
'**/dist/**',
|
'**/dist/**',
|
||||||
'node_modules/**',
|
'node_modules/**',
|
||||||
'.github/**',
|
'.github/**',
|
||||||
@@ -38,10 +37,7 @@ const eslintConfig = [
|
|||||||
'bootstrap/cache/**',
|
'bootstrap/cache/**',
|
||||||
'public/build/**',
|
'public/build/**',
|
||||||
'public/hot',
|
'public/hot',
|
||||||
'resources/js/actions/**',
|
|
||||||
'resources/js/components/ui/**',
|
'resources/js/components/ui/**',
|
||||||
'resources/js/routes/**',
|
|
||||||
'resources/js/wayfinder/**',
|
|
||||||
'resources/views/mail/**',
|
'resources/views/mail/**',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+6969
File diff suppressed because it is too large
Load Diff
@@ -2,13 +2,6 @@
|
|||||||
"$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",
|
||||||
@@ -46,7 +39,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -20,8 +20,6 @@
|
|||||||
<php>
|
<php>
|
||||||
<env name="APP_ENV" value="testing"/>
|
<env name="APP_ENV" value="testing"/>
|
||||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||||
<env name="AVATAR_FILESYSTEM_DISK" value="public"/>
|
|
||||||
<env name="AVATAR_URLS_TEMPORARY" value="false"/>
|
|
||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||||
<env name="CACHE_STORE" value="array"/>
|
<env name="CACHE_STORE" value="array"/>
|
||||||
|
|||||||
Generated
-4832
File diff suppressed because it is too large
Load Diff
@@ -1,4 +0,0 @@
|
|||||||
minimumReleaseAge: 10080
|
|
||||||
allowBuilds:
|
|
||||||
unrs-resolver: true
|
|
||||||
vue-demi: true
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -1,92 +0,0 @@
|
|||||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
|
||||||
|
|
||||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
||||||
This license is copied below, and is also available with a FAQ at:
|
|
||||||
http://scripts.sil.org/OFL
|
|
||||||
|
|
||||||
-----------------------------------------------------------
|
|
||||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
||||||
-----------------------------------------------------------
|
|
||||||
|
|
||||||
PREAMBLE
|
|
||||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
||||||
development of collaborative font projects, to support the font creation
|
|
||||||
efforts of academic and linguistic communities, and to provide a free and
|
|
||||||
open framework in which fonts may be shared and improved in partnership
|
|
||||||
with others.
|
|
||||||
|
|
||||||
The OFL allows the licensed fonts to be used, studied, modified and
|
|
||||||
redistributed freely as long as they are not sold by themselves. The
|
|
||||||
fonts, including any derivative works, can be bundled, embedded,
|
|
||||||
redistributed and/or sold with any software provided that any reserved
|
|
||||||
names are not used by derivative works. The fonts and derivatives,
|
|
||||||
however, cannot be released under any other type of license. The
|
|
||||||
requirement for fonts to remain under this license does not apply
|
|
||||||
to any document created using the fonts or their derivatives.
|
|
||||||
|
|
||||||
DEFINITIONS
|
|
||||||
"Font Software" refers to the set of files released by the Copyright
|
|
||||||
Holder(s) under this license and clearly marked as such. This may
|
|
||||||
include source files, build scripts and documentation.
|
|
||||||
|
|
||||||
"Reserved Font Name" refers to any names specified as such after the
|
|
||||||
copyright statement(s).
|
|
||||||
|
|
||||||
"Original Version" refers to the collection of Font Software components as
|
|
||||||
distributed by the Copyright Holder(s).
|
|
||||||
|
|
||||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
||||||
or substituting -- in part or in whole -- any of the components of the
|
|
||||||
Original Version, by changing formats or by porting the Font Software to a
|
|
||||||
new environment.
|
|
||||||
|
|
||||||
"Author" refers to any designer, engineer, programmer, technical
|
|
||||||
writer or other person who contributed to the Font Software.
|
|
||||||
|
|
||||||
PERMISSION AND CONDITIONS
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
||||||
redistribute, and sell modified and unmodified copies of the Font
|
|
||||||
Software, subject to the following conditions:
|
|
||||||
|
|
||||||
1) Neither the Font Software nor any of its individual components,
|
|
||||||
in Original or Modified Versions, may be sold by itself.
|
|
||||||
|
|
||||||
2) Original or Modified Versions of the Font Software may be bundled,
|
|
||||||
redistributed and/or sold with any software, provided that each copy
|
|
||||||
contains the above copyright notice and this license. These can be
|
|
||||||
included either as stand-alone text files, human-readable headers or
|
|
||||||
in the appropriate machine-readable metadata fields within text or
|
|
||||||
binary files as long as those fields can be easily viewed by the user.
|
|
||||||
|
|
||||||
3) No Modified Version of the Font Software may use the Reserved Font
|
|
||||||
Name(s) unless explicit written permission is granted by the corresponding
|
|
||||||
Copyright Holder. This restriction only applies to the primary font name as
|
|
||||||
presented to the users.
|
|
||||||
|
|
||||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
||||||
Software shall not be used to promote, endorse or advertise any
|
|
||||||
Modified Version, except to acknowledge the contribution(s) of the
|
|
||||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
||||||
permission.
|
|
||||||
|
|
||||||
5) The Font Software, modified or unmodified, in part or in whole,
|
|
||||||
must be distributed entirely under this license, and must not be
|
|
||||||
distributed under any other license. The requirement for fonts to
|
|
||||||
remain under this license does not apply to any document created
|
|
||||||
using the Font Software.
|
|
||||||
|
|
||||||
TERMINATION
|
|
||||||
This license becomes null and void if any of the above conditions are
|
|
||||||
not met.
|
|
||||||
|
|
||||||
DISCLAIMER
|
|
||||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
||||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
||||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
||||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
||||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
||||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
||||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
||||||
+122
-87
@@ -1,98 +1,56 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
@import 'tw-animate-css';
|
@import 'tw-animate-css';
|
||||||
@import './theme.css';
|
|
||||||
|
|
||||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||||
@source '../../storage/framework/views/*.php';
|
@source '../../storage/framework/views/*.php';
|
||||||
@source not '../../bootstrap/ssr';
|
|
||||||
|
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: Inter;
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 100 900;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/inter/InterVariable.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: Inter;
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 100 900;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/fonts/inter/InterVariable-Italic.woff2') format('woff2');
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-sans: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
|
--font-sans:
|
||||||
|
Instrument Sans, ui-sans-serif, system-ui, sans-serif,
|
||||||
|
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||||
|
'Noto Color Emoji';
|
||||||
|
|
||||||
--radius-sm: 0.125rem;
|
--radius-lg: var(--radius);
|
||||||
--radius-md: var(--radius);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--radius-lg: 0.5rem;
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--radius-xl: 0.75rem;
|
|
||||||
|
|
||||||
--spacing-page: 1.5rem;
|
|
||||||
--spacing-column: 1rem;
|
|
||||||
--spacing-card: 0.75rem;
|
|
||||||
--spacing-stack: 0.5rem;
|
|
||||||
--spacing-control: 2.75rem;
|
|
||||||
|
|
||||||
--text-headline: 1.5rem;
|
|
||||||
--text-headline--line-height: 2rem;
|
|
||||||
--text-headline--font-weight: 600;
|
|
||||||
--text-headline--letter-spacing: -0.02em;
|
|
||||||
--text-headline-mobile: 1.25rem;
|
|
||||||
--text-headline-mobile--line-height: 1.75rem;
|
|
||||||
--text-headline-mobile--font-weight: 600;
|
|
||||||
--text-headline-mobile--letter-spacing: -0.02em;
|
|
||||||
--text-title: 1.125rem;
|
|
||||||
--text-title--line-height: 1.5rem;
|
|
||||||
--text-title--font-weight: 600;
|
|
||||||
--text-title--letter-spacing: -0.01em;
|
|
||||||
--text-body: 0.875rem;
|
|
||||||
--text-body--line-height: 1.25rem;
|
|
||||||
--text-caption: 0.8125rem;
|
|
||||||
--text-caption--line-height: 1.125rem;
|
|
||||||
--text-label: 0.6875rem;
|
|
||||||
--text-label--line-height: 1rem;
|
|
||||||
--text-label--font-weight: 700;
|
|
||||||
--text-label--letter-spacing: 0.05em;
|
|
||||||
|
|
||||||
--shadow-card-hover: 0 4px 12px rgb(0 0 0 / 0.08);
|
|
||||||
|
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
|
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
--color-card-foreground: var(--card-foreground);
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
|
||||||
--color-popover: var(--popover);
|
--color-popover: var(--popover);
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
|
||||||
--color-primary: var(--primary);
|
--color-primary: var(--primary);
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
|
||||||
--color-secondary: var(--secondary);
|
--color-secondary: var(--secondary);
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
|
||||||
--color-muted: var(--muted);
|
--color-muted: var(--muted);
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
|
||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
|
||||||
--color-destructive: var(--destructive);
|
--color-destructive: var(--destructive);
|
||||||
--color-destructive-foreground: var(--destructive-foreground);
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
--color-brand: var(--brand);
|
|
||||||
--color-brand-foreground: var(--brand-foreground);
|
|
||||||
--color-success: var(--success);
|
|
||||||
--color-success-foreground: var(--success-foreground);
|
|
||||||
--color-warning: var(--warning);
|
|
||||||
--color-warning-foreground: var(--warning-foreground);
|
|
||||||
--color-info: var(--info);
|
|
||||||
--color-info-foreground: var(--info-foreground);
|
|
||||||
--color-chart-1: var(--chart-1);
|
--color-chart-1: var(--chart-1);
|
||||||
--color-chart-2: var(--chart-2);
|
--color-chart-2: var(--chart-2);
|
||||||
--color-chart-3: var(--chart-3);
|
--color-chart-3: var(--chart-3);
|
||||||
--color-chart-4: var(--chart-4);
|
--color-chart-4: var(--chart-4);
|
||||||
--color-chart-5: var(--chart-5);
|
--color-chart-5: var(--chart-5);
|
||||||
|
|
||||||
--color-sidebar: var(--sidebar-background);
|
--color-sidebar: var(--sidebar-background);
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
@@ -103,35 +61,112 @@
|
|||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The default border color has changed to `currentColor` in Tailwind CSS v4,
|
||||||
|
so we've added these compatibility styles to make sure everything still
|
||||||
|
looks the same as it did with Tailwind CSS v3.
|
||||||
|
|
||||||
|
If we ever want to remove these styles, we need to add an explicit border
|
||||||
|
color utility to any element that depends on these defaults.
|
||||||
|
*/
|
||||||
|
@layer base {
|
||||||
|
*,
|
||||||
|
::after,
|
||||||
|
::before,
|
||||||
|
::backdrop,
|
||||||
|
::file-selector-button {
|
||||||
|
border-color: var(--color-gray-200, currentColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
body,
|
||||||
|
html {
|
||||||
|
--font-sans:
|
||||||
|
'Instrument Sans', ui-sans-serif, system-ui, sans-serif,
|
||||||
|
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||||
|
'Noto Color Emoji';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: hsl(0 0% 100%);
|
||||||
|
--foreground: hsl(0 0% 3.9%);
|
||||||
|
--card: hsl(0 0% 100%);
|
||||||
|
--card-foreground: hsl(0 0% 3.9%);
|
||||||
|
--popover: hsl(0 0% 100%);
|
||||||
|
--popover-foreground: hsl(0 0% 3.9%);
|
||||||
|
--primary: hsl(0 0% 9%);
|
||||||
|
--primary-foreground: hsl(0 0% 98%);
|
||||||
|
--secondary: hsl(0 0% 92.1%);
|
||||||
|
--secondary-foreground: hsl(0 0% 9%);
|
||||||
|
--muted: hsl(0 0% 96.1%);
|
||||||
|
--muted-foreground: hsl(0 0% 45.1%);
|
||||||
|
--accent: hsl(0 0% 96.1%);
|
||||||
|
--accent-foreground: hsl(0 0% 9%);
|
||||||
|
--destructive: hsl(0 84.2% 60.2%);
|
||||||
|
--destructive-foreground: hsl(0 0% 98%);
|
||||||
|
--border: hsl(0 0% 92.8%);
|
||||||
|
--input: hsl(0 0% 89.8%);
|
||||||
|
--ring: hsl(0 0% 3.9%);
|
||||||
|
--chart-1: hsl(12 76% 61%);
|
||||||
|
--chart-2: hsl(173 58% 39%);
|
||||||
|
--chart-3: hsl(197 37% 24%);
|
||||||
|
--chart-4: hsl(43 74% 66%);
|
||||||
|
--chart-5: hsl(27 87% 67%);
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--sidebar-background: hsl(0 0% 98%);
|
||||||
|
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
||||||
|
--sidebar-primary: hsl(0 0% 10%);
|
||||||
|
--sidebar-primary-foreground: hsl(0 0% 98%);
|
||||||
|
--sidebar-accent: hsl(0 0% 94%);
|
||||||
|
--sidebar-accent-foreground: hsl(0 0% 30%);
|
||||||
|
--sidebar-border: hsl(0 0% 91%);
|
||||||
|
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||||
|
--sidebar: hsl(0 0% 98%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: hsl(0 0% 3.9%);
|
||||||
|
--foreground: hsl(0 0% 98%);
|
||||||
|
--card: hsl(0 0% 3.9%);
|
||||||
|
--card-foreground: hsl(0 0% 98%);
|
||||||
|
--popover: hsl(0 0% 3.9%);
|
||||||
|
--popover-foreground: hsl(0 0% 98%);
|
||||||
|
--primary: hsl(0 0% 98%);
|
||||||
|
--primary-foreground: hsl(0 0% 9%);
|
||||||
|
--secondary: hsl(0 0% 14.9%);
|
||||||
|
--secondary-foreground: hsl(0 0% 98%);
|
||||||
|
--muted: hsl(0 0% 16.08%);
|
||||||
|
--muted-foreground: hsl(0 0% 63.9%);
|
||||||
|
--accent: hsl(0 0% 14.9%);
|
||||||
|
--accent-foreground: hsl(0 0% 98%);
|
||||||
|
--destructive: hsl(0 84% 60%);
|
||||||
|
--destructive-foreground: hsl(0 0% 98%);
|
||||||
|
--border: hsl(0 0% 14.9%);
|
||||||
|
--input: hsl(0 0% 14.9%);
|
||||||
|
--ring: hsl(0 0% 83.1%);
|
||||||
|
--chart-1: hsl(220 70% 50%);
|
||||||
|
--chart-2: hsl(160 60% 45%);
|
||||||
|
--chart-3: hsl(30 80% 55%);
|
||||||
|
--chart-4: hsl(280 65% 60%);
|
||||||
|
--chart-5: hsl(340 75% 55%);
|
||||||
|
--sidebar-background: hsl(0 0% 7%);
|
||||||
|
--sidebar-foreground: hsl(0 0% 95.9%);
|
||||||
|
--sidebar-primary: hsl(360, 100%, 100%);
|
||||||
|
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||||
|
--sidebar-accent: hsl(0 0% 15.9%);
|
||||||
|
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
||||||
|
--sidebar-border: hsl(0 0% 15.9%);
|
||||||
|
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||||
|
--sidebar: hsl(240 5.9% 10%);
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background font-sans text-body text-foreground antialiased;
|
@apply bg-background text-foreground;
|
||||||
font-optical-sizing: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
:focus-visible {
|
|
||||||
outline: 2px solid var(--ring);
|
|
||||||
outline-offset: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::selection {
|
|
||||||
background: var(--brand);
|
|
||||||
color: var(--brand-foreground);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Preserve completion events while removing visible nonessential motion. */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
animation-duration: 0.01ms !important;
|
|
||||||
animation-iteration-count: 1 !important;
|
|
||||||
transition-duration: 0.01ms !important;
|
|
||||||
scroll-behavior: auto !important;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
/* Fluid Emerald / Kinetic Kanban: shared by the first paint and the app bundle. */
|
|
||||||
:root {
|
|
||||||
color-scheme: light;
|
|
||||||
--radius: 0.25rem;
|
|
||||||
--background: #f7f9fb;
|
|
||||||
--foreground: #191c1e;
|
|
||||||
--card: #ffffff;
|
|
||||||
--card-foreground: #191c1e;
|
|
||||||
--popover: #ffffff;
|
|
||||||
--popover-foreground: #191c1e;
|
|
||||||
--primary: #006e2f;
|
|
||||||
--primary-foreground: #ffffff;
|
|
||||||
--secondary: #d0e1fb;
|
|
||||||
--secondary-foreground: #38485d;
|
|
||||||
--muted: #f2f4f6;
|
|
||||||
--muted-foreground: #505f76;
|
|
||||||
--accent: #dcfce7;
|
|
||||||
--accent-foreground: #005321;
|
|
||||||
--destructive: #ba1a1a;
|
|
||||||
--destructive-foreground: #ffffff;
|
|
||||||
--border: #e2e8f0;
|
|
||||||
--input: #6d7b6c;
|
|
||||||
--ring: #006e2f;
|
|
||||||
--brand: #22c55e;
|
|
||||||
--brand-foreground: #002109;
|
|
||||||
--success: #dcfce7;
|
|
||||||
--success-foreground: #005321;
|
|
||||||
--warning: #fef3c7;
|
|
||||||
--warning-foreground: #78350f;
|
|
||||||
--info: #dbeafe;
|
|
||||||
--info-foreground: #1e40af;
|
|
||||||
--chart-1: #006e2f;
|
|
||||||
--chart-2: #505f76;
|
|
||||||
--chart-3: #9e4036;
|
|
||||||
--chart-4: #92400e;
|
|
||||||
--chart-5: #1d4ed8;
|
|
||||||
--sidebar-background: #f2f4f6;
|
|
||||||
--sidebar-foreground: #191c1e;
|
|
||||||
--sidebar-primary: #006e2f;
|
|
||||||
--sidebar-primary-foreground: #ffffff;
|
|
||||||
--sidebar-accent: #dcfce7;
|
|
||||||
--sidebar-accent-foreground: #005321;
|
|
||||||
--sidebar-border: #e2e8f0;
|
|
||||||
--sidebar-ring: #006e2f;
|
|
||||||
--sidebar: #f2f4f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
color-scheme: dark;
|
|
||||||
--background: #0f172a;
|
|
||||||
--foreground: #f1f5f9;
|
|
||||||
--card: #1e293b;
|
|
||||||
--card-foreground: #f1f5f9;
|
|
||||||
--popover: #1e293b;
|
|
||||||
--popover-foreground: #f1f5f9;
|
|
||||||
--primary: #4ae176;
|
|
||||||
--primary-foreground: #002109;
|
|
||||||
--secondary: #334155;
|
|
||||||
--secondary-foreground: #e2e8f0;
|
|
||||||
--muted: #1e293b;
|
|
||||||
--muted-foreground: #b7c8e1;
|
|
||||||
--accent: #173b2b;
|
|
||||||
--accent-foreground: #6bff8f;
|
|
||||||
--destructive: #fca5a5;
|
|
||||||
--destructive-foreground: #450a0a;
|
|
||||||
--border: #334155;
|
|
||||||
--input: #8293aa;
|
|
||||||
--ring: #4ae176;
|
|
||||||
--brand: #22c55e;
|
|
||||||
--brand-foreground: #002109;
|
|
||||||
--success: #173b2b;
|
|
||||||
--success-foreground: #6bff8f;
|
|
||||||
--warning: #422006;
|
|
||||||
--warning-foreground: #fde68a;
|
|
||||||
--info: #172554;
|
|
||||||
--info-foreground: #bfdbfe;
|
|
||||||
--chart-1: #4ae176;
|
|
||||||
--chart-2: #b7c8e1;
|
|
||||||
--chart-3: #ffb4a9;
|
|
||||||
--chart-4: #fbbf24;
|
|
||||||
--chart-5: #60a5fa;
|
|
||||||
--sidebar-background: #111c30;
|
|
||||||
--sidebar-foreground: #f1f5f9;
|
|
||||||
--sidebar-primary: #4ae176;
|
|
||||||
--sidebar-primary-foreground: #002109;
|
|
||||||
--sidebar-accent: #173b2b;
|
|
||||||
--sidebar-accent-foreground: #6bff8f;
|
|
||||||
--sidebar-border: #334155;
|
|
||||||
--sidebar-ring: #4ae176;
|
|
||||||
--sidebar: #111c30;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
background-color: var(--background);
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
+5
-5
@@ -3,8 +3,7 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
|||||||
import type { DefineComponent } from 'vue';
|
import type { DefineComponent } from 'vue';
|
||||||
import { createApp, h } from 'vue';
|
import { createApp, h } from 'vue';
|
||||||
import '../css/app.css';
|
import '../css/app.css';
|
||||||
import { createAppearance } from '@/composables/useAppearance';
|
import { initializeTheme } from '@/composables/useAppearance';
|
||||||
import { createLocalization } from '@/lib/localization';
|
|
||||||
|
|
||||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||||
|
|
||||||
@@ -18,11 +17,12 @@ createInertiaApp({
|
|||||||
setup({ el, App, props, plugin }) {
|
setup({ el, App, props, plugin }) {
|
||||||
createApp({ render: () => h(App, props) })
|
createApp({ render: () => h(App, props) })
|
||||||
.use(plugin)
|
.use(plugin)
|
||||||
.use(createLocalization(props.initialPage.props.localization))
|
|
||||||
.use(createAppearance(props.initialPage.props.appearance))
|
|
||||||
.mount(el);
|
.mount(el);
|
||||||
},
|
},
|
||||||
progress: {
|
progress: {
|
||||||
color: 'var(--primary)',
|
color: '#4B5563',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// This will set light / dark mode on page load...
|
||||||
|
initializeTheme();
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
import { AlertCircle } from 'lucide-vue-next';
|
import { AlertCircle } from 'lucide-vue-next';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
errors: string[];
|
errors: string[];
|
||||||
title?: string;
|
title?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
const { t } = useLocalization();
|
title: 'Something went wrong.',
|
||||||
|
});
|
||||||
|
|
||||||
const uniqueErrors = computed(() => Array.from(new Set(props.errors)));
|
const uniqueErrors = computed(() => Array.from(new Set(props.errors)));
|
||||||
</script>
|
</script>
|
||||||
@@ -18,7 +18,7 @@ const uniqueErrors = computed(() => Array.from(new Set(props.errors)));
|
|||||||
<template>
|
<template>
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircle class="size-4" />
|
<AlertCircle class="size-4" />
|
||||||
<AlertTitle>{{ title ?? t('common.error') }}</AlertTitle>
|
<AlertTitle>{{ title }}</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<ul class="list-inside list-disc text-sm">
|
<ul class="list-inside list-disc text-sm">
|
||||||
<li v-for="(error, index) in uniqueErrors" :key="index">
|
<li v-for="(error, index) in uniqueErrors" :key="index">
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Columns3 } from 'lucide-vue-next';
|
import AppLogoIcon from '@/components/AppLogoIcon.vue';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span class="flex min-w-0 items-center gap-3">
|
<div
|
||||||
<span
|
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md"
|
||||||
class="bg-brand text-brand-foreground flex size-8 shrink-0 items-center justify-center rounded-md"
|
>
|
||||||
|
<AppLogoIcon class="size-5 fill-current text-white dark:text-black" />
|
||||||
|
</div>
|
||||||
|
<div class="ml-1 grid flex-1 text-left text-sm">
|
||||||
|
<span class="mb-0.5 truncate font-semibold leading-tight"
|
||||||
|
>Laravel Starter Kit</span
|
||||||
>
|
>
|
||||||
<Columns3 class="size-5" aria-hidden="true" />
|
</div>
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="text-title truncate font-semibold group-data-[collapsible=icon]:hidden"
|
|
||||||
>KanbanGit</span
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link, router } from '@inertiajs/vue3';
|
import { Link, usePage } from '@inertiajs/vue3';
|
||||||
import { Settings2, X } from 'lucide-vue-next';
|
import {
|
||||||
import { computed, onMounted, onUnmounted } from 'vue';
|
BookOpen,
|
||||||
|
Folder,
|
||||||
|
FolderGit2,
|
||||||
|
LayoutGrid,
|
||||||
|
Layers3,
|
||||||
|
Shield,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
import { computed } from 'vue';
|
||||||
import AppLogo from '@/components/AppLogo.vue';
|
import AppLogo from '@/components/AppLogo.vue';
|
||||||
|
import NavFooter from '@/components/NavFooter.vue';
|
||||||
import NavMain from '@/components/NavMain.vue';
|
import NavMain from '@/components/NavMain.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import NavUser from '@/components/NavUser.vue';
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -13,98 +22,93 @@ import {
|
|||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
useSidebar,
|
|
||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
import { useAppNavigation } from '@/composables/useAppNavigation';
|
|
||||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
|
||||||
import { dashboard } from '@/routes';
|
import { dashboard } from '@/routes';
|
||||||
import { edit as profile } from '@/routes/profile';
|
import { index as adminUsers } from '@/routes/admin/users';
|
||||||
import { show } from '@/routes/projects';
|
import { index as projectTemplatesIndex } from '@/routes/project-templates';
|
||||||
|
import { index as projectsIndex } from '@/routes/projects';
|
||||||
|
import { index as teamsIndex } from '@/routes/teams';
|
||||||
|
import type { NavItem } from '@/types';
|
||||||
|
|
||||||
const { t } = useLocalization();
|
const page = usePage();
|
||||||
const { currentUrl } = useCurrentUrl();
|
|
||||||
const { project, workspaceItems, projectItems, projectSettingsItems } =
|
const mainNavItems = computed<NavItem[]>(() => {
|
||||||
useAppNavigation();
|
const items: NavItem[] = [
|
||||||
const { isMobile, setOpenMobile } = useSidebar();
|
{
|
||||||
const accountItems = computed(() => [
|
title: 'Dashboard',
|
||||||
{
|
href: dashboard(),
|
||||||
title: t('navigation.settings'),
|
icon: LayoutGrid,
|
||||||
href: profile(),
|
},
|
||||||
icon: Settings2,
|
{
|
||||||
isActive: currentUrl.value.startsWith('/settings/'),
|
title: 'Projects',
|
||||||
},
|
href: projectsIndex(),
|
||||||
]);
|
icon: Folder,
|
||||||
let removeNavigationListener: (() => void) | undefined;
|
},
|
||||||
onMounted(() => {
|
];
|
||||||
removeNavigationListener = router.on('navigate', () =>
|
|
||||||
setOpenMobile(false),
|
if (page.props.auth.permissions.projectCreate) {
|
||||||
);
|
items.push({
|
||||||
|
title: 'Project templates',
|
||||||
|
href: projectTemplatesIndex(),
|
||||||
|
icon: Layers3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page.props.auth.permissions.globalAdmin) {
|
||||||
|
items.push({
|
||||||
|
title: 'Users',
|
||||||
|
href: adminUsers(),
|
||||||
|
icon: Shield,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page.props.auth.permissions.canManageTeams) {
|
||||||
|
items.push({
|
||||||
|
title: 'Teams',
|
||||||
|
href: teamsIndex(),
|
||||||
|
icon: Users,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
});
|
});
|
||||||
onUnmounted(() => removeNavigationListener?.());
|
|
||||||
|
const footerNavItems: NavItem[] = [
|
||||||
|
{
|
||||||
|
title: 'Repository',
|
||||||
|
href: 'https://github.com/laravel/vue-starter-kit',
|
||||||
|
icon: FolderGit2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Documentation',
|
||||||
|
href: 'https://laravel.com/docs/starter-kits#vue',
|
||||||
|
icon: BookOpen,
|
||||||
|
},
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Sidebar id="app-navigation" collapsible="icon" variant="sidebar">
|
<Sidebar collapsible="icon" variant="inset">
|
||||||
<SidebarHeader class="border-sidebar-border gap-4 border-b p-3">
|
<SidebarHeader>
|
||||||
<div class="flex items-center gap-2">
|
<SidebarMenu>
|
||||||
<SidebarMenu class="min-w-0">
|
<SidebarMenuItem>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuButton size="lg" as-child>
|
||||||
<SidebarMenuButton
|
<Link :href="dashboard()">
|
||||||
size="lg"
|
<AppLogo />
|
||||||
as-child
|
</Link>
|
||||||
tooltip="KanbanGit"
|
</SidebarMenuButton>
|
||||||
>
|
</SidebarMenuItem>
|
||||||
<Link :href="dashboard()" aria-label="KanbanGit"
|
</SidebarMenu>
|
||||||
><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>
|
||||||
<nav :aria-label="t('navigation.main')">
|
<NavMain :items="mainNavItems" />
|
||||||
<NavMain
|
|
||||||
:items="workspaceItems"
|
|
||||||
:label="t('navigation.workspace')"
|
|
||||||
/>
|
|
||||||
<NavMain
|
|
||||||
v-if="projectItems.length"
|
|
||||||
:items="projectItems"
|
|
||||||
:label="t('navigation.project')"
|
|
||||||
/>
|
|
||||||
<NavMain
|
|
||||||
v-if="projectSettingsItems.length"
|
|
||||||
:items="projectSettingsItems"
|
|
||||||
:label="t('navigation.projectSettings')"
|
|
||||||
/>
|
|
||||||
</nav>
|
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter class="border-sidebar-border border-t p-0">
|
|
||||||
<NavMain :items="accountItems" :label="t('navigation.account')" />
|
<SidebarFooter>
|
||||||
|
<NavFooter :items="footerNavItems" />
|
||||||
|
<NavUser />
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
|
<slot />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,145 +1,27 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link, usePage } from '@inertiajs/vue3';
|
|
||||||
import { Monitor, Moon, Plus, Sun } from 'lucide-vue-next';
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import Breadcrumbs from '@/components/Breadcrumbs.vue';
|
import Breadcrumbs from '@/components/Breadcrumbs.vue';
|
||||||
import UserMenuContent from '@/components/UserMenuContent.vue';
|
|
||||||
import { UserAvatar } from '@/components/ui/avatar';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuRadioGroup,
|
|
||||||
DropdownMenuRadioItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||||
import { useAppNavigation } from '@/composables/useAppNavigation';
|
|
||||||
import {
|
|
||||||
normalizeAppearance,
|
|
||||||
useAppearance,
|
|
||||||
} from '@/composables/useAppearance';
|
|
||||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
|
||||||
import { create as createProject } from '@/routes/projects';
|
|
||||||
import { index as board } from '@/routes/projects/board';
|
|
||||||
import { create as createTicket } from '@/routes/projects/tickets';
|
|
||||||
import type { BreadcrumbItem } from '@/types';
|
import type { BreadcrumbItem } from '@/types';
|
||||||
|
|
||||||
withDefaults(defineProps<{ breadcrumbs?: BreadcrumbItem[] }>(), {
|
withDefaults(
|
||||||
breadcrumbs: () => [],
|
defineProps<{
|
||||||
});
|
breadcrumbs?: BreadcrumbItem[];
|
||||||
const page = usePage();
|
}>(),
|
||||||
const { project } = useAppNavigation();
|
{
|
||||||
const { isCurrentUrl } = useCurrentUrl();
|
breadcrumbs: () => [],
|
||||||
const { t } = useLocalization();
|
},
|
||||||
const { appearance, updateAppearance } = useAppearance();
|
|
||||||
const themeIcon = computed(
|
|
||||||
() => ({ light: Sun, dark: Moon, system: Monitor })[appearance.value],
|
|
||||||
);
|
);
|
||||||
const createAction = computed(() => {
|
|
||||||
if (project.value?.canCreateTickets) {
|
|
||||||
return {
|
|
||||||
title: t('navigation.createTicket'),
|
|
||||||
href: createTicket(project.value.id, {
|
|
||||||
query: {
|
|
||||||
source: isCurrentUrl(board(project.value.id))
|
|
||||||
? 'board'
|
|
||||||
: 'backlog',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!project.value && page.props.auth.permissions.projectCreate) {
|
|
||||||
return { title: t('navigation.createProject'), href: createProject() };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header
|
<header
|
||||||
class="bg-background md:px-page sticky top-0 z-20 flex min-h-16 min-w-0 shrink-0 items-center justify-between gap-2 border-b px-3 md:gap-4"
|
class="border-sidebar-border/70 group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 flex h-16 shrink-0 items-center gap-2 border-b px-6 transition-[width,height] ease-linear md:px-4"
|
||||||
>
|
>
|
||||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<SidebarTrigger class="shrink-0" />
|
<SidebarTrigger class="-ml-1" />
|
||||||
<Breadcrumbs v-if="breadcrumbs.length" :breadcrumbs="breadcrumbs" />
|
<template v-if="breadcrumbs && breadcrumbs.length > 0">
|
||||||
<span v-else class="truncate font-semibold">KanbanGit</span>
|
<Breadcrumbs :breadcrumbs="breadcrumbs" />
|
||||||
</div>
|
</template>
|
||||||
<div class="flex shrink-0 items-center gap-1 sm:gap-2">
|
|
||||||
<Button
|
|
||||||
v-if="createAction"
|
|
||||||
as-child
|
|
||||||
:aria-label="createAction.title"
|
|
||||||
class="px-3"
|
|
||||||
>
|
|
||||||
<Link :href="createAction.href"
|
|
||||||
><Plus aria-hidden="true" /><span
|
|
||||||
class="hidden sm:inline"
|
|
||||||
>{{ createAction.title }}</span
|
|
||||||
></Link
|
|
||||||
>
|
|
||||||
</Button>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger as-child>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
:aria-label="t('appearance.label')"
|
|
||||||
><component :is="themeIcon" aria-hidden="true"
|
|
||||||
/></Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuLabel>{{
|
|
||||||
t('appearance.label')
|
|
||||||
}}</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuRadioGroup
|
|
||||||
:model-value="appearance"
|
|
||||||
@update:model-value="
|
|
||||||
(value) =>
|
|
||||||
updateAppearance(normalizeAppearance(value))
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<DropdownMenuRadioItem value="light"
|
|
||||||
><Sun aria-hidden="true" />{{
|
|
||||||
t('appearance.light')
|
|
||||||
}}</DropdownMenuRadioItem
|
|
||||||
>
|
|
||||||
<DropdownMenuRadioItem value="dark"
|
|
||||||
><Moon aria-hidden="true" />{{
|
|
||||||
t('appearance.dark')
|
|
||||||
}}</DropdownMenuRadioItem
|
|
||||||
>
|
|
||||||
<DropdownMenuRadioItem value="system"
|
|
||||||
><Monitor aria-hidden="true" />{{
|
|
||||||
t('appearance.system')
|
|
||||||
}}</DropdownMenuRadioItem
|
|
||||||
>
|
|
||||||
</DropdownMenuRadioGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger as-child>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
:aria-label="
|
|
||||||
t('navigation.userMenu', {
|
|
||||||
name: page.props.auth.user.name,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
data-test="sidebar-menu-button"
|
|
||||||
>
|
|
||||||
<UserAvatar :user="page.props.auth.user" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" class="min-w-60">
|
|
||||||
<UserMenuContent :user="page.props.auth.user" />
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,39 +1,33 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Monitor, Moon, Sun } from 'lucide-vue-next';
|
import { Monitor, Moon, Sun } from 'lucide-vue-next';
|
||||||
import { useAppearance } from '@/composables/useAppearance';
|
import { useAppearance } from '@/composables/useAppearance';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
|
||||||
|
|
||||||
const { appearance, updateAppearance } = useAppearance();
|
const { appearance, updateAppearance } = useAppearance();
|
||||||
const { t } = useLocalization();
|
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ value: 'light', Icon: Sun, label: 'appearance.light' },
|
{ value: 'light', Icon: Sun, label: 'Light' },
|
||||||
{ value: 'dark', Icon: Moon, label: 'appearance.dark' },
|
{ value: 'dark', Icon: Moon, label: 'Dark' },
|
||||||
{ value: 'system', Icon: Monitor, label: 'appearance.system' },
|
{ value: 'system', Icon: Monitor, label: 'System' },
|
||||||
] as const;
|
] as const;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
role="group"
|
class="inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800"
|
||||||
:aria-label="t('appearance.label')"
|
|
||||||
class="border-border bg-muted inline-flex max-w-full gap-1 rounded-lg border p-1"
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
v-for="{ value, Icon, label } in tabs"
|
v-for="{ value, Icon, label } in tabs"
|
||||||
:key="value"
|
:key="value"
|
||||||
type="button"
|
|
||||||
:aria-pressed="appearance === value"
|
|
||||||
@click="updateAppearance(value)"
|
@click="updateAppearance(value)"
|
||||||
:class="[
|
:class="[
|
||||||
'min-h-control text-body focus-visible:outline-ring flex min-w-0 items-center justify-center gap-1.5 rounded-md px-3 font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
|
'flex items-center rounded-md px-3.5 py-1.5 transition-colors',
|
||||||
appearance === value
|
appearance === value
|
||||||
? 'bg-primary text-primary-foreground'
|
? 'shadow-xs bg-white dark:bg-neutral-700 dark:text-neutral-100'
|
||||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<component :is="Icon" class="size-4 shrink-0" aria-hidden="true" />
|
<component :is="Icon" class="-ml-1 h-4 w-4" />
|
||||||
<span>{{ t(label) }}</span>
|
<span class="ml-1.5 text-sm">{{ label }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import FormFeedback from '@/components/FormFeedback.vue';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
|
||||||
import type { FormFeedbackState } from '@/lib/form-feedback';
|
|
||||||
import { login } from '@/routes/index';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
state: FormFeedbackState;
|
|
||||||
responseStatus?: number | null;
|
|
||||||
loadingTitle?: string;
|
|
||||||
successTitle?: string;
|
|
||||||
}>();
|
|
||||||
defineEmits<{ retry: [] }>();
|
|
||||||
const { t } = useLocalization();
|
|
||||||
const expired = computed(
|
|
||||||
() => props.state === 'server' && props.responseStatus === 419,
|
|
||||||
);
|
|
||||||
const limited = computed(
|
|
||||||
() => props.state === 'server' && props.responseStatus === 429,
|
|
||||||
);
|
|
||||||
const title = computed(() => {
|
|
||||||
if (expired.value) {
|
|
||||||
return t('auth.feedback.expiredTitle');
|
|
||||||
}
|
|
||||||
if (limited.value) {
|
|
||||||
return t('auth.feedback.limitedTitle');
|
|
||||||
}
|
|
||||||
if (props.state === 'loading') {
|
|
||||||
return props.loadingTitle ?? t('auth.feedback.pending');
|
|
||||||
}
|
|
||||||
if (props.state === 'success') {
|
|
||||||
return props.successTitle ?? t('auth.feedback.completed');
|
|
||||||
}
|
|
||||||
if (props.state === 'validation') {
|
|
||||||
return t('auth.feedback.validationTitle');
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
const description = computed(() => {
|
|
||||||
if (expired.value) {
|
|
||||||
return t('auth.feedback.expiredDescription');
|
|
||||||
}
|
|
||||||
if (limited.value) {
|
|
||||||
return t('auth.feedback.limitedDescription');
|
|
||||||
}
|
|
||||||
if (props.state === 'validation') {
|
|
||||||
return t('auth.feedback.validationDescription');
|
|
||||||
}
|
|
||||||
if (props.state === 'network') {
|
|
||||||
return t('auth.feedback.networkDescription');
|
|
||||||
}
|
|
||||||
if (props.state === 'server') {
|
|
||||||
return t('auth.feedback.serverDescription');
|
|
||||||
}
|
|
||||||
if (props.state === 'cancelled') {
|
|
||||||
return t('auth.feedback.cancelledDescription');
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="gap-card grid" v-show="state !== 'idle'">
|
|
||||||
<FormFeedback
|
|
||||||
:state="state"
|
|
||||||
:title="title"
|
|
||||||
:description="description"
|
|
||||||
:retryable="!expired"
|
|
||||||
@retry="$emit('retry')"
|
|
||||||
/>
|
|
||||||
<Button v-if="expired" as-child variant="outline">
|
|
||||||
<!-- A fresh document renews the session/CSRF state and drops in-memory secrets. -->
|
|
||||||
<a :href="login.url()">{{ t('auth.backToLogin') }}</a>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -9,48 +9,29 @@ import {
|
|||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from '@/components/ui/breadcrumb';
|
} from '@/components/ui/breadcrumb';
|
||||||
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
breadcrumbs: BreadcrumbItemType[];
|
breadcrumbs: BreadcrumbItemType[];
|
||||||
};
|
};
|
||||||
|
|
||||||
defineProps<Props>();
|
defineProps<Props>();
|
||||||
const { t } = useLocalization();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Breadcrumb class="min-w-0" :aria-label="t('navigation.breadcrumbs')">
|
<Breadcrumb>
|
||||||
<BreadcrumbList class="flex-nowrap">
|
<BreadcrumbList>
|
||||||
<template v-for="(item, index) in breadcrumbs" :key="index">
|
<template v-for="(item, index) in breadcrumbs" :key="index">
|
||||||
<BreadcrumbItem
|
<BreadcrumbItem>
|
||||||
:class="[
|
|
||||||
'min-w-0',
|
|
||||||
index !== breadcrumbs.length - 1
|
|
||||||
? 'hidden md:inline-flex'
|
|
||||||
: '',
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<template v-if="index === breadcrumbs.length - 1">
|
<template v-if="index === breadcrumbs.length - 1">
|
||||||
<BreadcrumbPage class="truncate" :title="item.title">{{
|
<BreadcrumbPage>{{ item.title }}</BreadcrumbPage>
|
||||||
item.title
|
|
||||||
}}</BreadcrumbPage>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<BreadcrumbLink as-child>
|
<BreadcrumbLink as-child>
|
||||||
<Link
|
<Link :href="item.href">{{ item.title }}</Link>
|
||||||
:href="item.href"
|
|
||||||
class="max-w-40 truncate"
|
|
||||||
:title="item.title"
|
|
||||||
>{{ item.title }}</Link
|
|
||||||
>
|
|
||||||
</BreadcrumbLink>
|
</BreadcrumbLink>
|
||||||
</template>
|
</template>
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
<BreadcrumbSeparator
|
<BreadcrumbSeparator v-if="index !== breadcrumbs.length - 1" />
|
||||||
v-if="index !== breadcrumbs.length - 1"
|
|
||||||
class="hidden md:block"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { CircleAlert, CircleCheck, Inbox, LoaderCircle } from 'lucide-vue-next';
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
state: 'empty' | 'loading' | 'error' | 'success';
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
retryable?: boolean;
|
|
||||||
}>(),
|
|
||||||
{ retryable: false },
|
|
||||||
);
|
|
||||||
defineEmits<{ retry: [] }>();
|
|
||||||
const { t } = useLocalization();
|
|
||||||
const icons = {
|
|
||||||
empty: Inbox,
|
|
||||||
loading: LoaderCircle,
|
|
||||||
error: CircleAlert,
|
|
||||||
success: CircleCheck,
|
|
||||||
};
|
|
||||||
const defaultTitles = {
|
|
||||||
empty: 'feedback.empty',
|
|
||||||
loading: 'common.loading',
|
|
||||||
error: 'common.error',
|
|
||||||
success: 'feedback.saved',
|
|
||||||
} as const;
|
|
||||||
const heading = computed(() => props.title ?? t(defaultTitles[props.state]));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
:role="state === 'error' ? 'alert' : 'status'"
|
|
||||||
:aria-busy="state === 'loading' || undefined"
|
|
||||||
:data-feedback="state"
|
|
||||||
class="gap-card bg-card p-card text-body text-card-foreground flex min-w-0 items-start rounded-md border"
|
|
||||||
:class="{
|
|
||||||
'border-destructive/40': state === 'error',
|
|
||||||
'border-success/40': state === 'success',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<component
|
|
||||||
:is="icons[state]"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="mt-0.5 size-5 shrink-0"
|
|
||||||
:class="{
|
|
||||||
'text-primary animate-spin motion-reduce:animate-none':
|
|
||||||
state === 'loading',
|
|
||||||
'text-destructive': state === 'error',
|
|
||||||
'text-success-foreground': state === 'success',
|
|
||||||
'text-muted-foreground': state === 'empty',
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
<div class="gap-stack grid min-w-0 flex-1 break-words">
|
|
||||||
<p class="font-semibold">{{ heading }}</p>
|
|
||||||
<p v-if="description" class="text-muted-foreground">
|
|
||||||
{{ description }}
|
|
||||||
</p>
|
|
||||||
<div v-if="$slots.default"><slot /></div>
|
|
||||||
<Button
|
|
||||||
v-if="retryable && state === 'error'"
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
class="justify-self-start"
|
|
||||||
@click="$emit('retry')"
|
|
||||||
>{{ t('common.retry') }}</Button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, nextTick, useTemplateRef, watch } from 'vue';
|
|
||||||
import FeedbackState from '@/components/FeedbackState.vue';
|
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
|
||||||
import type { FormFeedbackState } from '@/lib/form-feedback';
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
state: FormFeedbackState;
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
retryable?: boolean;
|
|
||||||
}>(),
|
|
||||||
{ retryable: true },
|
|
||||||
);
|
|
||||||
defineEmits<{ retry: [] }>();
|
|
||||||
const { t } = useLocalization();
|
|
||||||
const feedback = useTemplateRef<HTMLDivElement>('feedback');
|
|
||||||
watch(
|
|
||||||
() => props.state,
|
|
||||||
async (state) => {
|
|
||||||
if (['validation', 'network', 'server', 'cancelled'].includes(state)) {
|
|
||||||
await nextTick();
|
|
||||||
if (props.state === state) {
|
|
||||||
feedback.value?.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const titles = {
|
|
||||||
idle: 'feedback.saved',
|
|
||||||
loading: 'forms.saving',
|
|
||||||
success: 'feedback.saved',
|
|
||||||
validation: 'forms.validationTitle',
|
|
||||||
network: 'forms.networkTitle',
|
|
||||||
server: 'forms.serverTitle',
|
|
||||||
cancelled: 'forms.cancelledTitle',
|
|
||||||
} as const;
|
|
||||||
const descriptions = {
|
|
||||||
validation: 'forms.validationDescription',
|
|
||||||
network: 'forms.networkDescription',
|
|
||||||
server: 'forms.serverDescription',
|
|
||||||
cancelled: 'forms.cancelledDescription',
|
|
||||||
} as const;
|
|
||||||
const description = computed(() =>
|
|
||||||
props.state in descriptions
|
|
||||||
? t(descriptions[props.state as keyof typeof descriptions])
|
|
||||||
: undefined,
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
ref="feedback"
|
|
||||||
v-show="state !== 'idle'"
|
|
||||||
:data-form-state="state"
|
|
||||||
tabindex="-1"
|
|
||||||
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
|
|
||||||
>
|
|
||||||
<FeedbackState
|
|
||||||
v-if="state !== 'idle'"
|
|
||||||
:state="
|
|
||||||
state === 'loading' || state === 'success' ? state : 'error'
|
|
||||||
"
|
|
||||||
:title="props.title ?? t(titles[state])"
|
|
||||||
:description="props.description ?? description"
|
|
||||||
:retryable="
|
|
||||||
props.retryable !== false &&
|
|
||||||
(state === 'network' ||
|
|
||||||
state === 'server' ||
|
|
||||||
state === 'cancelled')
|
|
||||||
"
|
|
||||||
@retry="$emit('retry')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -5,8 +5,8 @@ defineProps<{
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-show="message" role="alert">
|
<div v-show="message">
|
||||||
<p class="text-body text-destructive">
|
<p class="text-sm text-red-600 dark:text-red-500">
|
||||||
{{ message }}
|
{{ message }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,46 +12,23 @@ import type { NavItem } from '@/types';
|
|||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
items: NavItem[];
|
items: NavItem[];
|
||||||
label: string;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { isCurrentUrl } = useCurrentUrl();
|
const { isCurrentUrl } = useCurrentUrl();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SidebarGroup class="px-3 py-2">
|
<SidebarGroup class="px-2 py-0">
|
||||||
<SidebarGroupLabel class="text-label text-muted-foreground uppercase">{{
|
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||||
label
|
<SidebarMenu>
|
||||||
}}</SidebarGroupLabel>
|
|
||||||
<SidebarMenu role="list" :aria-label="label">
|
|
||||||
<SidebarMenuItem v-for="item in items" :key="item.title">
|
<SidebarMenuItem v-for="item in items" :key="item.title">
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
as-child
|
as-child
|
||||||
:is-active="
|
:is-active="isCurrentUrl(item.href)"
|
||||||
item.isActive ??
|
|
||||||
isCurrentUrl(
|
|
||||||
item.href,
|
|
||||||
undefined,
|
|
||||||
item.match === 'section',
|
|
||||||
)
|
|
||||||
"
|
|
||||||
:tooltip="item.title"
|
:tooltip="item.title"
|
||||||
class="min-h-control data-[active=true]:border-brand border-l-2 border-transparent px-3 data-[active=true]:font-semibold group-data-[collapsible=icon]:min-h-8"
|
|
||||||
>
|
>
|
||||||
<Link
|
<Link :href="item.href">
|
||||||
:href="item.href"
|
<component :is="item.icon" />
|
||||||
:aria-current="
|
|
||||||
(item.isActive ??
|
|
||||||
isCurrentUrl(
|
|
||||||
item.href,
|
|
||||||
undefined,
|
|
||||||
item.match === 'section',
|
|
||||||
))
|
|
||||||
? 'page'
|
|
||||||
: undefined
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<component :is="item.icon" aria-hidden="true" />
|
|
||||||
<span>{{ item.title }}</span>
|
<span>{{ item.title }}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { Eye, EyeOff } from 'lucide-vue-next';
|
import { Eye, EyeOff } from 'lucide-vue-next';
|
||||||
import { ref, useTemplateRef } from 'vue';
|
import { ref, useTemplateRef } from 'vue';
|
||||||
import type { HTMLAttributes } from 'vue';
|
import type { HTMLAttributes } from 'vue';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -10,14 +9,12 @@ defineOptions({ inheritAttrs: false });
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
class?: HTMLAttributes['class'];
|
class?: HTMLAttributes['class'];
|
||||||
disabled?: boolean;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const model = defineModel<string | number | null>();
|
const model = defineModel<string | number | null>();
|
||||||
|
|
||||||
const showPassword = ref(false);
|
const showPassword = ref(false);
|
||||||
const inputRef = useTemplateRef('inputRef');
|
const inputRef = useTemplateRef('inputRef');
|
||||||
const { t } = useLocalization();
|
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
$el: inputRef,
|
$el: inputRef,
|
||||||
@@ -31,8 +28,7 @@ defineExpose({
|
|||||||
ref="inputRef"
|
ref="inputRef"
|
||||||
v-model="model"
|
v-model="model"
|
||||||
:type="showPassword ? 'text' : 'password'"
|
:type="showPassword ? 'text' : 'password'"
|
||||||
:class="cn('pr-12', props.class)"
|
:class="cn('pr-10', props.class)"
|
||||||
:disabled="disabled"
|
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -40,14 +36,11 @@ defineExpose({
|
|||||||
@click="showPassword = !showPassword"
|
@click="showPassword = !showPassword"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'text-muted-foreground hover:text-foreground focus-visible:ring-ring w-control absolute inset-y-0 right-0 flex items-center justify-center rounded-r-md focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
|
'text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 focus-visible:outline-none focus-visible:ring-[3px]',
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
:aria-label="
|
:aria-label="showPassword ? 'Hide password' : 'Show password'"
|
||||||
t(showPassword ? 'auth.password.hide' : 'auth.password.show')
|
:tabindex="-1"
|
||||||
"
|
|
||||||
:aria-pressed="showPassword"
|
|
||||||
:disabled="disabled"
|
|
||||||
>
|
>
|
||||||
<EyeOff v-if="showPassword" class="size-4" />
|
<EyeOff v-if="showPassword" class="size-4" />
|
||||||
<Eye v-else class="size-4" />
|
<Eye v-else class="size-4" />
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Link } from '@inertiajs/vue3';
|
|
||||||
import { ChevronDown, Settings2 } from 'lucide-vue-next';
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
import { useAppNavigation } from '@/composables/useAppNavigation';
|
|
||||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
|
||||||
|
|
||||||
const { projectItems, projectSettingsItems } = useAppNavigation();
|
|
||||||
const { isCurrentUrl } = useCurrentUrl();
|
|
||||||
const { t } = useLocalization();
|
|
||||||
const settingsActive = computed(() =>
|
|
||||||
projectSettingsItems.value.some((item) => isCurrentUrl(item.href)),
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
v-if="projectItems.length"
|
|
||||||
class="bg-card md:px-page flex min-w-0 items-center gap-2 border-b px-4"
|
|
||||||
>
|
|
||||||
<nav
|
|
||||||
:aria-label="t('navigation.project')"
|
|
||||||
class="flex min-w-0 flex-1 gap-4 overflow-x-auto"
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
v-for="item in projectItems"
|
|
||||||
:key="item.title"
|
|
||||||
:href="item.href"
|
|
||||||
:aria-current="isCurrentUrl(item.href) ? 'page' : undefined"
|
|
||||||
:class="[
|
|
||||||
'min-h-control text-body flex shrink-0 items-center gap-2 border-b-2 px-1 py-3 font-medium outline-offset-[-3px]',
|
|
||||||
isCurrentUrl(item.href)
|
|
||||||
? 'border-brand text-primary'
|
|
||||||
: 'text-muted-foreground hover:border-border hover:text-foreground border-transparent',
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<component
|
|
||||||
:is="item.icon"
|
|
||||||
class="size-4"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>{{ item.title }}
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
<DropdownMenu v-if="projectSettingsItems.length">
|
|
||||||
<DropdownMenuTrigger as-child>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
:aria-label="t('navigation.projectSettings')"
|
|
||||||
:class="{
|
|
||||||
'bg-accent text-accent-foreground': settingsActive,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Settings2 aria-hidden="true" /><span
|
|
||||||
class="hidden sm:inline"
|
|
||||||
>{{ t('navigation.projectSettings') }}</span
|
|
||||||
><ChevronDown class="hidden sm:block" aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
v-for="item in projectSettingsItems"
|
|
||||||
:key="item.title"
|
|
||||||
as-child
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
:href="item.href"
|
|
||||||
:aria-current="
|
|
||||||
isCurrentUrl(item.href) ? 'page' : undefined
|
|
||||||
"
|
|
||||||
:class="{ 'bg-accent': isCurrentUrl(item.href) }"
|
|
||||||
>
|
|
||||||
<component :is="item.icon" aria-hidden="true" />{{
|
|
||||||
item.title
|
|
||||||
}}
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,93 +1,45 @@
|
|||||||
<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 { TicketTreeListProps } from '@/types/ticket-tree';
|
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
|
||||||
|
|
||||||
defineProps<TicketTreeListProps>();
|
defineProps<{
|
||||||
|
nodes: TicketTreeNodeModel[];
|
||||||
const { t } = useLocalization();
|
projectId: number;
|
||||||
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="grid min-w-0 gap-3">
|
<ul class="space-y-3">
|
||||||
<li v-for="node in nodes" :key="node.id" class="min-w-0">
|
<li v-for="node in nodes" :key="node.id">
|
||||||
<div class="grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] gap-2">
|
<div class="border-border rounded-xl border px-4 py-4">
|
||||||
<div class="flex justify-center pt-1">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<button
|
<Link
|
||||||
v-if="node.children.length > 0"
|
:href="
|
||||||
type="button"
|
showTicket({
|
||||||
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"
|
project: projectId,
|
||||||
:aria-expanded="!collapsedTicketIds.has(node.id)"
|
ticket: 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 },
|
|
||||||
)
|
|
||||||
"
|
"
|
||||||
@click="toggleTicket(node.id)"
|
class="text-foreground hover:text-primary font-medium transition"
|
||||||
>
|
>
|
||||||
<ChevronRight
|
{{ node.ticket_key }}
|
||||||
v-if="collapsedTicketIds.has(node.id)"
|
</Link>
|
||||||
class="size-4"
|
|
||||||
aria-hidden="true"
|
<Badge v-if="node.ticket_type_name" variant="secondary">
|
||||||
/>
|
{{ node.ticket_type_name }}
|
||||||
<ChevronDown v-else class="size-4" aria-hidden="true" />
|
</Badge>
|
||||||
</button>
|
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
class="bg-border mt-4 size-1.5 rounded-full"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
<div class="text-foreground mt-2 text-sm">
|
||||||
:href="
|
{{ node.title }}
|
||||||
showTicket({
|
</div>
|
||||||
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="
|
v-if="node.children.length > 0"
|
||||||
node.children.length > 0 && !collapsedTicketIds.has(node.id)
|
class="border-border mt-3 border-l pl-4"
|
||||||
"
|
|
||||||
: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"
|
||||||
|
|||||||
@@ -1,22 +1,33 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
import { computed } from 'vue';
|
||||||
|
import { useInitials } from '@/composables/useInitials';
|
||||||
import type { User } from '@/types';
|
import type { User } from '@/types';
|
||||||
|
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||||
|
|
||||||
withDefaults(defineProps<{ user: User; showEmail?: boolean }>(), {
|
type Props = {
|
||||||
|
user: User;
|
||||||
|
showEmail?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
showEmail: false,
|
showEmail: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { getInitials } = useInitials();
|
||||||
|
|
||||||
|
// Compute whether we should show the avatar image
|
||||||
|
const showAvatar = computed(
|
||||||
|
() => props.user.avatar && props.user.avatar !== '',
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
<UserAvatar />
|
||||||
<UserAvatar :user="user" />
|
|
||||||
<div class="grid min-w-0 flex-1 text-left text-sm leading-tight">
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span class="truncate font-medium">{{ user.name }}</span>
|
<span class="truncate font-medium">{{ user.name }}</span>
|
||||||
<span
|
<span v-if="showEmail" class="text-muted-foreground truncate text-xs">{{
|
||||||
v-if="showEmail"
|
user.email
|
||||||
class="text-muted-foreground truncate text-xs"
|
}}</span>
|
||||||
>{{ user.email }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import UserInfo from '@/components/UserInfo.vue';
|
|||||||
import { logout } from '@/routes/index';
|
import { logout } from '@/routes/index';
|
||||||
import { edit } from '@/routes/profile/index';
|
import { edit } from '@/routes/profile/index';
|
||||||
import type { User } from '@/types';
|
import type { User } from '@/types';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
user: User;
|
user: User;
|
||||||
@@ -22,7 +21,6 @@ const handleLogout = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
defineProps<Props>();
|
defineProps<Props>();
|
||||||
const { t } = useLocalization();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -36,7 +34,7 @@ const { t } = useLocalization();
|
|||||||
<DropdownMenuItem :as-child="true">
|
<DropdownMenuItem :as-child="true">
|
||||||
<Link class="block w-full cursor-pointer" :href="edit()" prefetch>
|
<Link class="block w-full cursor-pointer" :href="edit()" prefetch>
|
||||||
<Settings class="mr-2 h-4 w-4" />
|
<Settings class="mr-2 h-4 w-4" />
|
||||||
{{ t('navigation.settings') }}
|
Settings
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
@@ -50,7 +48,7 @@ const { t } = useLocalization();
|
|||||||
data-test="logout-button"
|
data-test="logout-button"
|
||||||
>
|
>
|
||||||
<LogOut class="mr-2 h-4 w-4" />
|
<LogOut class="mr-2 h-4 w-4" />
|
||||||
{{ t('navigation.logout') }}
|
Log out
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Link } from '@inertiajs/vue3';
|
|
||||||
import { ArrowUpRight } from 'lucide-vue-next';
|
|
||||||
import TicketMetadata from '@/components/tickets/TicketMetadata.vue';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
|
||||||
import { show as showTicket } from '@/routes/projects/tickets';
|
|
||||||
import type {
|
|
||||||
TicketPresentationEmits,
|
|
||||||
TicketPresentationProps,
|
|
||||||
} from '@/types/ticket-presentation';
|
|
||||||
|
|
||||||
withDefaults(defineProps<TicketPresentationProps>(), {
|
|
||||||
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>
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Link } from '@inertiajs/vue3';
|
|
||||||
import { ArrowUpRight } from 'lucide-vue-next';
|
|
||||||
import TicketMetadata from '@/components/tickets/TicketMetadata.vue';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
|
||||||
import { show as showTicket } from '@/routes/projects/tickets';
|
|
||||||
import type {
|
|
||||||
TicketPresentationEmits,
|
|
||||||
TicketPresentationProps,
|
|
||||||
} from '@/types/ticket-presentation';
|
|
||||||
|
|
||||||
withDefaults(defineProps<TicketPresentationProps>(), {
|
|
||||||
showDescription: true,
|
|
||||||
showStatus: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits<TicketPresentationEmits>();
|
|
||||||
const { t } = useLocalization();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<li
|
|
||||||
:class="[
|
|
||||||
'bg-card text-card-foreground min-w-0 rounded-md border',
|
|
||||||
ticket.is_blocked ? 'border-warning' : 'border-border',
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<div class="flex min-w-0 flex-col lg:flex-row lg:items-stretch">
|
|
||||||
<Link
|
|
||||||
:href="showTicket({ project: projectId, ticket: ticket.id })"
|
|
||||||
:aria-label="
|
|
||||||
t('tickets.open', {
|
|
||||||
key: ticket.ticket_key,
|
|
||||||
title: ticket.title,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
class="focus-visible:ring-ring/35 gap-card min-h-control hover:bg-accent/35 group flex min-w-0 flex-1 flex-col rounded-t-md p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2 lg:rounded-l-md lg:rounded-tr-none"
|
|
||||||
@click="emit('open', ticket.id)"
|
|
||||||
>
|
|
||||||
<div class="flex min-w-0 items-start gap-3">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
class="wrap-anywhere max-w-full whitespace-normal"
|
|
||||||
>
|
|
||||||
{{ ticket.ticket_key }}
|
|
||||||
</Badge>
|
|
||||||
<h3
|
|
||||||
class="text-body group-hover:text-primary wrap-anywhere min-w-0 flex-1 font-semibold"
|
|
||||||
>
|
|
||||||
{{ ticket.title }}
|
|
||||||
</h3>
|
|
||||||
<ArrowUpRight
|
|
||||||
class="text-muted-foreground group-hover:text-primary size-4 shrink-0"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TicketMetadata :ticket="ticket" :show-status="showStatus" />
|
|
||||||
|
|
||||||
<p
|
|
||||||
v-if="showDescription && ticket.description"
|
|
||||||
class="text-caption text-muted-foreground wrap-anywhere line-clamp-2"
|
|
||||||
>
|
|
||||||
{{ ticket.description }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p
|
|
||||||
v-if="ticket.is_blocked && ticket.blocked_comment"
|
|
||||||
class="text-caption text-warning-foreground bg-warning wrap-anywhere rounded-sm px-3 py-2"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
t('tickets.blockerComment', {
|
|
||||||
comment: ticket.blocked_comment,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="$slots.actions"
|
|
||||||
class="border-t p-4 lg:flex lg:w-80 lg:items-center lg:border-l lg:border-t-0"
|
|
||||||
>
|
|
||||||
<slot name="actions" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</template>
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { CircleUserRound, OctagonAlert } from 'lucide-vue-next';
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { UserAvatar } from '@/components/ui/avatar';
|
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
|
||||||
import { ticketStatusLabel } from '@/lib/tickets';
|
|
||||||
import type { TicketModel } from '@/types/models/ticket-model';
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
showStatus?: boolean;
|
|
||||||
ticket: TicketModel;
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
showStatus: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const { t } = useLocalization();
|
|
||||||
const statusLabel = computed(() =>
|
|
||||||
ticketStatusLabel(props.ticket.ticket_status, t('tickets.noStatus')),
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="gap-stack flex min-w-0 flex-wrap items-center">
|
|
||||||
<Badge
|
|
||||||
v-if="ticket.is_blocked"
|
|
||||||
variant="warning"
|
|
||||||
:aria-label="t('tickets.blocked')"
|
|
||||||
>
|
|
||||||
<OctagonAlert aria-hidden="true" />
|
|
||||||
{{ t('tickets.blocked') }}
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
:aria-label="
|
|
||||||
t('tickets.typeLabel', { name: ticket.ticket_type.name })
|
|
||||||
"
|
|
||||||
:data-ticket-type-id="ticket.ticket_type.id"
|
|
||||||
>
|
|
||||||
{{ ticket.ticket_type.name }}
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
:aria-label="
|
|
||||||
t('tickets.priorityLabel', {
|
|
||||||
name: ticket.ticket_priority.name,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
:data-ticket-priority-id="ticket.ticket_priority.id"
|
|
||||||
>
|
|
||||||
{{ ticket.ticket_priority.name }}
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
<Badge
|
|
||||||
v-if="showStatus"
|
|
||||||
variant="outline"
|
|
||||||
:aria-label="t('tickets.statusLabel', { name: statusLabel })"
|
|
||||||
:data-ticket-status-id="ticket.ticket_status?.id"
|
|
||||||
>
|
|
||||||
{{ statusLabel }}
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
<span
|
|
||||||
v-if="ticket.assignee"
|
|
||||||
class="text-caption text-muted-foreground flex min-w-0 items-center gap-1.5"
|
|
||||||
:aria-label="
|
|
||||||
t('tickets.assignedTo', { name: ticket.assignee.name })
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<UserAvatar
|
|
||||||
:user="ticket.assignee"
|
|
||||||
size="sm"
|
|
||||||
:label="ticket.assignee.name"
|
|
||||||
/>
|
|
||||||
<span class="max-w-40 truncate">{{ ticket.assignee.name }}</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
class="text-caption text-muted-foreground flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<CircleUserRound class="size-4" aria-hidden="true" />
|
|
||||||
{{ t('tickets.unassigned') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,613 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,658 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { default as TicketCard } from './TicketCard.vue';
|
|
||||||
export { default as TicketListItem } from './TicketListItem.vue';
|
|
||||||
@@ -1,35 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AvatarRootProps } from "reka-ui";
|
import type { HTMLAttributes } from "vue"
|
||||||
import type { HTMLAttributes } from "vue";
|
import { AvatarRoot } from "reka-ui"
|
||||||
import type { AvatarVariants } from ".";
|
import { cn } from "@/lib/utils"
|
||||||
import { reactiveOmit } from "@vueuse/core";
|
|
||||||
import { AvatarRoot, useForwardProps } from "reka-ui";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { avatarVariants } from ".";
|
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = defineProps<{
|
||||||
defineProps<
|
class?: HTMLAttributes["class"]
|
||||||
AvatarRootProps & {
|
}>()
|
||||||
class?: HTMLAttributes["class"];
|
|
||||||
size?: AvatarVariants["size"];
|
|
||||||
shape?: AvatarVariants["shape"];
|
|
||||||
}
|
|
||||||
>(),
|
|
||||||
{ size: "default", shape: "circle" },
|
|
||||||
);
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class", "size", "shape");
|
|
||||||
const forwardedProps = useForwardProps(delegatedProps);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AvatarRoot
|
<AvatarRoot
|
||||||
data-slot="avatar"
|
data-slot="avatar"
|
||||||
:data-size="size"
|
:class="cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', props.class)"
|
||||||
:data-shape="shape"
|
>
|
||||||
v-bind="forwardedProps"
|
<slot />
|
||||||
:class="cn(avatarVariants({ size, shape }), props.class)"
|
</AvatarRoot>
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</AvatarRoot>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,28 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AvatarFallbackProps } from "reka-ui";
|
import type { AvatarFallbackProps } from "reka-ui"
|
||||||
import type { HTMLAttributes } from "vue";
|
import type { HTMLAttributes } from "vue"
|
||||||
import { reactiveOmit } from "@vueuse/core";
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
import { AvatarFallback } from "reka-ui";
|
import { AvatarFallback } from "reka-ui"
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const props = defineProps<
|
const props = defineProps<AvatarFallbackProps & { class?: HTMLAttributes["class"] }>()
|
||||||
AvatarFallbackProps & { class?: HTMLAttributes["class"] }
|
|
||||||
>();
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class");
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AvatarFallback
|
<AvatarFallback
|
||||||
data-slot="avatar-fallback"
|
data-slot="avatar-fallback"
|
||||||
v-bind="delegatedProps"
|
v-bind="delegatedProps"
|
||||||
:class="
|
:class="cn('bg-muted flex size-full items-center justify-center rounded-full', props.class)"
|
||||||
cn(
|
>
|
||||||
'bg-accent text-accent-foreground flex size-full items-center justify-center font-semibold',
|
<slot />
|
||||||
props.class,
|
</AvatarFallback>
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</AvatarFallback>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,39 +1,41 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AvatarVariants } from "@/components/ui/avatar";
|
|
||||||
import type { UserModel } from "@/types/models/user-model";
|
|
||||||
import { usePage } from "@inertiajs/vue3";
|
|
||||||
import { computed } from "vue";
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
||||||
import { getInitials } from "@/composables/useInitials";
|
import { getInitials } from "@/composables/useInitials";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from "@/components/ui/avatar/index";
|
||||||
|
import { usePage } from "@inertiajs/vue3";
|
||||||
|
import { computed, PropType } from "vue";
|
||||||
|
import { UserModel } from "@/types/models/user-model";
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = defineProps({
|
||||||
defineProps<{
|
user: {
|
||||||
user?: UserModel;
|
type: Object as PropType<UserModel>,
|
||||||
rounded?: boolean;
|
|
||||||
size?: AvatarVariants["size"];
|
|
||||||
label?: string;
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
rounded: true,
|
|
||||||
size: "default",
|
|
||||||
},
|
},
|
||||||
);
|
rounded: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const page = usePage();
|
const page = usePage();
|
||||||
const user = computed(() => props.user ?? page.props.auth.user);
|
const auth = computed(() => page.props.auth);
|
||||||
const accessibleLabel = computed(() => props.label ?? user.value.name);
|
|
||||||
const initials = computed(() => getInitials(user.value.name));
|
const user = computed(() => props.user ?? auth.value.user)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Avatar :size="size" :shape="rounded ? 'circle' : 'rounded'">
|
<Avatar class="size-8 overflow-hidden" :class="rounded ? 'rounded-full' : 'rounded-lg'">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
v-if="user.avatar"
|
v-if="user.avatar"
|
||||||
:src="user.avatar"
|
:src="user.avatar"
|
||||||
:alt="accessibleLabel"
|
:alt="user.name"
|
||||||
/>
|
/>
|
||||||
<AvatarFallback :aria-label="accessibleLabel">
|
<AvatarFallback
|
||||||
{{ initials }}
|
class="rounded-lg bg-neutral-200 font-semibold text-black dark:bg-neutral-700 dark:text-white"
|
||||||
|
>
|
||||||
|
{{ getInitials(auth.user?.name) }}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,31 +1,3 @@
|
|||||||
import type { VariantProps } from "class-variance-authority";
|
export { default as Avatar } from "./Avatar.vue"
|
||||||
import { cva } from "class-variance-authority";
|
export { default as AvatarFallback } from "./AvatarFallback.vue"
|
||||||
|
export { default as AvatarImage } from "./AvatarImage.vue"
|
||||||
export { default as Avatar } from "./Avatar.vue";
|
|
||||||
export { default as AvatarFallback } from "./AvatarFallback.vue";
|
|
||||||
export { default as AvatarImage } from "./AvatarImage.vue";
|
|
||||||
export { default as UserAvatar } from "./UserAvatar.vue";
|
|
||||||
|
|
||||||
export const avatarVariants = cva(
|
|
||||||
"border-border bg-muted relative flex shrink-0 overflow-hidden border",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
size: {
|
|
||||||
sm: "size-6 text-label",
|
|
||||||
default: "size-8 text-caption",
|
|
||||||
lg: "size-10 text-body",
|
|
||||||
xl: "size-16 text-title",
|
|
||||||
},
|
|
||||||
shape: {
|
|
||||||
circle: "rounded-full",
|
|
||||||
rounded: "rounded-lg",
|
|
||||||
square: "rounded-sm",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
size: "default",
|
|
||||||
shape: "circle",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
export type AvatarVariants = VariantProps<typeof avatarVariants>;
|
|
||||||
|
|||||||
@@ -1,28 +1,26 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { PrimitiveProps } from "reka-ui";
|
import type { PrimitiveProps } from "reka-ui"
|
||||||
import type { HTMLAttributes } from "vue";
|
import type { HTMLAttributes } from "vue"
|
||||||
import type { BadgeVariants } from ".";
|
import type { BadgeVariants } from "."
|
||||||
import { reactiveOmit } from "@vueuse/core";
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
import { Primitive } from "reka-ui";
|
import { Primitive } from "reka-ui"
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils"
|
||||||
import { badgeVariants } from ".";
|
import { badgeVariants } from "."
|
||||||
|
|
||||||
const props = defineProps<
|
const props = defineProps<PrimitiveProps & {
|
||||||
PrimitiveProps & {
|
variant?: BadgeVariants["variant"]
|
||||||
variant?: BadgeVariants["variant"];
|
class?: HTMLAttributes["class"]
|
||||||
class?: HTMLAttributes["class"];
|
}>()
|
||||||
}
|
|
||||||
>();
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class", "variant");
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<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>
|
||||||
|
|||||||
@@ -1,31 +1,26 @@
|
|||||||
import type { VariantProps } from "class-variance-authority";
|
import type { VariantProps } from "class-variance-authority"
|
||||||
import { cva } from "class-variance-authority";
|
import { cva } from "class-variance-authority"
|
||||||
|
|
||||||
export { default as Badge } from "./Badge.vue";
|
export { default as Badge } from "./Badge.vue"
|
||||||
|
|
||||||
export const badgeVariants = cva(
|
export const badgeVariants = cva(
|
||||||
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2.5 py-1 text-label whitespace-nowrap transition-[background-color,border-color,color,box-shadow] outline-none [&>svg]:pointer-events-none [&>svg]:size-3 [&>svg]:shrink-0 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
secondary:
|
secondary:
|
||||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/80",
|
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
destructive:
|
destructive:
|
||||||
"border-transparent bg-destructive text-destructive-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/35",
|
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
outline:
|
outline:
|
||||||
"border-border bg-card text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
success:
|
},
|
||||||
"border-transparent bg-success text-success-foreground [a&]:hover:brightness-95",
|
|
||||||
warning:
|
|
||||||
"border-transparent bg-warning text-warning-foreground [a&]:hover:brightness-95",
|
|
||||||
info: "border-transparent bg-info text-info-foreground [a&]:hover:brightness-95",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
defaultVariants: {
|
||||||
export type BadgeVariants = VariantProps<typeof badgeVariants>;
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||||
|
|||||||
@@ -1,47 +1,29 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { PrimitiveProps } from "reka-ui";
|
import type { PrimitiveProps } from "reka-ui"
|
||||||
import type { HTMLAttributes } from "vue";
|
import type { HTMLAttributes } from "vue"
|
||||||
import type { ButtonVariants } from ".";
|
import type { ButtonVariants } from "."
|
||||||
import { Primitive } from "reka-ui";
|
import { Primitive } from "reka-ui"
|
||||||
import { computed } from "vue";
|
import { cn } from "@/lib/utils"
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { buttonVariants } from "."
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { buttonVariants } from ".";
|
|
||||||
|
|
||||||
interface Props extends PrimitiveProps {
|
interface Props extends PrimitiveProps {
|
||||||
variant?: ButtonVariants["variant"];
|
variant?: ButtonVariants["variant"]
|
||||||
size?: ButtonVariants["size"];
|
size?: ButtonVariants["size"]
|
||||||
class?: HTMLAttributes["class"];
|
class?: HTMLAttributes["class"]
|
||||||
disabled?: boolean;
|
|
||||||
loading?: boolean;
|
|
||||||
loadingLabel?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
as: "button",
|
as: "button",
|
||||||
disabled: false,
|
})
|
||||||
loading: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isDisabled = computed(() => props.disabled || props.loading);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Primitive
|
<Primitive
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
:data-loading="loading ? '' : undefined"
|
:as="as"
|
||||||
:as="as"
|
:as-child="asChild"
|
||||||
:as-child="asChild"
|
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||||
:disabled="isDisabled"
|
>
|
||||||
:aria-disabled="isDisabled ? 'true' : undefined"
|
<slot />
|
||||||
:aria-busy="loading ? 'true' : undefined"
|
</Primitive>
|
||||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
|
||||||
>
|
|
||||||
<Spinner
|
|
||||||
v-if="loading"
|
|
||||||
data-slot="button-spinner"
|
|
||||||
:label="loadingLabel"
|
|
||||||
/>
|
|
||||||
<slot />
|
|
||||||
</Primitive>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,37 +1,38 @@
|
|||||||
import type { VariantProps } from "class-variance-authority";
|
import type { VariantProps } from "class-variance-authority"
|
||||||
import { cva } from "class-variance-authority";
|
import { cva } from "class-variance-authority"
|
||||||
|
|
||||||
export { default as Button } from "./Button.vue";
|
export { default as Button } from "./Button.vue"
|
||||||
|
|
||||||
export const buttonVariants = cva(
|
export const buttonVariants = cva(
|
||||||
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-body font-semibold transition-[background-color,border-color,color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[loading]:cursor-wait data-[loading]:opacity-80 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 focus-visible:ring-offset-2 focus-visible:ring-offset-background [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"border border-transparent bg-brand text-brand-foreground shadow-xs hover:brightness-95",
|
"bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
destructive:
|
destructive:
|
||||||
"border border-transparent bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/35",
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
outline:
|
outline:
|
||||||
"border border-input bg-card text-foreground shadow-xs hover:border-primary/45 hover:bg-accent hover:text-accent-foreground",
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
secondary:
|
secondary:
|
||||||
"border border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
ghost: "border border-transparent hover:bg-accent hover:text-accent-foreground",
|
ghost:
|
||||||
link: "min-h-0 border-0 px-0 text-primary underline-offset-4 shadow-none hover:underline",
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
},
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
size: {
|
},
|
||||||
default: "min-h-control px-4 py-2 has-[>svg]:px-3",
|
size: {
|
||||||
sm: "min-h-9 gap-1.5 px-3 py-1.5 has-[>svg]:px-2.5",
|
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
lg: "min-h-12 px-6 py-3 has-[>svg]:px-4",
|
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
icon: "size-control",
|
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
"icon-sm": "size-9",
|
"icon": "size-9",
|
||||||
"icon-lg": "size-12",
|
"icon-sm": "size-8",
|
||||||
},
|
"icon-lg": "size-10",
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
defaultVariants: {
|
||||||
export type ButtonVariants = VariantProps<typeof buttonVariants>;
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||||
|
|||||||
@@ -1,48 +1,35 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui";
|
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui"
|
||||||
import type { HTMLAttributes } from "vue";
|
import type { HTMLAttributes } from "vue"
|
||||||
import { reactiveOmit } from "@vueuse/core";
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
import { Check } from "lucide-vue-next";
|
import { Check } from "lucide-vue-next"
|
||||||
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui";
|
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui"
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>()
|
||||||
defineProps<
|
const emits = defineEmits<CheckboxRootEmits>()
|
||||||
CheckboxRootProps & {
|
|
||||||
class?: HTMLAttributes["class"];
|
|
||||||
invalid?: boolean;
|
|
||||||
}
|
|
||||||
>(),
|
|
||||||
{ invalid: false },
|
|
||||||
);
|
|
||||||
const emits = defineEmits<CheckboxRootEmits>();
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class", "invalid");
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CheckboxRoot
|
<CheckboxRoot
|
||||||
v-slot="slotProps"
|
v-slot="slotProps"
|
||||||
data-slot="checkbox"
|
data-slot="checkbox"
|
||||||
:data-invalid="invalid ? '' : undefined"
|
v-bind="forwarded"
|
||||||
:aria-invalid="invalid ? 'true' : undefined"
|
:class="
|
||||||
v-bind="forwarded"
|
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',
|
||||||
:class="
|
props.class)"
|
||||||
cn(
|
>
|
||||||
'peer border-input bg-card size-5 shrink-0 rounded-md border shadow-xs transition-[background-color,border-color,box-shadow] outline-none hover:border-primary/55 data-[state=checked]:border-brand data-[state=checked]:bg-brand data-[state=checked]:text-brand-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25',
|
<CheckboxIndicator
|
||||||
props.class,
|
data-slot="checkbox-indicator"
|
||||||
)
|
class="grid place-content-center text-current transition-none"
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<CheckboxIndicator
|
<slot v-bind="slotProps">
|
||||||
data-slot="checkbox-indicator"
|
<Check class="size-3.5" />
|
||||||
class="grid place-content-center text-current transition-none"
|
</slot>
|
||||||
>
|
</CheckboxIndicator>
|
||||||
<slot v-bind="slotProps">
|
</CheckboxRoot>
|
||||||
<Check class="size-4" stroke-width="2.5" />
|
|
||||||
</slot>
|
|
||||||
</CheckboxIndicator>
|
|
||||||
</CheckboxRoot>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,67 +1,53 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
|
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||||
import type { HTMLAttributes } from "vue";
|
import type { HTMLAttributes } from "vue"
|
||||||
import { reactiveOmit } from "@vueuse/core";
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
import { X } from "lucide-vue-next";
|
import { X } from "lucide-vue-next"
|
||||||
import {
|
import {
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogPortal,
|
DialogPortal,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "reka-ui";
|
} from "reka-ui"
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils"
|
||||||
import DialogOverlay from "./DialogOverlay.vue";
|
import DialogOverlay from "./DialogOverlay.vue"
|
||||||
import { useDialogFocus } from "@/composables/useDialogFocus";
|
|
||||||
import { useLocalization } from "@/composables/useLocalization";
|
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
});
|
})
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
|
||||||
defineProps<
|
showCloseButton: true,
|
||||||
DialogContentProps & {
|
})
|
||||||
class?: HTMLAttributes["class"];
|
const emits = defineEmits<DialogContentEmits>()
|
||||||
showCloseButton?: boolean;
|
|
||||||
}
|
|
||||||
>(),
|
|
||||||
{
|
|
||||||
showCloseButton: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const emits = defineEmits<DialogContentEmits>();
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class", "showCloseButton");
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
const { restoreFocus } = useDialogFocus();
|
|
||||||
const { t } = useLocalization();
|
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogContent
|
<DialogContent
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
v-bind="{ ...$attrs, ...forwarded }"
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
@close-auto-focus="restoreFocus"
|
:class="
|
||||||
:class="
|
cn(
|
||||||
cn(
|
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||||
'bg-card text-card-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-column overflow-y-auto overscroll-contain rounded-md border p-page shadow-lg duration-200 motion-reduce:animate-none sm:max-w-lg',
|
props.class,
|
||||||
props.class,
|
)"
|
||||||
)
|
>
|
||||||
"
|
<slot />
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
|
|
||||||
<DialogClose
|
<DialogClose
|
||||||
v-if="showCloseButton"
|
v-if="showCloseButton"
|
||||||
data-slot="dialog-close"
|
data-slot="dialog-close"
|
||||||
class="ring-offset-background focus-visible:ring-ring absolute top-1 right-1 flex size-control items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
>
|
>
|
||||||
<X />
|
<X />
|
||||||
<span class="sr-only">{{ t("common.close") }}</span>
|
<span class="sr-only">Close</span>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
</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-stack pr-6 text-left', props.class)"
|
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,71 +1,59 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
|
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||||
import type { HTMLAttributes } from "vue";
|
import type { HTMLAttributes } from "vue"
|
||||||
import { reactiveOmit } from "@vueuse/core";
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
import { X } from "lucide-vue-next";
|
import { X } from "lucide-vue-next"
|
||||||
import {
|
import {
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogOverlay,
|
DialogOverlay,
|
||||||
DialogPortal,
|
DialogPortal,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "reka-ui";
|
} from "reka-ui"
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils"
|
||||||
import { useDialogFocus } from "@/composables/useDialogFocus";
|
|
||||||
import { useLocalization } from "@/composables/useLocalization";
|
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
});
|
})
|
||||||
|
|
||||||
const props = defineProps<
|
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||||
DialogContentProps & { class?: HTMLAttributes["class"] }
|
const emits = defineEmits<DialogContentEmits>()
|
||||||
>();
|
|
||||||
const emits = defineEmits<DialogContentEmits>();
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class");
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
const { restoreFocus } = useDialogFocus();
|
|
||||||
const { t } = useLocalization();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<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
|
<DialogContent
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'relative z-50 my-4 grid w-full max-w-[calc(100%-2rem)] gap-column rounded-md border bg-card p-page text-card-foreground shadow-lg duration-200 sm:max-w-lg',
|
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
|
||||||
props.class,
|
props.class,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
v-bind="{ ...$attrs, ...forwarded }"
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
@close-auto-focus="restoreFocus"
|
@pointer-down-outside="(event) => {
|
||||||
@pointer-down-outside="
|
const originalEvent = event.detail.originalEvent;
|
||||||
(event) => {
|
const target = originalEvent.target as HTMLElement;
|
||||||
const originalEvent = event.detail.originalEvent;
|
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
||||||
const target = originalEvent.target as HTMLElement;
|
event.preventDefault();
|
||||||
if (
|
}
|
||||||
originalEvent.offsetX > target.clientWidth ||
|
}"
|
||||||
originalEvent.offsetY > target.clientHeight
|
>
|
||||||
) {
|
<slot />
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
|
|
||||||
<DialogClose
|
<DialogClose
|
||||||
class="absolute top-1 right-1 flex size-control items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"
|
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||||
>
|
>
|
||||||
<X class="w-4 h-4" />
|
<X class="w-4 h-4" />
|
||||||
<span class="sr-only">{{ t("common.close") }}</span>
|
<span class="sr-only">Close</span>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</DialogOverlay>
|
</DialogOverlay>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,48 +1,39 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {
|
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from "reka-ui"
|
||||||
DropdownMenuCheckboxItemEmits,
|
import type { HTMLAttributes } from "vue"
|
||||||
DropdownMenuCheckboxItemProps,
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
} from "reka-ui";
|
import { Check } from "lucide-vue-next"
|
||||||
import type { HTMLAttributes } from "vue";
|
|
||||||
import { reactiveOmit } from "@vueuse/core";
|
|
||||||
import { Check } from "lucide-vue-next";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
DropdownMenuItemIndicator,
|
DropdownMenuItemIndicator,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "reka-ui";
|
} from "reka-ui"
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const props = defineProps<
|
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>()
|
||||||
DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }
|
const emits = defineEmits<DropdownMenuCheckboxItemEmits>()
|
||||||
>();
|
|
||||||
const emits = defineEmits<DropdownMenuCheckboxItemEmits>();
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class");
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
data-slot="dropdown-menu-checkbox-item"
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
:class="
|
:class=" cn(
|
||||||
cn(
|
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||||
'focus:bg-accent focus:text-accent-foreground relative flex min-h-control cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
props.class,
|
||||||
props.class,
|
)"
|
||||||
)
|
>
|
||||||
"
|
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
>
|
<DropdownMenuItemIndicator>
|
||||||
<span
|
<slot name="indicator-icon">
|
||||||
class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"
|
<Check class="size-4" />
|
||||||
>
|
</slot>
|
||||||
<DropdownMenuItemIndicator>
|
</DropdownMenuItemIndicator>
|
||||||
<slot name="indicator-icon">
|
</span>
|
||||||
<Check class="size-4" />
|
<slot />
|
||||||
</slot>
|
</DropdownMenuCheckboxItem>
|
||||||
</DropdownMenuItemIndicator>
|
|
||||||
</span>
|
|
||||||
<slot />
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,49 +1,39 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {
|
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui"
|
||||||
DropdownMenuContentEmits,
|
import type { HTMLAttributes } from "vue"
|
||||||
DropdownMenuContentProps,
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
} from "reka-ui";
|
|
||||||
import type { HTMLAttributes } from "vue";
|
|
||||||
import { reactiveOmit } from "@vueuse/core";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuPortal,
|
DropdownMenuPortal,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "reka-ui";
|
} from "reka-ui"
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
});
|
})
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<
|
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||||
DropdownMenuContentProps & { class?: HTMLAttributes["class"] }
|
{
|
||||||
>(),
|
sideOffset: 4,
|
||||||
{
|
},
|
||||||
sideOffset: 4,
|
)
|
||||||
},
|
const emits = defineEmits<DropdownMenuContentEmits>()
|
||||||
);
|
|
||||||
const emits = defineEmits<DropdownMenuContentEmits>();
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class");
|
const delegatedProps = reactiveOmit(props, "class")
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DropdownMenuPortal>
|
<DropdownMenuPortal>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
data-slot="dropdown-menu-content"
|
data-slot="dropdown-menu-content"
|
||||||
v-bind="{ ...$attrs, ...forwarded }"
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
:class="
|
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', props.class)"
|
||||||
cn(
|
>
|
||||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] max-w-[calc(100vw-2rem)] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
<slot />
|
||||||
props.class,
|
</DropdownMenuContent>
|
||||||
)
|
</DropdownMenuPortal>
|
||||||
"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenuPortal>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,41 +1,31 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DropdownMenuItemProps } from "reka-ui";
|
import type { DropdownMenuItemProps } from "reka-ui"
|
||||||
import type { HTMLAttributes } from "vue";
|
import type { HTMLAttributes } from "vue"
|
||||||
import { reactiveOmit } from "@vueuse/core";
|
import { reactiveOmit } from "@vueuse/core"
|
||||||
import { DropdownMenuItem, useForwardProps } from "reka-ui";
|
import { DropdownMenuItem, useForwardProps } from "reka-ui"
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(defineProps<DropdownMenuItemProps & {
|
||||||
defineProps<
|
class?: HTMLAttributes["class"]
|
||||||
DropdownMenuItemProps & {
|
inset?: boolean
|
||||||
class?: HTMLAttributes["class"];
|
variant?: "default" | "destructive"
|
||||||
inset?: boolean;
|
}>(), {
|
||||||
variant?: "default" | "destructive";
|
variant: "default",
|
||||||
}
|
})
|
||||||
>(),
|
|
||||||
{
|
|
||||||
variant: "default",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "inset", "variant", "class");
|
const delegatedProps = reactiveOmit(props, "inset", "variant", "class")
|
||||||
|
|
||||||
const forwardedProps = useForwardProps(delegatedProps);
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<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="
|
:class="cn('focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', props.class)"
|
||||||
cn(
|
>
|
||||||
'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex min-h-control cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
<slot />
|
||||||
props.class,
|
</DropdownMenuItem>
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user