Merge branch 'codex/app-layout-redesign' into staging
This commit is contained in:
@@ -35,6 +35,7 @@ class BuildProjectBacklogPayload
|
||||
)->resolve(),
|
||||
'canCreateTickets' => $canManageTickets,
|
||||
'canEditTickets' => $canManageTickets,
|
||||
'canManageProject' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
|
||||
'project' => ProjectResource::make($project)->resolve(),
|
||||
'status' => $status,
|
||||
'ticketStatusOptions' => TicketStatusResource::collection(
|
||||
|
||||
@@ -29,6 +29,7 @@ class BuildProjectBoardPayload
|
||||
'ticketStatuses',
|
||||
'tickets' => fn ($query) => $query
|
||||
->with([
|
||||
'assignee',
|
||||
'ticketPriority',
|
||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||
'ticketStatus.boardColumn',
|
||||
@@ -39,6 +40,7 @@ class BuildProjectBoardPayload
|
||||
)->resolve(),
|
||||
'canCreateTickets' => $canManageTickets,
|
||||
'canEditTickets' => $canManageTickets,
|
||||
'canManageProject' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
|
||||
'project' => ProjectResource::make($project)->resolve(),
|
||||
'status' => $status,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Actions\Notifications\BuildProjectNotificationSettingsPayload;
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Actions\Repositories\BuildProjectRepositorySettingsPayload;
|
||||
use App\Enums\ProjectSettingsSection;
|
||||
use App\Http\Resources\PermissionOptionResource;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Http\Resources\ProjectTemplateResource;
|
||||
use App\Http\Resources\TeamResource;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class BuildProjectSettingsPayload
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BuildProjectBoardSettingsPayload $buildProjectBoardSettingsPayload,
|
||||
private readonly BuildProjectNotificationSettingsPayload $buildProjectNotificationSettingsPayload,
|
||||
private readonly BuildProjectRepositorySettingsPayload $buildProjectRepositorySettingsPayload,
|
||||
private readonly BuildProjectTicketMetadataSettingsPayload $buildProjectTicketMetadataSettingsPayload,
|
||||
private readonly PermissionService $permissionService,
|
||||
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function handle(
|
||||
Project $project,
|
||||
User $user,
|
||||
ProjectSettingsSection $section,
|
||||
mixed $status = null,
|
||||
mixed $error = null,
|
||||
): array {
|
||||
return [
|
||||
'activeSection' => $section->value,
|
||||
'error' => $error,
|
||||
'project' => ProjectResource::make($project)->resolve(),
|
||||
'sectionData' => $this->sectionData($project, $user, $section),
|
||||
'status' => $status,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function sectionData(
|
||||
Project $project,
|
||||
User $user,
|
||||
ProjectSettingsSection $section,
|
||||
): array {
|
||||
$payload = match ($section) {
|
||||
ProjectSettingsSection::General => $this->generalData($project),
|
||||
ProjectSettingsSection::Board => $this->buildProjectBoardSettingsPayload->handle($project),
|
||||
ProjectSettingsSection::Metadata => $this->buildProjectTicketMetadataSettingsPayload->handle($project),
|
||||
ProjectSettingsSection::Repositories => $this->buildProjectRepositorySettingsPayload->handle($project, $user),
|
||||
ProjectSettingsSection::Notifications => $this->buildProjectNotificationSettingsPayload->handle($project),
|
||||
ProjectSettingsSection::Access => $this->accessData($project),
|
||||
};
|
||||
|
||||
return Arr::except($payload, ['error', 'project', 'status']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function generalData(Project $project): array
|
||||
{
|
||||
return [
|
||||
'sourceTemplates' => ProjectTemplateResource::collection(
|
||||
ProjectTemplate::query()
|
||||
->published()
|
||||
->with('sourceProject')
|
||||
->orderBy('name')
|
||||
->get(),
|
||||
)->resolve(),
|
||||
'templateState' => $this->resolveProjectTemplateState->handle($project),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function accessData(Project $project): array
|
||||
{
|
||||
return [
|
||||
'directPermissions' => $this->permissionService->assignmentMap(User::class, $project),
|
||||
'permissionOptions' => PermissionOptionResource::collection(
|
||||
$this->permissionService->permissionOptions(User::class, Project::class),
|
||||
)->resolve(),
|
||||
'teamPermissions' => $this->permissionService->assignmentMap(Team::class, $project),
|
||||
'teams' => TeamResource::collection(
|
||||
Team::query()
|
||||
->with('users')
|
||||
->orderBy('name')
|
||||
->get(),
|
||||
)->resolve(),
|
||||
'users' => UserResource::collection(
|
||||
User::query()
|
||||
->orderBy('name')
|
||||
->get(),
|
||||
)->resolve(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ class BuildProjectTicketDetailPayload
|
||||
'fieldValues.userValue',
|
||||
'parentTicket',
|
||||
'ticketPriority',
|
||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||
'ticketStatus.boardColumn',
|
||||
'ticketType',
|
||||
'worklogEntries.actor',
|
||||
|
||||
@@ -7,9 +7,9 @@ use App\Http\Resources\TicketPriorityResource;
|
||||
use App\Http\Resources\TicketReferenceResource;
|
||||
use App\Http\Resources\TicketStatusResource;
|
||||
use App\Http\Resources\TicketTypeResource;
|
||||
use App\Models\Ticket;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
|
||||
class BuildProjectTicketOptionsPayload
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ProjectSettingsSection: string
|
||||
{
|
||||
case General = 'general';
|
||||
case Board = 'board';
|
||||
case Metadata = 'metadata';
|
||||
case Repositories = 'repositories';
|
||||
case Notifications = 'notifications';
|
||||
case Access = 'access';
|
||||
}
|
||||
@@ -7,8 +7,10 @@ use App\Actions\Projects\BuildProjectBoardPayload;
|
||||
use App\Actions\Tickets\MoveProjectTicket;
|
||||
use App\Enums\Permission;
|
||||
use App\Http\Requests\MoveProjectTicketRequest;
|
||||
use App\Http\Resources\TicketStatusResource;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
@@ -42,7 +44,7 @@ class ProjectBoardController extends Controller
|
||||
MoveProjectTicketRequest $request,
|
||||
Project $project,
|
||||
Ticket $ticket,
|
||||
): RedirectResponse {
|
||||
): RedirectResponse|JsonResponse {
|
||||
$user = $request->user();
|
||||
|
||||
abort_unless(
|
||||
@@ -56,13 +58,32 @@ class ProjectBoardController extends Controller
|
||||
: 'projects.board.index';
|
||||
|
||||
$result = $this->moveProjectTicket->handle($project, $ticket, $user, $validated);
|
||||
$statusMessage = $result['moved']
|
||||
? "{$ticket->ticket_key} moved to {$result['destination_label']}."
|
||||
: "{$ticket->ticket_key} already belongs to {$result['destination_label']}.";
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
$ticket->refresh()->load([
|
||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||
'ticketStatus.boardColumn',
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => $statusMessage,
|
||||
'moved' => $result['moved'],
|
||||
'ticket_status' => $ticket->ticketStatus === null
|
||||
? null
|
||||
: TicketStatusResource::make($ticket->ticketStatus)->resolve(),
|
||||
'ticket_status_id' => $ticket->ticket_status_id,
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $result['moved']) {
|
||||
return to_route($redirectRoute, $project)
|
||||
->with('status', "{$ticket->ticket_key} already belongs to {$result['destination_label']}.");
|
||||
->with('status', $statusMessage);
|
||||
}
|
||||
|
||||
return to_route($redirectRoute, $project)
|
||||
->with('status', "{$ticket->ticket_key} moved to {$result['destination_label']}.");
|
||||
->with('status', $statusMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ use App\Actions\ProjectBoard\StoreTicketStatus;
|
||||
use App\Actions\ProjectBoard\UpdateBoardColumn;
|
||||
use App\Actions\ProjectBoard\UpdateTicketStatus;
|
||||
use App\Actions\ProjectBoard\UpdateTicketStatusTransitions;
|
||||
use App\Actions\Projects\BuildProjectBoardSettingsPayload;
|
||||
use App\Enums\Permission;
|
||||
use App\Enums\ProjectSettingsSection;
|
||||
use App\Http\Requests\StoreBoardColumnRequest;
|
||||
use App\Http\Requests\StoreTicketStatusRequest;
|
||||
use App\Http\Requests\UpdateBoardColumnRequest;
|
||||
@@ -22,13 +22,10 @@ use App\Models\Project;
|
||||
use App\Models\TicketStatus;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ProjectBoardSettingsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BuildProjectBoardSettingsPayload $buildProjectBoardSettingsPayload,
|
||||
private readonly DeleteBoardColumn $deleteBoardColumn,
|
||||
private readonly DeleteTicketStatus $deleteTicketStatus,
|
||||
private readonly PermissionService $permissionService,
|
||||
@@ -39,19 +36,6 @@ class ProjectBoardSettingsController extends Controller
|
||||
private readonly UpdateTicketStatusTransitions $updateTicketStatusTransitions,
|
||||
) {}
|
||||
|
||||
public function show(Request $request, Project $project): Response
|
||||
{
|
||||
abort_unless(
|
||||
$this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project),
|
||||
403,
|
||||
);
|
||||
|
||||
return Inertia::render(
|
||||
'projects/settings/Board',
|
||||
$this->buildProjectBoardSettingsPayload->handle($project, session('status')),
|
||||
);
|
||||
}
|
||||
|
||||
public function store(
|
||||
StoreBoardColumnRequest $request,
|
||||
Project $project,
|
||||
@@ -62,7 +46,7 @@ class ProjectBoardSettingsController extends Controller
|
||||
);
|
||||
$boardColumn = $this->storeBoardColumn->handle($project, $request->validated());
|
||||
|
||||
return to_route('projects.settings.board', $project)
|
||||
return $this->settingsRedirect($project)
|
||||
->with('status', "{$boardColumn->name} was added to the board workflow.");
|
||||
}
|
||||
|
||||
@@ -77,7 +61,7 @@ class ProjectBoardSettingsController extends Controller
|
||||
);
|
||||
$boardColumn = $this->updateBoardColumn->handle($project, $boardColumn, $request->validated());
|
||||
|
||||
return to_route('projects.settings.board', $project)
|
||||
return $this->settingsRedirect($project)
|
||||
->with('status', "{$boardColumn->fresh()->name} was updated.");
|
||||
}
|
||||
|
||||
@@ -93,7 +77,7 @@ class ProjectBoardSettingsController extends Controller
|
||||
|
||||
$deletedName = $this->deleteBoardColumn->handle($project, $boardColumn);
|
||||
|
||||
return to_route('projects.settings.board', $project)
|
||||
return $this->settingsRedirect($project)
|
||||
->with('status', "{$deletedName} was removed from the board workflow.");
|
||||
}
|
||||
|
||||
@@ -107,7 +91,7 @@ class ProjectBoardSettingsController extends Controller
|
||||
);
|
||||
$ticketStatus = $this->storeTicketStatus->handle($project, $request->validated());
|
||||
|
||||
return to_route('projects.settings.board', $project)
|
||||
return $this->settingsRedirect($project)
|
||||
->with('status', "{$ticketStatus->name} was added to {$this->statusContextLabel($ticketStatus)}.");
|
||||
}
|
||||
|
||||
@@ -122,7 +106,7 @@ class ProjectBoardSettingsController extends Controller
|
||||
);
|
||||
$ticketStatus = $this->updateTicketStatus->handle($ticketStatus, $request->validated());
|
||||
|
||||
return to_route('projects.settings.board', $project)
|
||||
return $this->settingsRedirect($project)
|
||||
->with('status', "{$ticketStatus->fresh()->name} was updated.");
|
||||
}
|
||||
|
||||
@@ -138,7 +122,7 @@ class ProjectBoardSettingsController extends Controller
|
||||
|
||||
$result = $this->deleteTicketStatus->handle($project, $ticketStatus);
|
||||
|
||||
return to_route('projects.settings.board', $project)
|
||||
return $this->settingsRedirect($project)
|
||||
->with('status', "{$result['deleted_name']} was removed from {$result['context_label']}.");
|
||||
}
|
||||
|
||||
@@ -153,7 +137,7 @@ class ProjectBoardSettingsController extends Controller
|
||||
);
|
||||
$this->updateTicketStatusTransitions->handle($ticketStatus, $request->validated());
|
||||
|
||||
return to_route('projects.settings.board', $project)
|
||||
return $this->settingsRedirect($project)
|
||||
->with('status', "{$ticketStatus->name} move targets were updated.");
|
||||
}
|
||||
|
||||
@@ -161,4 +145,12 @@ class ProjectBoardSettingsController extends Controller
|
||||
{
|
||||
return $ticketStatus->boardColumn?->name ?? 'Backlog';
|
||||
}
|
||||
|
||||
private function settingsRedirect(Project $project): RedirectResponse
|
||||
{
|
||||
return to_route('projects.settings.index', [
|
||||
'project' => $project,
|
||||
'section' => ProjectSettingsSection::Board->value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,19 +8,16 @@ use App\Actions\Projects\CreateProject;
|
||||
use App\Actions\Projects\DeleteProject;
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\ListVisibleProjects;
|
||||
use App\Actions\Projects\ResolveProjectTemplateState;
|
||||
use App\Actions\Projects\UpdateProjectTemplateLink;
|
||||
use App\Enums\Permission;
|
||||
use App\Enums\ProjectSettingsSection;
|
||||
use App\Http\Requests\DeleteProjectRequest;
|
||||
use App\Http\Requests\ResetProjectTemplateRequest;
|
||||
use App\Http\Requests\StoreProjectRequest;
|
||||
use App\Http\Requests\SwitchProjectTemplateRequest;
|
||||
use App\Http\Requests\SyncProjectPermissionsRequest;
|
||||
use App\Http\Resources\PermissionOptionResource;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Http\Resources\ProjectTemplateResource;
|
||||
use App\Http\Resources\TeamResource;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use App\Models\Team;
|
||||
@@ -39,7 +36,6 @@ class ProjectController extends Controller
|
||||
private readonly DeleteProject $deleteProject,
|
||||
private readonly ListVisibleProjects $listVisibleProjects,
|
||||
private readonly PermissionService $permissionService,
|
||||
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
||||
private readonly UpdateProjectTemplateLink $updateProjectTemplateLink,
|
||||
) {}
|
||||
|
||||
@@ -83,10 +79,10 @@ class ProjectController extends Controller
|
||||
{
|
||||
$project = $this->createProject->handle($request->validated(), $request->user());
|
||||
|
||||
return to_route('projects.show', $project);
|
||||
return to_route('projects.board.index', $project);
|
||||
}
|
||||
|
||||
public function show(Request $request, Project $project): Response
|
||||
public function show(Request $request, Project $project): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
@@ -95,11 +91,7 @@ class ProjectController extends Controller
|
||||
403,
|
||||
);
|
||||
|
||||
return Inertia::render('projects/Show', [
|
||||
'canManageAccess' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
|
||||
'project' => ProjectResource::make($project)->resolve(),
|
||||
'templateState' => $this->resolveProjectTemplateState->handle($project),
|
||||
]);
|
||||
return to_route('projects.board.index', $project);
|
||||
}
|
||||
|
||||
public function backlog(Request $request, Project $project): Response
|
||||
@@ -117,31 +109,19 @@ class ProjectController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
public function general(Request $request, Project $project): Response
|
||||
{
|
||||
return Inertia::render('projects/settings/General', [
|
||||
'project' => ProjectResource::make($project)->resolve(),
|
||||
'sourceTemplates' => ProjectTemplateResource::collection(
|
||||
ProjectTemplate::query()
|
||||
->published()
|
||||
->with('sourceProject')
|
||||
->orderBy('name')
|
||||
->get(),
|
||||
)->resolve(),
|
||||
'status' => session('status'),
|
||||
'templateState' => $this->resolveProjectTemplateState->handle($project),
|
||||
]);
|
||||
}
|
||||
|
||||
public function switchTemplate(SwitchProjectTemplateRequest $request, Project $project): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$template = ProjectTemplate::query()
|
||||
->published()
|
||||
->findOrFail($request->integer('source_template_id'));
|
||||
->findOrFail((int) Arr::get($validated, 'source_template_id'));
|
||||
|
||||
$this->updateProjectTemplateLink->switch($project, $template);
|
||||
|
||||
return to_route('projects.settings.general', $project)
|
||||
return to_route('projects.settings.index', [
|
||||
'project' => $project,
|
||||
'section' => ProjectSettingsSection::General->value,
|
||||
])
|
||||
->with('status', "{$project->name} now uses the {$template->name} template.");
|
||||
}
|
||||
|
||||
@@ -155,40 +135,13 @@ class ProjectController extends Controller
|
||||
|
||||
$this->updateProjectTemplateLink->reset($project->load('projectTemplate'));
|
||||
|
||||
return to_route('projects.settings.general', $project)
|
||||
return to_route('projects.settings.index', [
|
||||
'project' => $project,
|
||||
'section' => ProjectSettingsSection::General->value,
|
||||
])
|
||||
->with('status', "{$project->name} was reset to the {$template->name} template baseline.");
|
||||
}
|
||||
|
||||
public function access(Request $request, Project $project): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
abort_unless(
|
||||
$this->permissionService->allows($user, Permission::ProjectAdmin, $project),
|
||||
403,
|
||||
);
|
||||
|
||||
return Inertia::render('projects/Access', [
|
||||
'directPermissions' => $this->permissionService->assignmentMap(User::class, $project),
|
||||
'permissionOptions' => PermissionOptionResource::collection(
|
||||
$this->permissionService->permissionOptions(User::class, Project::class),
|
||||
)->resolve(),
|
||||
'project' => ProjectResource::make($project)->resolve(),
|
||||
'status' => session('status'),
|
||||
'teamPermissions' => $this->permissionService->assignmentMap(Team::class, $project),
|
||||
'teams' => TeamResource::collection(
|
||||
Team::query()
|
||||
->with('users')
|
||||
->orderBy('name')
|
||||
->get(),
|
||||
)->resolve(),
|
||||
'users' => UserResource::collection(
|
||||
User::query()
|
||||
->orderBy('name')
|
||||
->get(),
|
||||
)->resolve(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateUserPermissions(
|
||||
SyncProjectPermissionsRequest $request,
|
||||
Project $project,
|
||||
@@ -205,7 +158,10 @@ class ProjectController extends Controller
|
||||
|
||||
$this->permissionService->sync($user, $project, $permissions);
|
||||
|
||||
return to_route('projects.access', $project)
|
||||
return to_route('projects.settings.index', [
|
||||
'project' => $project,
|
||||
'section' => ProjectSettingsSection::Access->value,
|
||||
])
|
||||
->with('status', "{$user->name} permissions were updated.");
|
||||
}
|
||||
|
||||
@@ -224,7 +180,10 @@ class ProjectController extends Controller
|
||||
|
||||
$this->permissionService->sync($team, $project, $permissions);
|
||||
|
||||
return to_route('projects.access', $project)
|
||||
return to_route('projects.settings.index', [
|
||||
'project' => $project,
|
||||
'section' => ProjectSettingsSection::Access->value,
|
||||
])
|
||||
->with('status', "{$team->name} permissions were updated.");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,41 +2,28 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Notifications\BuildProjectNotificationSettingsPayload;
|
||||
use App\Actions\Notifications\DeleteProjectNotificationAction;
|
||||
use App\Actions\Notifications\StoreProjectNotificationAction;
|
||||
use App\Actions\Notifications\UpdateProjectNotificationAction;
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Enums\Permission;
|
||||
use App\Enums\ProjectSettingsSection;
|
||||
use App\Http\Requests\StoreProjectNotificationActionRequest;
|
||||
use App\Http\Requests\UpdateProjectNotificationActionRequest;
|
||||
use App\Models\NotificationAction;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ProjectNotificationSettingsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BuildProjectNotificationSettingsPayload $buildProjectNotificationSettingsPayload,
|
||||
private readonly DeleteProjectNotificationAction $deleteProjectNotificationAction,
|
||||
private readonly PermissionService $permissionService,
|
||||
private readonly StoreProjectNotificationAction $storeProjectNotificationAction,
|
||||
private readonly UpdateProjectNotificationAction $updateProjectNotificationAction,
|
||||
) {}
|
||||
|
||||
public function show(Request $request, Project $project): Response
|
||||
{
|
||||
$this->abortUnlessCanManage($request, $project);
|
||||
|
||||
return Inertia::render(
|
||||
'projects/settings/Notifications',
|
||||
$this->buildProjectNotificationSettingsPayload->handle($project, session('status')),
|
||||
);
|
||||
}
|
||||
|
||||
public function store(
|
||||
StoreProjectNotificationActionRequest $request,
|
||||
Project $project,
|
||||
@@ -45,7 +32,7 @@ class ProjectNotificationSettingsController extends Controller
|
||||
|
||||
$notificationAction = $this->storeProjectNotificationAction->handle($project, $request->validated());
|
||||
|
||||
return to_route('projects.settings.notifications', $project)
|
||||
return $this->settingsRedirect($project)
|
||||
->with('status', "{$notificationAction->name} notification was added.");
|
||||
}
|
||||
|
||||
@@ -62,7 +49,7 @@ class ProjectNotificationSettingsController extends Controller
|
||||
$request->validated(),
|
||||
);
|
||||
|
||||
return to_route('projects.settings.notifications', $project)
|
||||
return $this->settingsRedirect($project)
|
||||
->with('status', "{$notificationAction->name} notification was updated.");
|
||||
}
|
||||
|
||||
@@ -80,7 +67,7 @@ class ProjectNotificationSettingsController extends Controller
|
||||
|
||||
$this->deleteProjectNotificationAction->handle($project, $notificationAction);
|
||||
|
||||
return to_route('projects.settings.notifications', $project)
|
||||
return $this->settingsRedirect($project)
|
||||
->with('status', "{$deletedName} notification was deleted.");
|
||||
}
|
||||
|
||||
@@ -91,4 +78,12 @@ class ProjectNotificationSettingsController extends Controller
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
private function settingsRedirect(Project $project): RedirectResponse
|
||||
{
|
||||
return to_route('projects.settings.index', [
|
||||
'project' => $project,
|
||||
'section' => ProjectSettingsSection::Notifications->value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,44 +3,27 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Actions\Repositories\BuildProjectRepositorySettingsPayload;
|
||||
use App\Actions\Repositories\DeleteProjectRepositoryConnection;
|
||||
use App\Actions\Repositories\StoreProjectRepositoryConnection;
|
||||
use App\Actions\Repositories\UpdateProjectRepositoryConnection;
|
||||
use App\Enums\Permission;
|
||||
use App\Enums\ProjectSettingsSection;
|
||||
use App\Http\Requests\StoreRepositoryConnectionRequest;
|
||||
use App\Http\Requests\UpdateRepositoryConnectionRequest;
|
||||
use App\Models\Project;
|
||||
use App\Models\RepositoryConnection;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ProjectRepositorySettingsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BuildProjectRepositorySettingsPayload $buildProjectRepositorySettingsPayload,
|
||||
private readonly DeleteProjectRepositoryConnection $deleteProjectRepositoryConnection,
|
||||
private readonly PermissionService $permissionService,
|
||||
private readonly StoreProjectRepositoryConnection $storeProjectRepositoryConnection,
|
||||
private readonly UpdateProjectRepositoryConnection $updateProjectRepositoryConnection,
|
||||
) {}
|
||||
|
||||
public function show(Request $request, Project $project): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
abort_unless(
|
||||
$this->permissionService->allows($user, Permission::ProjectAdmin, $project),
|
||||
403,
|
||||
);
|
||||
|
||||
return Inertia::render(
|
||||
'projects/settings/Repositories',
|
||||
$this->buildProjectRepositorySettingsPayload->handle($project, $user, session('status')),
|
||||
);
|
||||
}
|
||||
|
||||
public function store(
|
||||
StoreRepositoryConnectionRequest $request,
|
||||
Project $project,
|
||||
@@ -53,7 +36,7 @@ class ProjectRepositorySettingsController extends Controller
|
||||
|
||||
$repositoryConnection = $this->storeProjectRepositoryConnection->handle($project, $user, $request->validated());
|
||||
|
||||
return to_route('projects.settings.repositories', $project)
|
||||
return $this->settingsRedirect($project)
|
||||
->with('status', "{$repositoryConnection->repository_full_name} was linked to {$project->name}.");
|
||||
}
|
||||
|
||||
@@ -76,7 +59,7 @@ class ProjectRepositorySettingsController extends Controller
|
||||
$request->validated(),
|
||||
);
|
||||
|
||||
return to_route('projects.settings.repositories', $project)
|
||||
return $this->settingsRedirect($project)
|
||||
->with('status', "{$repositoryConnection->fresh()->repository_full_name} connection was updated.");
|
||||
}
|
||||
|
||||
@@ -92,7 +75,15 @@ class ProjectRepositorySettingsController extends Controller
|
||||
|
||||
$repositoryLabel = $this->deleteProjectRepositoryConnection->handle($repositoryConnection);
|
||||
|
||||
return to_route('projects.settings.repositories', $project)
|
||||
return $this->settingsRedirect($project)
|
||||
->with('status', "{$repositoryLabel} was removed from the project.");
|
||||
}
|
||||
|
||||
private function settingsRedirect(Project $project): RedirectResponse
|
||||
{
|
||||
return to_route('projects.settings.index', [
|
||||
'project' => $project,
|
||||
'section' => ProjectSettingsSection::Repositories->value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Actions\Projects\BuildProjectSettingsPayload;
|
||||
use App\Enums\Permission;
|
||||
use App\Enums\ProjectSettingsSection;
|
||||
use App\Http\Requests\ProjectSettingsRequest;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ProjectSettingsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BuildProjectSettingsPayload $buildProjectSettingsPayload,
|
||||
private readonly PermissionService $permissionService,
|
||||
) {}
|
||||
|
||||
public function index(ProjectSettingsRequest $request, Project $project): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
abort_unless(
|
||||
$this->permissionService->allows($user, Permission::ProjectAdmin, $project),
|
||||
403,
|
||||
);
|
||||
|
||||
$validated = $request->validated();
|
||||
$section = ProjectSettingsSection::from(
|
||||
(string) Arr::get($validated, 'section', ProjectSettingsSection::General->value),
|
||||
);
|
||||
|
||||
return Inertia::render(
|
||||
'projects/Settings',
|
||||
$this->buildProjectSettingsPayload->handle(
|
||||
$project,
|
||||
$user,
|
||||
$section,
|
||||
session('status'),
|
||||
session('error'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public function general(Request $request, Project $project): RedirectResponse
|
||||
{
|
||||
return $this->redirectToSection($request, $project, ProjectSettingsSection::General);
|
||||
}
|
||||
|
||||
public function board(Request $request, Project $project): RedirectResponse
|
||||
{
|
||||
return $this->redirectToSection($request, $project, ProjectSettingsSection::Board);
|
||||
}
|
||||
|
||||
public function metadata(Request $request, Project $project): RedirectResponse
|
||||
{
|
||||
return $this->redirectToSection($request, $project, ProjectSettingsSection::Metadata);
|
||||
}
|
||||
|
||||
public function repositories(Request $request, Project $project): RedirectResponse
|
||||
{
|
||||
return $this->redirectToSection($request, $project, ProjectSettingsSection::Repositories);
|
||||
}
|
||||
|
||||
public function notifications(Request $request, Project $project): RedirectResponse
|
||||
{
|
||||
return $this->redirectToSection($request, $project, ProjectSettingsSection::Notifications);
|
||||
}
|
||||
|
||||
public function access(Request $request, Project $project): RedirectResponse
|
||||
{
|
||||
return $this->redirectToSection($request, $project, ProjectSettingsSection::Access);
|
||||
}
|
||||
|
||||
private function redirectToSection(
|
||||
Request $request,
|
||||
Project $project,
|
||||
ProjectSettingsSection $section,
|
||||
): RedirectResponse {
|
||||
abort_unless(
|
||||
$this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project),
|
||||
403,
|
||||
);
|
||||
|
||||
return to_route('projects.settings.index', [
|
||||
'project' => $project,
|
||||
'section' => $section->value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Actions\Permissions\PermissionService;
|
||||
use App\Actions\Projects\BuildProjectTicketDetailPayload;
|
||||
use App\Actions\Projects\BuildProjectTicketOptionsPayload;
|
||||
use App\Actions\Tickets\CreateProjectTicket;
|
||||
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
||||
use App\Actions\Tickets\UpdateProjectTicket;
|
||||
use App\Enums\Permission;
|
||||
use App\Http\Requests\StoreProjectTicketRequest;
|
||||
@@ -40,11 +41,18 @@ class ProjectTicketController extends Controller
|
||||
|
||||
return Inertia::render(
|
||||
'projects/TicketCreate',
|
||||
$this->buildProjectTicketOptionsPayload->handle(
|
||||
$project,
|
||||
Arr::get($request->query(), 'source') === 'board' ? 'board' : 'backlog',
|
||||
session('status'),
|
||||
),
|
||||
[
|
||||
...$this->buildProjectTicketOptionsPayload->handle(
|
||||
$project,
|
||||
Arr::get($request->query(), 'source') === 'board' ? 'board' : 'backlog',
|
||||
session('status'),
|
||||
),
|
||||
'canManageProject' => $this->permissionService->allows(
|
||||
$user,
|
||||
Permission::ProjectAdmin,
|
||||
$project,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,6 +116,9 @@ class ProjectTicketController extends Controller
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'acceptance_criteria' => AcceptanceCriteriaChecklistMapper::fromModelsWithIds(
|
||||
$ticket->acceptanceCriteria()->get(),
|
||||
),
|
||||
'message' => $statusMessage,
|
||||
'updated' => $wasUpdated,
|
||||
]);
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Actions\Projects\BuildProjectTicketMetadataSettingsPayload;
|
||||
use App\Actions\ProjectTicketMetadata\DeleteProjectTicketPriority;
|
||||
use App\Actions\ProjectTicketMetadata\DeleteProjectTicketType;
|
||||
use App\Actions\ProjectTicketMetadata\DeleteTicketFieldDefinition;
|
||||
@@ -18,6 +17,7 @@ use App\Actions\ProjectTicketMetadata\UpdateProjectTicketType;
|
||||
use App\Actions\ProjectTicketMetadata\UpdateTicketFieldDefinition;
|
||||
use App\Actions\ProjectTicketMetadata\UpdateTicketLinkType;
|
||||
use App\Enums\Permission;
|
||||
use App\Enums\ProjectSettingsSection;
|
||||
use App\Http\Requests\DestroyProjectTicketTypeRequest;
|
||||
use App\Http\Requests\StoreProjectNamedSettingRequest;
|
||||
use App\Http\Requests\StoreProjectTicketTypeRequest;
|
||||
@@ -35,13 +35,10 @@ use App\Models\TicketPriority;
|
||||
use App\Models\TicketType;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ProjectTicketMetadataSettingsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BuildProjectTicketMetadataSettingsPayload $buildProjectTicketMetadataSettingsPayload,
|
||||
private readonly DeleteProjectTicketPriority $deleteProjectTicketPriority,
|
||||
private readonly DeleteProjectTicketType $deleteProjectTicketType,
|
||||
private readonly DeleteTicketFieldDefinition $deleteTicketFieldDefinition,
|
||||
@@ -58,23 +55,13 @@ class ProjectTicketMetadataSettingsController extends Controller
|
||||
private readonly UpdateTicketLinkType $updateTicketLinkType,
|
||||
) {}
|
||||
|
||||
public function show(Request $request, Project $project): Response
|
||||
{
|
||||
$this->abortUnlessCanManage($request, $project);
|
||||
|
||||
return Inertia::render(
|
||||
'projects/settings/Metadata',
|
||||
$this->buildProjectTicketMetadataSettingsPayload->handle($project, session('status'), session('error')),
|
||||
);
|
||||
}
|
||||
|
||||
public function update(UpdateProjectTicketMetadataSettings $request, Project $project): RedirectResponse
|
||||
{
|
||||
$this->abortUnlessCanManage($request, $project);
|
||||
|
||||
$this->updateProjectTicketMetadataSettings->handle($project, $request->validated());
|
||||
|
||||
return to_route('projects.settings.metadata', $project)
|
||||
return $this->settingsRedirect($project)
|
||||
->with('status', 'Settings have been updated.');
|
||||
}
|
||||
|
||||
@@ -84,7 +71,7 @@ class ProjectTicketMetadataSettingsController extends Controller
|
||||
|
||||
$ticketType = $this->storeProjectTicketType->handle($project, $request->validated());
|
||||
|
||||
return to_route('projects.settings.metadata', $project)
|
||||
return $this->settingsRedirect($project)
|
||||
->with('status', "{$ticketType->name} type was added.");
|
||||
}
|
||||
|
||||
@@ -97,7 +84,7 @@ class ProjectTicketMetadataSettingsController extends Controller
|
||||
|
||||
$ticketType = $this->updateProjectTicketType->handle($project, $ticketType, $request->validated());
|
||||
|
||||
return to_route('projects.settings.metadata', $project)
|
||||
return $this->settingsRedirect($project)
|
||||
->with('status', "{$ticketType->fresh()->name} type was updated.");
|
||||
}
|
||||
|
||||
@@ -116,12 +103,12 @@ class ProjectTicketMetadataSettingsController extends Controller
|
||||
);
|
||||
|
||||
if (! $result['deleted']) {
|
||||
return to_route('projects.settings.metadata', $project)
|
||||
return $this->settingsRedirect($project)
|
||||
->with('error', true)
|
||||
->with('status', $result['message']);
|
||||
}
|
||||
|
||||
return to_route('projects.settings.metadata', $project)
|
||||
return $this->settingsRedirect($project)
|
||||
->with('status', $result['message']);
|
||||
}
|
||||
|
||||
@@ -131,7 +118,7 @@ class ProjectTicketMetadataSettingsController extends Controller
|
||||
|
||||
$ticketPriority = $this->storeProjectTicketPriority->handle($project, $request->validated());
|
||||
|
||||
return to_route('projects.settings.metadata', $project)
|
||||
return $this->settingsRedirect($project)
|
||||
->with('status', "{$ticketPriority->name} priority was added.");
|
||||
}
|
||||
|
||||
@@ -144,7 +131,7 @@ class ProjectTicketMetadataSettingsController extends Controller
|
||||
|
||||
$ticketPriority = $this->updateProjectTicketPriority->handle($project, $ticketPriority, $request->validated());
|
||||
|
||||
return to_route('projects.settings.metadata', $project)
|
||||
return $this->settingsRedirect($project)
|
||||
->with('status', "{$ticketPriority->fresh()->name} priority was updated.");
|
||||
}
|
||||
|
||||
@@ -158,12 +145,12 @@ class ProjectTicketMetadataSettingsController extends Controller
|
||||
$result = $this->deleteProjectTicketPriority->handle($project, $ticketPriority);
|
||||
|
||||
if (! $result['deleted']) {
|
||||
return to_route('projects.settings.metadata', $project)
|
||||
return $this->settingsRedirect($project)
|
||||
->with('error', true)
|
||||
->with('status', $result['message']);
|
||||
}
|
||||
|
||||
return to_route('projects.settings.metadata', $project)
|
||||
return $this->settingsRedirect($project)
|
||||
->with('status', $result['message']);
|
||||
}
|
||||
|
||||
@@ -175,7 +162,7 @@ class ProjectTicketMetadataSettingsController extends Controller
|
||||
|
||||
$ticketFieldDefinition = $this->storeTicketFieldDefinition->handle($project, $request->validated());
|
||||
|
||||
return to_route('projects.settings.metadata', $project)
|
||||
return $this->settingsRedirect($project)
|
||||
->with('status', "{$ticketFieldDefinition->label} field was added.");
|
||||
}
|
||||
|
||||
@@ -188,7 +175,7 @@ class ProjectTicketMetadataSettingsController extends Controller
|
||||
|
||||
$ticketFieldDefinition = $this->updateTicketFieldDefinition->handle($project, $ticketFieldDefinition, $request->validated());
|
||||
|
||||
return to_route('projects.settings.metadata', $project)
|
||||
return $this->settingsRedirect($project)
|
||||
->with('status', "{$ticketFieldDefinition->fresh()->label} field was updated.");
|
||||
}
|
||||
|
||||
@@ -201,7 +188,7 @@ class ProjectTicketMetadataSettingsController extends Controller
|
||||
|
||||
$deletedLabel = $this->deleteTicketFieldDefinition->handle($project, $ticketFieldDefinition);
|
||||
|
||||
return to_route('projects.settings.metadata', $project)
|
||||
return $this->settingsRedirect($project)
|
||||
->with('status', "{$deletedLabel} field was removed.");
|
||||
}
|
||||
|
||||
@@ -211,7 +198,7 @@ class ProjectTicketMetadataSettingsController extends Controller
|
||||
|
||||
$ticketLinkType = $this->storeTicketLinkType->handle($project, $request->validated());
|
||||
|
||||
return to_route('projects.settings.metadata', $project)
|
||||
return $this->settingsRedirect($project)
|
||||
->with('status', "{$ticketLinkType->label} link type was added.");
|
||||
}
|
||||
|
||||
@@ -225,7 +212,7 @@ class ProjectTicketMetadataSettingsController extends Controller
|
||||
|
||||
$ticketLinkType = $this->updateTicketLinkType->handle($project, $ticketLinkType, $request->validated());
|
||||
|
||||
return to_route('projects.settings.metadata', $project)
|
||||
return $this->settingsRedirect($project)
|
||||
->with('status', "{$ticketLinkType->fresh()->label} link type was updated.");
|
||||
}
|
||||
|
||||
@@ -238,7 +225,7 @@ class ProjectTicketMetadataSettingsController extends Controller
|
||||
|
||||
$deletedLabel = $this->deleteTicketLinkType->handle($project, $ticketLinkType);
|
||||
|
||||
return to_route('projects.settings.metadata', $project)
|
||||
return $this->settingsRedirect($project)
|
||||
->with('status', "{$deletedLabel} link type was removed.");
|
||||
}
|
||||
|
||||
@@ -249,4 +236,12 @@ class ProjectTicketMetadataSettingsController extends Controller
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
private function settingsRedirect(Project $project): RedirectResponse
|
||||
{
|
||||
return to_route('projects.settings.index', [
|
||||
'project' => $project,
|
||||
'section' => ProjectSettingsSection::Metadata->value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\ProjectSettingsSection;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ProjectSettingsRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'section' => ['required', Rule::enum(ProjectSettingsSection::class)],
|
||||
];
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$section = Arr::get($this->query(), 'section');
|
||||
|
||||
$this->merge([
|
||||
'section' => is_string($section) && ProjectSettingsSection::tryFrom($section) !== null
|
||||
? $section
|
||||
: ProjectSettingsSection::General->value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
+339
-79
@@ -1,6 +1,6 @@
|
||||
@import 'tailwindcss';
|
||||
@import "tailwindcss";
|
||||
|
||||
@import 'tw-animate-css';
|
||||
@import "tw-animate-css";
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
@@ -9,13 +9,69 @@
|
||||
|
||||
@theme inline {
|
||||
--font-sans:
|
||||
Instrument Sans, ui-sans-serif, system-ui, sans-serif,
|
||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
Inter, Instrument Sans, ui-sans-serif, system-ui, sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||
"Noto Color Emoji";
|
||||
--font-headline-lg:
|
||||
Inter, Instrument Sans, ui-sans-serif, system-ui, sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||
"Noto Color Emoji";
|
||||
--font-headline-lg-mobile:
|
||||
Inter, Instrument Sans, ui-sans-serif, system-ui, sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||
"Noto Color Emoji";
|
||||
--font-headline-md:
|
||||
Inter, Instrument Sans, ui-sans-serif, system-ui, sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||
"Noto Color Emoji";
|
||||
--font-body-md:
|
||||
Inter, Instrument Sans, ui-sans-serif, system-ui, sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||
"Noto Color Emoji";
|
||||
--font-body-sm:
|
||||
Inter, Instrument Sans, ui-sans-serif, system-ui, sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||
"Noto Color Emoji";
|
||||
--font-label-uppercase:
|
||||
Inter, Instrument Sans, ui-sans-serif, system-ui, sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||
"Noto Color Emoji";
|
||||
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.25rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-xl: 0.75rem;
|
||||
|
||||
--spacing-container-margin: 1.5rem;
|
||||
--spacing-column-gap: 1rem;
|
||||
--spacing-card-padding: 0.75rem;
|
||||
--spacing-stack-gap-sm: 0.5rem;
|
||||
--spacing-stack-gap-md: 0.75rem;
|
||||
|
||||
--text-headline-lg: 1.5rem;
|
||||
--text-headline-lg--line-height: 2rem;
|
||||
--text-headline-lg--font-weight: 600;
|
||||
--text-headline-lg--letter-spacing: -0.025em;
|
||||
--text-headline-md: 1.125rem;
|
||||
--text-headline-md--line-height: 1.5rem;
|
||||
--text-headline-md--font-weight: 600;
|
||||
--text-headline-md--letter-spacing: -0.015em;
|
||||
--text-headline-lg-mobile: 1.25rem;
|
||||
--text-headline-lg-mobile--line-height: 1.75rem;
|
||||
--text-headline-lg-mobile--font-weight: 600;
|
||||
--text-body-md: 0.875rem;
|
||||
--text-body-md--line-height: 1.25rem;
|
||||
--text-body-md--font-weight: 400;
|
||||
--text-body-sm: 0.8125rem;
|
||||
--text-body-sm--line-height: 1.125rem;
|
||||
--text-body-sm--font-weight: 400;
|
||||
--text-label-uppercase: 0.6875rem;
|
||||
--text-label-uppercase--line-height: 1rem;
|
||||
--text-label-uppercase--font-weight: 700;
|
||||
--text-label-uppercase--letter-spacing: 0.05em;
|
||||
|
||||
--shadow-kanban-card: 0 4px 12px rgb(0 0 0 / 0.08);
|
||||
--shadow-kanban-nav: 0 12px 30px rgb(15 23 42 / 0.06);
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
@@ -59,6 +115,54 @@
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
--color-surface: var(--surface);
|
||||
--color-surface-dim: var(--surface-dim);
|
||||
--color-surface-bright: var(--surface-bright);
|
||||
--color-surface-container-lowest: var(--surface-container-lowest);
|
||||
--color-surface-container-low: var(--surface-container-low);
|
||||
--color-surface-container: var(--surface-container);
|
||||
--color-surface-container-high: var(--surface-container-high);
|
||||
--color-surface-container-highest: var(--surface-container-highest);
|
||||
--color-surface-variant: var(--surface-variant);
|
||||
--color-surface-tint: var(--surface-tint);
|
||||
--color-on-surface: var(--on-surface);
|
||||
--color-on-surface-variant: var(--on-surface-variant);
|
||||
--color-on-background: var(--on-background);
|
||||
--color-outline: var(--outline);
|
||||
--color-outline-variant: var(--outline-variant);
|
||||
--color-primary-container: var(--primary-container);
|
||||
--color-on-primary: var(--on-primary);
|
||||
--color-on-primary-container: var(--on-primary-container);
|
||||
--color-primary-fixed: var(--primary-fixed);
|
||||
--color-primary-fixed-dim: var(--primary-fixed-dim);
|
||||
--color-on-primary-fixed: var(--on-primary-fixed);
|
||||
--color-on-primary-fixed-variant: var(--on-primary-fixed-variant);
|
||||
--color-secondary-container: var(--secondary-container);
|
||||
--color-on-secondary: var(--on-secondary);
|
||||
--color-on-secondary-container: var(--on-secondary-container);
|
||||
--color-secondary-fixed: var(--secondary-fixed);
|
||||
--color-secondary-fixed-dim: var(--secondary-fixed-dim);
|
||||
--color-on-secondary-fixed: var(--on-secondary-fixed);
|
||||
--color-on-secondary-fixed-variant: var(--on-secondary-fixed-variant);
|
||||
--color-tertiary: var(--tertiary);
|
||||
--color-tertiary-container: var(--tertiary-container);
|
||||
--color-on-tertiary: var(--on-tertiary);
|
||||
--color-on-tertiary-container: var(--on-tertiary-container);
|
||||
--color-tertiary-fixed: var(--tertiary-fixed);
|
||||
--color-tertiary-fixed-dim: var(--tertiary-fixed-dim);
|
||||
--color-on-tertiary-fixed: var(--on-tertiary-fixed);
|
||||
--color-on-tertiary-fixed-variant: var(--on-tertiary-fixed-variant);
|
||||
--color-error: var(--error);
|
||||
--color-error-container: var(--error-container);
|
||||
--color-on-error: var(--on-error);
|
||||
--color-on-error-container: var(--on-error-container);
|
||||
--color-inverse-surface: var(--inverse-surface);
|
||||
--color-inverse-on-surface: var(--inverse-on-surface);
|
||||
--color-inverse-primary: var(--inverse-primary);
|
||||
--color-priority-high: #ef4444;
|
||||
--color-priority-medium: #f59e0b;
|
||||
--color-priority-low: #3b82f6;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -83,83 +187,171 @@
|
||||
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';
|
||||
Inter, "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%);
|
||||
--background: #f7f9fb;
|
||||
--foreground: #191c1e;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #191c1e;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #191c1e;
|
||||
--primary: #006e2f;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #d0e1fb;
|
||||
--secondary-foreground: #0b1c30;
|
||||
--muted: #f2f4f6;
|
||||
--muted-foreground: #3d4a3d;
|
||||
--accent: #e6e8ea;
|
||||
--accent-foreground: #191c1e;
|
||||
--destructive: #ba1a1a;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #bccbb9;
|
||||
--input: #bccbb9;
|
||||
--ring: #006e2f;
|
||||
--chart-1: #22c55e;
|
||||
--chart-2: #3b82f6;
|
||||
--chart-3: #f59e0b;
|
||||
--chart-4: #ef4444;
|
||||
--chart-5: #505f76;
|
||||
--radius: 0.25rem;
|
||||
--surface: #f7f9fb;
|
||||
--surface-dim: #d8dadc;
|
||||
--surface-bright: #f7f9fb;
|
||||
--surface-container-lowest: #ffffff;
|
||||
--surface-container-low: #f2f4f6;
|
||||
--surface-container: #eceef0;
|
||||
--surface-container-high: #e6e8ea;
|
||||
--surface-container-highest: #e0e3e5;
|
||||
--surface-variant: #e0e3e5;
|
||||
--surface-tint: #006e2f;
|
||||
--on-surface: #191c1e;
|
||||
--on-surface-variant: #3d4a3d;
|
||||
--on-background: #191c1e;
|
||||
--outline: #6d7b6c;
|
||||
--outline-variant: #bccbb9;
|
||||
--primary-container: #22c55e;
|
||||
--on-primary: #ffffff;
|
||||
--on-primary-container: #004b1e;
|
||||
--primary-fixed: #6bff8f;
|
||||
--primary-fixed-dim: #4ae176;
|
||||
--on-primary-fixed: #002109;
|
||||
--on-primary-fixed-variant: #005321;
|
||||
--secondary-container: #d0e1fb;
|
||||
--on-secondary: #ffffff;
|
||||
--on-secondary-container: #54647a;
|
||||
--secondary-fixed: #d3e4fe;
|
||||
--secondary-fixed-dim: #b7c8e1;
|
||||
--on-secondary-fixed: #0b1c30;
|
||||
--on-secondary-fixed-variant: #38485d;
|
||||
--tertiary: #9e4036;
|
||||
--tertiary-container: #ff8b7c;
|
||||
--on-tertiary: #ffffff;
|
||||
--on-tertiary-container: #76231b;
|
||||
--tertiary-fixed: #ffdad5;
|
||||
--tertiary-fixed-dim: #ffb4a9;
|
||||
--on-tertiary-fixed: #410001;
|
||||
--on-tertiary-fixed-variant: #7f2a21;
|
||||
--error: #ba1a1a;
|
||||
--error-container: #ffdad6;
|
||||
--on-error: #ffffff;
|
||||
--on-error-container: #93000a;
|
||||
--inverse-surface: #2d3133;
|
||||
--inverse-on-surface: #eff1f3;
|
||||
--inverse-primary: #4ae176;
|
||||
--sidebar-background: #f2f4f6;
|
||||
--sidebar-foreground: #191c1e;
|
||||
--sidebar-primary: #006e2f;
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: #e6e8ea;
|
||||
--sidebar-accent-foreground: #191c1e;
|
||||
--sidebar-border: #bccbb9;
|
||||
--sidebar-ring: #006e2f;
|
||||
--sidebar: #f2f4f6;
|
||||
}
|
||||
|
||||
.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%);
|
||||
--background: #0b1326;
|
||||
--foreground: #e5eaf5;
|
||||
--card: #131b2e;
|
||||
--card-foreground: #e5eaf5;
|
||||
--popover: #171f33;
|
||||
--popover-foreground: #e5eaf5;
|
||||
--primary: #4be277;
|
||||
--primary-foreground: #002109;
|
||||
--secondary: #222a3d;
|
||||
--secondary-foreground: #e5eaf5;
|
||||
--muted: #171f33;
|
||||
--muted-foreground: #aeb8cd;
|
||||
--accent: #222a3d;
|
||||
--accent-foreground: #f3f6fc;
|
||||
--destructive: #ffb4ab;
|
||||
--destructive-foreground: #690005;
|
||||
--border: #3b4358;
|
||||
--input: #4a5268;
|
||||
--ring: #4be277;
|
||||
--chart-1: #4be277;
|
||||
--chart-2: #60a5fa;
|
||||
--chart-3: #fbbf24;
|
||||
--chart-4: #f87171;
|
||||
--chart-5: #b7c8e1;
|
||||
--surface: #0b1326;
|
||||
--surface-dim: #060e20;
|
||||
--surface-bright: #2d3449;
|
||||
--surface-container-lowest: #060e20;
|
||||
--surface-container-low: #131b2e;
|
||||
--surface-container: #171f33;
|
||||
--surface-container-high: #222a3d;
|
||||
--surface-container-highest: #2d3449;
|
||||
--surface-variant: #2d3449;
|
||||
--surface-tint: #4be277;
|
||||
--on-surface: #e5eaf5;
|
||||
--on-surface-variant: #aeb8cd;
|
||||
--on-background: #e5eaf5;
|
||||
--outline: #8993aa;
|
||||
--outline-variant: #3b4358;
|
||||
--primary-container: #136c35;
|
||||
--on-primary: #002109;
|
||||
--on-primary-container: #bbf7d0;
|
||||
--primary-fixed: #6bff8f;
|
||||
--primary-fixed-dim: #4be277;
|
||||
--on-primary-fixed: #002109;
|
||||
--on-primary-fixed-variant: #005321;
|
||||
--secondary-container: #1e3a5f;
|
||||
--on-secondary: #ffffff;
|
||||
--on-secondary-container: #d0e1fb;
|
||||
--secondary-fixed: #d3e4fe;
|
||||
--secondary-fixed-dim: #b7c8e1;
|
||||
--on-secondary-fixed: #0b1c30;
|
||||
--on-secondary-fixed-variant: #38485d;
|
||||
--tertiary: #ffb4a9;
|
||||
--tertiary-container: #7f2a21;
|
||||
--on-tertiary: #410001;
|
||||
--on-tertiary-container: #ffdad5;
|
||||
--tertiary-fixed: #ffdad5;
|
||||
--tertiary-fixed-dim: #ffb4a9;
|
||||
--on-tertiary-fixed: #410001;
|
||||
--on-tertiary-fixed-variant: #ffb4a9;
|
||||
--error: #f87171;
|
||||
--error-container: #7f1d1d;
|
||||
--on-error: #ffffff;
|
||||
--on-error-container: #fee2e2;
|
||||
--inverse-surface: #eff1f3;
|
||||
--inverse-on-surface: #191c1e;
|
||||
--inverse-primary: #006e2f;
|
||||
--sidebar-background: #060e20;
|
||||
--sidebar-foreground: #e5eaf5;
|
||||
--sidebar-primary: #4be277;
|
||||
--sidebar-primary-foreground: #002109;
|
||||
--sidebar-accent: #171f33;
|
||||
--sidebar-accent-foreground: #f3f6fc;
|
||||
--sidebar-border: #2d3449;
|
||||
--sidebar-ring: #4be277;
|
||||
--sidebar: #060e20;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -167,6 +359,74 @@
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply min-h-screen bg-background text-foreground antialiased;
|
||||
letter-spacing: -0.006em;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: color-mix(in srgb, var(--primary) 24%, transparent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
textarea,
|
||||
select {
|
||||
@apply border-input bg-surface-container-lowest text-foreground rounded-md border px-3 py-2 text-sm shadow-xs outline-none transition-[border-color,box-shadow,background-color];
|
||||
background-color: var(--surface-container-lowest) !important;
|
||||
}
|
||||
|
||||
textarea:focus-visible,
|
||||
select:focus-visible {
|
||||
@apply border-ring ring-ring/25 ring-3;
|
||||
}
|
||||
|
||||
textarea:disabled,
|
||||
select:disabled {
|
||||
@apply cursor-not-allowed opacity-55;
|
||||
}
|
||||
|
||||
table {
|
||||
@apply w-full border-separate border-spacing-0;
|
||||
}
|
||||
|
||||
thead {
|
||||
@apply bg-surface-container-low text-on-surface-variant;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
@apply transition-colors hover:bg-primary/3;
|
||||
}
|
||||
|
||||
th {
|
||||
@apply text-label-uppercase font-bold tracking-[0.08em];
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.app-page {
|
||||
@apply mx-auto flex w-full max-w-[96rem] flex-1 flex-col gap-6 px-4 py-5 sm:px-6 sm:py-7 lg:px-8;
|
||||
}
|
||||
|
||||
.app-page-narrow {
|
||||
@apply mx-auto flex w-full max-w-5xl flex-1 flex-col gap-6 px-4 py-5 sm:px-6 sm:py-7 lg:px-8;
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
@apply flex flex-wrap items-center gap-2;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
@apply border-primary/25 bg-primary/8 text-on-primary-container rounded-lg border px-4 py-3 text-sm dark:text-primary-fixed;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@apply border-outline-variant bg-surface-container-low text-on-surface-variant rounded-lg border border-dashed px-5 py-8 text-center text-sm;
|
||||
}
|
||||
|
||||
.table-shell {
|
||||
@apply border-outline-variant bg-surface-container-lowest overflow-hidden rounded-xl border;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
@apply text-label-uppercase text-primary font-bold uppercase tracking-[0.12em];
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ createInertiaApp({
|
||||
.mount(el);
|
||||
},
|
||||
progress: {
|
||||
color: '#4B5563',
|
||||
color: '#22c55e',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { SidebarInset } from '@/components/ui/sidebar';
|
||||
import type { AppVariant } from '@/types';
|
||||
import type { AppContentProps } from '@/types';
|
||||
|
||||
type Props = {
|
||||
variant?: AppVariant;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
const props = withDefaults(defineProps<AppContentProps>(), {
|
||||
variant: 'sidebar',
|
||||
});
|
||||
const className = computed(() => props.class);
|
||||
@@ -20,7 +15,7 @@ const className = computed(() => props.class);
|
||||
</SidebarInset>
|
||||
<main
|
||||
v-else
|
||||
class="mx-auto flex h-full w-full max-w-7xl flex-1 flex-col gap-4 rounded-xl"
|
||||
class="flex h-full w-full flex-1 flex-col gap-4"
|
||||
:class="className"
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import { BookOpen, Folder, LayoutGrid, Menu, Search } from 'lucide-vue-next';
|
||||
import {
|
||||
Folder,
|
||||
LayoutGrid,
|
||||
Layers3,
|
||||
Menu,
|
||||
Shield,
|
||||
Users,
|
||||
} from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import AppLogo from '@/components/AppLogo.vue';
|
||||
import AppLogoIcon from '@/components/AppLogoIcon.vue';
|
||||
import Breadcrumbs from '@/components/Breadcrumbs.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -11,12 +17,6 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuList,
|
||||
navigationMenuTriggerStyle,
|
||||
} from '@/components/ui/navigation-menu';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -24,246 +24,184 @@ import {
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||
import UserMenuContent from '@/components/UserMenuContent.vue';
|
||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
import { dashboard } from '@/routes/index';
|
||||
import type { BreadcrumbItem, NavItem } from '@/types';
|
||||
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||
import { dashboard } from '@/routes';
|
||||
import { index as adminUsers } from '@/routes/admin/users';
|
||||
import { index as projectTemplatesIndex } from '@/routes/project-templates';
|
||||
import { index as projectsIndex } from '@/routes/projects';
|
||||
import { index as teamsIndex } from '@/routes/teams';
|
||||
import type { AppHeaderProps, NavItem } from '@/types';
|
||||
|
||||
type Props = {
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
const props = withDefaults(defineProps<AppHeaderProps>(), {
|
||||
breadcrumbs: () => [],
|
||||
});
|
||||
|
||||
const page = usePage();
|
||||
const auth = computed(() => page.props.auth);
|
||||
const { isCurrentUrl, whenCurrentUrl } = useCurrentUrl();
|
||||
const { isCurrentOrParentUrl } = useCurrentUrl();
|
||||
|
||||
const activeItemStyles =
|
||||
'text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100';
|
||||
const mainNavItems = computed<NavItem[]>(() => {
|
||||
const items: NavItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
href: dashboard(),
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
{
|
||||
title: 'Projects',
|
||||
href: projectsIndex(),
|
||||
icon: Folder,
|
||||
},
|
||||
];
|
||||
|
||||
const mainNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
href: dashboard(),
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
];
|
||||
if (page.props.auth.permissions.projectCreate) {
|
||||
items.push({
|
||||
title: 'Project templates',
|
||||
href: projectTemplatesIndex(),
|
||||
icon: Layers3,
|
||||
});
|
||||
}
|
||||
|
||||
const rightNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Repository',
|
||||
href: 'https://github.com/laravel/vue-starter-kit',
|
||||
icon: Folder,
|
||||
},
|
||||
{
|
||||
title: 'Documentation',
|
||||
href: 'https://laravel.com/docs/starter-kits#vue',
|
||||
icon: BookOpen,
|
||||
},
|
||||
];
|
||||
if (page.props.auth.permissions.globalAdmin) {
|
||||
items.push({
|
||||
title: 'Users',
|
||||
href: adminUsers(),
|
||||
icon: Shield,
|
||||
});
|
||||
}
|
||||
|
||||
if (page.props.auth.permissions.canManageTeams) {
|
||||
items.push({
|
||||
title: 'Teams',
|
||||
href: teamsIndex(),
|
||||
icon: Users,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const navItemClasses = (item: NavItem): string =>
|
||||
[
|
||||
'inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors duration-200',
|
||||
isCurrentOrParentUrl(item.href)
|
||||
? 'bg-primary text-primary-foreground shadow-[0_8px_20px_rgba(0,110,47,0.16)]'
|
||||
: 'text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface',
|
||||
].join(' ');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="border-sidebar-border/80 border-b">
|
||||
<div class="mx-auto flex h-16 items-center px-4 md:max-w-7xl">
|
||||
<!-- Mobile Menu -->
|
||||
<div class="lg:hidden">
|
||||
<Sheet>
|
||||
<SheetTrigger :as-child="true">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="mr-2 h-9 w-9"
|
||||
>
|
||||
<Menu class="h-5 w-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" class="w-[300px] p-6">
|
||||
<SheetTitle class="sr-only"
|
||||
>Navigation menu</SheetTitle
|
||||
>
|
||||
<SheetHeader class="flex justify-start text-left">
|
||||
<AppLogoIcon
|
||||
class="size-6 fill-current text-black dark:text-white"
|
||||
/>
|
||||
</SheetHeader>
|
||||
<div
|
||||
class="flex h-full flex-1 flex-col justify-between space-y-4 py-6"
|
||||
>
|
||||
<nav class="-mx-3 space-y-1">
|
||||
<Link
|
||||
v-for="item in mainNavItems"
|
||||
:key="item.title"
|
||||
:href="item.href"
|
||||
class="hover:bg-accent flex items-center gap-x-3 rounded-lg px-3 py-2 text-sm font-medium"
|
||||
:class="
|
||||
whenCurrentUrl(
|
||||
item.href,
|
||||
activeItemStyles,
|
||||
)
|
||||
"
|
||||
>
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
{{ item.title }}
|
||||
</Link>
|
||||
</nav>
|
||||
<div class="flex flex-col space-y-4">
|
||||
<a
|
||||
v-for="item in rightNavItems"
|
||||
:key="item.title"
|
||||
:href="toUrl(item.href)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center space-x-2 text-sm font-medium"
|
||||
>
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
<span>{{ item.title }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
<Link :href="dashboard()" class="flex items-center gap-x-2">
|
||||
<AppLogo />
|
||||
</Link>
|
||||
|
||||
<!-- Desktop Menu -->
|
||||
<div class="hidden h-full lg:flex lg:flex-1">
|
||||
<NavigationMenu class="ml-10 flex h-full items-stretch">
|
||||
<NavigationMenuList
|
||||
class="flex h-full items-stretch space-x-2"
|
||||
<header
|
||||
class="border-outline-variant/80 bg-surface/90 shadow-kanban-nav sticky top-0 z-50 border-b backdrop-blur-md"
|
||||
>
|
||||
<div
|
||||
class="sm:px-container-margin flex h-16 w-full items-center gap-3 px-4"
|
||||
>
|
||||
<Sheet>
|
||||
<SheetTrigger :as-child="true">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface size-9 rounded-lg lg:hidden"
|
||||
>
|
||||
<Menu class="size-5" />
|
||||
<span class="sr-only">Open navigation</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
side="left"
|
||||
class="border-outline-variant bg-surface w-[320px] p-0"
|
||||
>
|
||||
<SheetTitle class="sr-only">Navigation menu</SheetTitle>
|
||||
<SheetHeader class="border-outline-variant border-b p-4">
|
||||
<Link
|
||||
:href="dashboard()"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<NavigationMenuItem
|
||||
v-for="(item, index) in mainNavItems"
|
||||
:key="index"
|
||||
class="relative flex h-full items-center"
|
||||
>
|
||||
<Link
|
||||
:class="[
|
||||
navigationMenuTriggerStyle(),
|
||||
whenCurrentUrl(
|
||||
item.href,
|
||||
activeItemStyles,
|
||||
),
|
||||
'h-9 cursor-pointer px-3',
|
||||
]"
|
||||
:href="item.href"
|
||||
>
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="mr-2 h-4 w-4"
|
||||
/>
|
||||
{{ item.title }}
|
||||
</Link>
|
||||
<div
|
||||
v-if="isCurrentUrl(item.href)"
|
||||
class="absolute bottom-0 left-0 h-0.5 w-full translate-y-px bg-black dark:bg-white"
|
||||
></div>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</div>
|
||||
<AppLogo />
|
||||
</Link>
|
||||
</SheetHeader>
|
||||
|
||||
<div class="ml-auto flex items-center space-x-2">
|
||||
<div class="relative flex items-center space-x-1">
|
||||
<div class="flex flex-col gap-5 p-4">
|
||||
<nav class="flex flex-col gap-1" aria-label="Main">
|
||||
<Link
|
||||
v-for="item in mainNavItems"
|
||||
:key="item.title"
|
||||
:href="item.href"
|
||||
:class="navItemClasses(item)"
|
||||
>
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="size-4"
|
||||
/>
|
||||
{{ item.title }}
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<Link
|
||||
:href="dashboard()"
|
||||
class="flex shrink-0 items-center gap-2"
|
||||
aria-label="KanbanGit dashboard"
|
||||
>
|
||||
<AppLogo />
|
||||
</Link>
|
||||
|
||||
<nav
|
||||
class="ml-3 hidden min-w-0 flex-1 items-center gap-1 lg:flex"
|
||||
aria-label="Main"
|
||||
>
|
||||
<Link
|
||||
v-for="item in mainNavItems"
|
||||
:key="item.title"
|
||||
:href="item.href"
|
||||
:class="navItemClasses(item)"
|
||||
>
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="size-4"
|
||||
/>
|
||||
<span class="truncate">{{ item.title }}</span>
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger :as-child="true">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="group h-9 w-9 cursor-pointer"
|
||||
class="focus-visible:ring-primary/30 hover:bg-surface-container-high size-10 rounded-full p-1 focus-visible:ring-2"
|
||||
>
|
||||
<Search
|
||||
class="size-5 opacity-80 group-hover:opacity-100"
|
||||
/>
|
||||
<UserAvatar rounded />
|
||||
<span class="sr-only">Open user menu</span>
|
||||
</Button>
|
||||
|
||||
<div class="hidden space-x-1 lg:flex">
|
||||
<template
|
||||
v-for="item in rightNavItems"
|
||||
:key="item.title"
|
||||
>
|
||||
<TooltipProvider :delay-duration="0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
as-child
|
||||
class="group h-9 w-9 cursor-pointer"
|
||||
>
|
||||
<a
|
||||
:href="toUrl(item.href)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span class="sr-only">{{
|
||||
item.title
|
||||
}}</span>
|
||||
<component
|
||||
:is="item.icon"
|
||||
class="size-5 opacity-80 group-hover:opacity-100"
|
||||
/>
|
||||
</a>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ item.title }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger :as-child="true">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="focus-within:ring-primary relative size-10 w-auto rounded-full p-1 focus-within:ring-2"
|
||||
>
|
||||
<UserAvatar rounded />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-56">
|
||||
<UserMenuContent :user="auth.user" />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
class="border-outline-variant bg-popover w-56"
|
||||
>
|
||||
<UserMenuContent :user="auth.user" />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="props.breadcrumbs.length > 1"
|
||||
class="border-sidebar-border/70 flex w-full border-b"
|
||||
class="border-outline-variant/70 bg-surface-container-low/70 border-t"
|
||||
>
|
||||
<div
|
||||
class="mx-auto flex h-12 w-full items-center justify-start px-4 text-neutral-500 md:max-w-7xl"
|
||||
class="text-on-surface-variant sm:px-container-margin flex h-11 w-full items-center px-4 text-sm"
|
||||
>
|
||||
<Breadcrumbs :breadcrumbs="breadcrumbs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -4,13 +4,13 @@ import AppLogoIcon from '@/components/AppLogoIcon.vue';
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md"
|
||||
class="bg-primary text-primary-foreground flex aspect-square size-9 items-center justify-center rounded-lg shadow-[0_8px_24px_rgba(0,110,47,0.18)]"
|
||||
>
|
||||
<AppLogoIcon class="size-5 fill-current text-white dark:text-black" />
|
||||
<AppLogoIcon class="size-5 fill-current" />
|
||||
</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
|
||||
>
|
||||
<span class="text-primary truncate text-sm font-semibold leading-tight">
|
||||
KanbanGit
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -15,15 +15,15 @@ defineProps<Props>();
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 40 42"
|
||||
viewBox="0 0 32 32"
|
||||
:class="className"
|
||||
v-bind="$attrs"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M17.2 5.633 8.6.855 0 5.633v26.51l16.2 9 16.2-9v-8.442l7.6-4.223V9.856l-8.6-4.777-8.6 4.777V18.3l-5.6 3.111V5.633ZM38 18.301l-5.6 3.11v-6.157l5.6-3.11V18.3Zm-1.06-7.856-5.54 3.078-5.54-3.079 5.54-3.078 5.54 3.079ZM24.8 18.3v-6.157l5.6 3.111v6.158L24.8 18.3Zm-1 1.732 5.54 3.078-13.14 7.302-5.54-3.078 13.14-7.3v-.002Zm-16.2 7.89 7.6 4.222V38.3L2 30.966V7.92l5.6 3.111v16.892ZM8.6 9.3 3.06 6.222 8.6 3.143l5.54 3.08L8.6 9.3Zm21.8 15.51-13.2 7.334V38.3l13.2-7.334v-6.156ZM9.6 11.034l5.6-3.11v14.6l-5.6 3.11v-14.6Z"
|
||||
/>
|
||||
<rect x="4" y="5" width="7" height="22" rx="2" />
|
||||
<rect x="13" y="5" width="7" height="14" rx="2" opacity="0.82" />
|
||||
<rect x="22" y="5" width="6" height="18" rx="2" opacity="0.64" />
|
||||
<path d="M14 24h7v3h-7z" opacity="0.82" />
|
||||
<path d="M22 26h6v1h-6z" opacity="0.64" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||
import type { AppVariant } from '@/types';
|
||||
import type { AppShellProps } from '@/types';
|
||||
|
||||
type Props = {
|
||||
variant?: AppVariant;
|
||||
};
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
withDefaults(defineProps<AppShellProps>(), {
|
||||
variant: 'sidebar',
|
||||
});
|
||||
|
||||
@@ -15,7 +11,10 @@ const isOpen = usePage().props.sidebarOpen;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="variant === 'header'" class="flex min-h-screen w-full flex-col">
|
||||
<div
|
||||
v-if="variant === 'header'"
|
||||
class="bg-background text-foreground flex min-h-screen w-full flex-col"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<SidebarProvider v-else :default-open="isOpen">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import {
|
||||
ArrowLeft,
|
||||
BookOpen,
|
||||
CirclePlus,
|
||||
Folder,
|
||||
FolderGit2,
|
||||
LayoutGrid,
|
||||
@@ -26,9 +28,14 @@ import {
|
||||
import { dashboard } from '@/routes';
|
||||
import { index as adminUsers } from '@/routes/admin/users';
|
||||
import { index as projectTemplatesIndex } from '@/routes/project-templates';
|
||||
import { index as projectsIndex } from '@/routes/projects';
|
||||
import {
|
||||
create as projectsCreate,
|
||||
index as projectsIndex,
|
||||
} from '@/routes/projects';
|
||||
import { index as teamsIndex } from '@/routes/teams';
|
||||
import type { NavItem } from '@/types';
|
||||
import type { AppSidebarProps, NavItem } from '@/types';
|
||||
|
||||
const props = defineProps<AppSidebarProps>();
|
||||
|
||||
const page = usePage();
|
||||
|
||||
@@ -76,24 +83,24 @@ const mainNavItems = computed<NavItem[]>(() => {
|
||||
const footerNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Repository',
|
||||
href: 'https://github.com/laravel/vue-starter-kit',
|
||||
href: 'https://git.twyco.de/OpenSource/KanbanGit',
|
||||
icon: FolderGit2,
|
||||
},
|
||||
{
|
||||
title: 'Documentation',
|
||||
href: 'https://laravel.com/docs/starter-kits#vue',
|
||||
href: 'https://git.twyco.de/OpenSource/KanbanGit/src/branch/main/README.md',
|
||||
icon: BookOpen,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sidebar collapsible="icon" variant="inset">
|
||||
<Sidebar collapsible="icon" variant="sidebar" class="border-sidebar-border">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" as-child>
|
||||
<Link :href="dashboard()">
|
||||
<Link :href="project ? projectsIndex() : dashboard()">
|
||||
<AppLogo />
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
@@ -102,7 +109,54 @@ const footerNavItems: NavItem[] = [
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<NavMain :items="mainNavItems" />
|
||||
<template v-if="project">
|
||||
<div
|
||||
class="border-sidebar-border bg-sidebar-accent/60 mx-3 mb-3 rounded-lg border p-3 group-data-[collapsible=icon]:hidden"
|
||||
>
|
||||
<div
|
||||
class="text-sidebar-foreground/55 text-[0.65rem] font-bold uppercase tracking-[0.12em]"
|
||||
>
|
||||
{{ project.key }}
|
||||
</div>
|
||||
<div class="mt-1 truncate text-sm font-semibold">
|
||||
{{ project.name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-auto px-3 pb-3">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton as-child tooltip="All projects">
|
||||
<Link :href="projectsIndex()">
|
||||
<ArrowLeft />
|
||||
<span>All projects</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NavMain :items="mainNavItems" label="Workspace" />
|
||||
<div
|
||||
v-if="page.props.auth.permissions.projectCreate"
|
||||
class="px-3 pt-3 group-data-[collapsible=icon]:px-2"
|
||||
>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
as-child
|
||||
tooltip="New project"
|
||||
class="bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground"
|
||||
>
|
||||
<Link :href="projectsCreate()">
|
||||
<CirclePlus />
|
||||
<span>New project</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</div>
|
||||
</template>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import Breadcrumbs from '@/components/Breadcrumbs.vue';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
import type { AppSidebarHeaderProps } from '@/types';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
}>(),
|
||||
{
|
||||
breadcrumbs: () => [],
|
||||
},
|
||||
);
|
||||
withDefaults(defineProps<AppSidebarHeaderProps>(), {
|
||||
breadcrumbs: () => [],
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="border-sidebar-border/70 group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 flex h-16 shrink-0 items-center gap-2 border-b px-6 transition-[width,height] ease-linear md:px-4"
|
||||
class="border-border/70 bg-background/92 sticky top-0 z-20 flex h-14 shrink-0 items-center gap-2 border-b px-4 backdrop-blur-xl transition-[width,height] ease-linear md:px-6"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<SidebarTrigger class="-ml-1" />
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<SidebarTrigger
|
||||
class="text-muted-foreground hover:text-foreground -ml-1"
|
||||
/>
|
||||
<template v-if="breadcrumbs && breadcrumbs.length > 0">
|
||||
<Breadcrumbs :breadcrumbs="breadcrumbs" />
|
||||
</template>
|
||||
|
||||
@@ -13,17 +13,17 @@ const tabs = [
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800"
|
||||
class="bg-surface-container-low border-outline-variant inline-flex gap-1 rounded-lg border p-1"
|
||||
>
|
||||
<button
|
||||
v-for="{ value, Icon, label } in tabs"
|
||||
:key="value"
|
||||
@click="updateAppearance(value)"
|
||||
:class="[
|
||||
'flex items-center rounded-md px-3.5 py-1.5 transition-colors',
|
||||
'flex items-center rounded-md px-3.5 py-2 text-sm font-medium transition-all',
|
||||
appearance === value
|
||||
? 'shadow-xs bg-white dark:bg-neutral-700 dark:text-neutral-100'
|
||||
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60',
|
||||
? 'bg-surface-container-lowest text-foreground shadow-xs ring-outline-variant ring-1'
|
||||
: 'text-muted-foreground hover:bg-surface-container-high hover:text-foreground',
|
||||
]"
|
||||
>
|
||||
<component :is="Icon" class="-ml-1 h-4 w-4" />
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
type Props = {
|
||||
title: string;
|
||||
description?: string;
|
||||
variant?: 'default' | 'small';
|
||||
};
|
||||
import type { HeadingProps } from '@/types';
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
withDefaults(defineProps<HeadingProps>(), {
|
||||
variant: 'default',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header :class="variant === 'small' ? '' : 'mb-8 space-y-0.5'">
|
||||
<header :class="variant === 'small' ? '' : 'space-y-1.5'">
|
||||
<h2
|
||||
:class="
|
||||
variant === 'small'
|
||||
? 'mb-0.5 text-base font-medium'
|
||||
: 'text-xl font-semibold tracking-tight'
|
||||
? 'mb-0.5 text-base font-semibold tracking-tight'
|
||||
: 'text-2xl font-semibold tracking-[-0.025em] sm:text-[1.75rem]'
|
||||
"
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p v-if="description" class="text-muted-foreground text-sm">
|
||||
<p
|
||||
v-if="description"
|
||||
class="text-muted-foreground max-w-3xl text-sm leading-6"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -25,7 +25,7 @@ defineProps<Props>();
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem v-for="item in items" :key="item.title">
|
||||
<SidebarMenuButton
|
||||
class="text-neutral-600 hover:text-neutral-800 dark:text-neutral-300 dark:hover:text-neutral-100"
|
||||
class="text-sidebar-foreground/60 hover:text-sidebar-foreground"
|
||||
as-child
|
||||
>
|
||||
<a
|
||||
|
||||
@@ -8,23 +8,25 @@ import {
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||
import type { NavItem } from '@/types';
|
||||
import type { NavMainProps } from '@/types';
|
||||
|
||||
defineProps<{
|
||||
items: NavItem[];
|
||||
}>();
|
||||
withDefaults(defineProps<NavMainProps>(), {
|
||||
label: 'Workspace',
|
||||
});
|
||||
|
||||
const { isCurrentUrl } = useCurrentUrl();
|
||||
const { isCurrentOrParentUrl } = useCurrentUrl();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarGroup class="px-2 py-0">
|
||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||
<SidebarGroupLabel>{{ label }}</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem v-for="item in items" :key="item.title">
|
||||
<SidebarMenuButton
|
||||
as-child
|
||||
:is-active="isCurrentUrl(item.href)"
|
||||
:is-active="
|
||||
item.isActive ?? isCurrentOrParentUrl(item.href)
|
||||
"
|
||||
:tooltip="item.title"
|
||||
>
|
||||
<Link :href="item.href">
|
||||
|
||||
@@ -1,28 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useInitials } from '@/composables/useInitials';
|
||||
import type { User } from '@/types';
|
||||
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||
import type { UserInfoProps } from '@/types';
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
showEmail?: boolean;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
withDefaults(defineProps<UserInfoProps>(), {
|
||||
showEmail: false,
|
||||
});
|
||||
|
||||
const { getInitials } = useInitials();
|
||||
|
||||
// Compute whether we should show the avatar image
|
||||
const showAvatar = computed(
|
||||
() => props.user.avatar && props.user.avatar !== '',
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserAvatar />
|
||||
<UserAvatar :user="user" />
|
||||
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-medium">{{ user.name }}</span>
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
CircleAlert,
|
||||
IndentDecrease,
|
||||
IndentIncrease,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from 'lucide-vue-next';
|
||||
import { nextTick, ref } from 'vue';
|
||||
import {
|
||||
addAcceptanceCriterionAfter,
|
||||
indentAcceptanceCriterionSubtree,
|
||||
moveAcceptanceCriterionSubtree,
|
||||
nextAcceptanceCriteriaSiblingIndex,
|
||||
outdentAcceptanceCriterionSubtree,
|
||||
previousAcceptanceCriteriaSiblingIndex,
|
||||
removeAcceptanceCriterion,
|
||||
syncAcceptanceCriteriaDescendantCheckedState,
|
||||
} from '@/lib/acceptance-criteria';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type {
|
||||
AcceptanceCriteriaEditorEmits,
|
||||
AcceptanceCriteriaEditorProps,
|
||||
} from '@/types';
|
||||
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
|
||||
|
||||
const props = withDefaults(defineProps<AcceptanceCriteriaEditorProps>(), {
|
||||
disabled: false,
|
||||
error: '',
|
||||
saveState: 'idle',
|
||||
});
|
||||
const emit = defineEmits<AcceptanceCriteriaEditorEmits>();
|
||||
|
||||
const editorRoot = ref<HTMLElement | null>(null);
|
||||
const editingIndex = ref<number | null>(null);
|
||||
const originalText = ref<{ index: number; text: string } | null>(null);
|
||||
const skippedBlurIndex = ref<number | null>(null);
|
||||
|
||||
const emitUpdate = (items: AcceptanceCriteriaModel): void => {
|
||||
emit('update:modelValue', items);
|
||||
};
|
||||
|
||||
const emitCommit = (
|
||||
items: AcceptanceCriteriaModel,
|
||||
source: 'check' | 'delete' | 'indent' | 'move' | 'text',
|
||||
): void => {
|
||||
emitUpdate(items);
|
||||
emit('commit', { items, source });
|
||||
};
|
||||
|
||||
const focusRow = (index: number): void => {
|
||||
editingIndex.value = index;
|
||||
|
||||
void nextTick(() => {
|
||||
editorRoot.value
|
||||
?.querySelector<HTMLInputElement>(`[data-ac-row-index="${index}"]`)
|
||||
?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const startTextEdit = (index: number): void => {
|
||||
if (!props.editable || props.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
rememberText(index);
|
||||
focusRow(index);
|
||||
};
|
||||
|
||||
const addCriterion = (index: number | null): void => {
|
||||
if (!props.editable || props.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emptyCriterionIndex = props.modelValue.findIndex(
|
||||
(criterion) => criterion.text.trim() === '',
|
||||
);
|
||||
|
||||
if (emptyCriterionIndex !== -1) {
|
||||
focusRow(emptyCriterionIndex);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const result = addAcceptanceCriterionAfter(props.modelValue, index);
|
||||
|
||||
emitUpdate(result.items);
|
||||
focusRow(result.index);
|
||||
};
|
||||
|
||||
const updateText = (index: number, event: Event): void => {
|
||||
const target = event.target;
|
||||
|
||||
if (!(target instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = props.modelValue.map((item, itemIndex) => ({
|
||||
...item,
|
||||
text: itemIndex === index ? target.value : item.text,
|
||||
}));
|
||||
|
||||
emitUpdate(items);
|
||||
};
|
||||
|
||||
const rememberText = (index: number): void => {
|
||||
originalText.value = {
|
||||
index,
|
||||
text: props.modelValue[index]?.text ?? '',
|
||||
};
|
||||
};
|
||||
|
||||
const commitText = (index: number): void => {
|
||||
if (skippedBlurIndex.value === index) {
|
||||
skippedBlurIndex.value = null;
|
||||
|
||||
if (editingIndex.value === index) {
|
||||
editingIndex.value = null;
|
||||
}
|
||||
|
||||
if (originalText.value?.index === index) {
|
||||
originalText.value = null;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const item = props.modelValue[index];
|
||||
|
||||
if (item === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedText = item.text.trim();
|
||||
|
||||
if (trimmedText === '') {
|
||||
const items = removeAcceptanceCriterion(props.modelValue, index);
|
||||
|
||||
if (item.id === undefined) {
|
||||
emitUpdate(items);
|
||||
} else {
|
||||
emitCommit(items, 'delete');
|
||||
}
|
||||
|
||||
originalText.value = null;
|
||||
editingIndex.value = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const items = props.modelValue.map((criterion, criterionIndex) => ({
|
||||
...criterion,
|
||||
text: criterionIndex === index ? trimmedText : criterion.text,
|
||||
}));
|
||||
const previousText =
|
||||
originalText.value?.index === index
|
||||
? originalText.value.text
|
||||
: item.text;
|
||||
|
||||
if (trimmedText !== previousText || props.error !== '') {
|
||||
emitCommit(items, 'text');
|
||||
} else if (trimmedText !== item.text) {
|
||||
emitUpdate(items);
|
||||
}
|
||||
|
||||
originalText.value = null;
|
||||
editingIndex.value = null;
|
||||
};
|
||||
|
||||
const cancelTextEdit = (index: number, event: KeyboardEvent): void => {
|
||||
const target = event.target;
|
||||
|
||||
if (!(target instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackText =
|
||||
originalText.value?.index === index ? originalText.value.text : '';
|
||||
const items = props.modelValue.map((item, itemIndex) => ({
|
||||
...item,
|
||||
text: itemIndex === index ? fallbackText : item.text,
|
||||
}));
|
||||
|
||||
skippedBlurIndex.value = index;
|
||||
emitUpdate(items);
|
||||
target.blur();
|
||||
};
|
||||
|
||||
const insertAfterCurrent = (index: number, event: KeyboardEvent): void => {
|
||||
event.preventDefault();
|
||||
const item = props.modelValue[index];
|
||||
const trimmedText = item?.text.trim() ?? '';
|
||||
|
||||
if (item === undefined || trimmedText === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
const committedItems = props.modelValue.map(
|
||||
(criterion, criterionIndex) => ({
|
||||
...criterion,
|
||||
text: criterionIndex === index ? trimmedText : criterion.text,
|
||||
}),
|
||||
);
|
||||
const result = addAcceptanceCriterionAfter(committedItems, index);
|
||||
|
||||
originalText.value = null;
|
||||
skippedBlurIndex.value = index;
|
||||
emitUpdate(result.items);
|
||||
focusRow(result.index);
|
||||
emit('commit', { items: committedItems, source: 'text' });
|
||||
};
|
||||
|
||||
const toggleCriterion = (index: number, event: Event): void => {
|
||||
const target = event.target;
|
||||
|
||||
if (!(target instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
emitCommit(
|
||||
syncAcceptanceCriteriaDescendantCheckedState(
|
||||
props.modelValue,
|
||||
index,
|
||||
target.checked,
|
||||
),
|
||||
'check',
|
||||
);
|
||||
};
|
||||
|
||||
const indentCriterion = (index: number): void => {
|
||||
const items = indentAcceptanceCriterionSubtree(props.modelValue, index);
|
||||
|
||||
if (props.modelValue[index]?.text.trim() === '') {
|
||||
emitUpdate(items);
|
||||
focusRow(index);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
emitCommit(items, 'indent');
|
||||
};
|
||||
|
||||
const outdentCriterion = (index: number): void => {
|
||||
const items = outdentAcceptanceCriterionSubtree(props.modelValue, index);
|
||||
|
||||
if (props.modelValue[index]?.text.trim() === '') {
|
||||
emitUpdate(items);
|
||||
focusRow(index);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
emitCommit(items, 'indent');
|
||||
};
|
||||
|
||||
const moveCriterion = (index: number, direction: 'down' | 'up'): void => {
|
||||
emitCommit(
|
||||
moveAcceptanceCriterionSubtree(props.modelValue, index, direction),
|
||||
'move',
|
||||
);
|
||||
};
|
||||
|
||||
const deleteCriterion = (index: number): void => {
|
||||
emitCommit(removeAcceptanceCriterion(props.modelValue, index), 'delete');
|
||||
};
|
||||
|
||||
const handleIndentKeyboard = (index: number, event: KeyboardEvent): void => {
|
||||
event.preventDefault();
|
||||
|
||||
if (event.shiftKey) {
|
||||
outdentCriterion(index);
|
||||
} else {
|
||||
indentCriterion(index);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="editorRoot" class="space-y-3">
|
||||
<div
|
||||
v-if="modelValue.length === 0"
|
||||
class="border-outline-variant bg-surface-container-low flex min-h-28 flex-col items-center justify-center gap-3 rounded border border-dashed px-4 py-6 text-center"
|
||||
>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
No acceptance criteria have been added yet.
|
||||
</p>
|
||||
<Button
|
||||
v-if="editable"
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="disabled"
|
||||
@click="addCriterion(null)"
|
||||
>
|
||||
<Plus class="size-4" />
|
||||
Add criterion
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ol v-else class="space-y-1.5">
|
||||
<li
|
||||
v-for="(item, index) in modelValue"
|
||||
:key="item.id ?? `draft-${index}`"
|
||||
class="group/ac-row"
|
||||
:style="{ paddingLeft: `${item.indent * 1.1}rem` }"
|
||||
>
|
||||
<div
|
||||
class="border-outline-variant/80 bg-background hover:border-primary/35 focus-within:border-primary/50 focus-within:bg-primary/[0.025] flex min-h-10 flex-wrap items-start gap-2 rounded border px-2.5 py-2 transition sm:flex-nowrap"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="item.checked"
|
||||
:disabled="!editable || disabled"
|
||||
class="border-input text-primary focus:ring-ring mt-0.5 size-4 shrink-0 rounded"
|
||||
:aria-label="`Mark ${item.text || 'criterion'} as complete`"
|
||||
@change="toggleCriterion(index, $event)"
|
||||
/>
|
||||
|
||||
<input
|
||||
v-if="editable && editingIndex === index"
|
||||
:data-ac-row-index="index"
|
||||
:value="item.text"
|
||||
type="text"
|
||||
:disabled="disabled"
|
||||
class="placeholder:text-muted-foreground/70 min-w-0 flex-1 border-0 bg-transparent p-0 text-sm leading-5 outline-none disabled:cursor-not-allowed"
|
||||
placeholder="Describe a verifiable outcome"
|
||||
@input="updateText(index, $event)"
|
||||
@blur="commitText(index)"
|
||||
@keydown.enter="insertAfterCurrent(index, $event)"
|
||||
@keydown.esc="cancelTextEdit(index, $event)"
|
||||
@keydown.tab="handleIndentKeyboard(index, $event)"
|
||||
/>
|
||||
<button
|
||||
v-else-if="editable"
|
||||
type="button"
|
||||
class="placeholder:text-muted-foreground/70 hover:text-primary focus-visible:ring-ring min-w-0 flex-1 whitespace-normal rounded text-left text-sm leading-5 outline-none focus-visible:ring-2"
|
||||
:disabled="disabled"
|
||||
@click="startTextEdit(index)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
item.checked
|
||||
? 'text-muted-foreground line-through'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
{{ item.text || 'Describe a verifiable outcome' }}
|
||||
</span>
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="min-w-0 flex-1 text-sm leading-5"
|
||||
:class="
|
||||
item.checked
|
||||
? 'text-muted-foreground line-through'
|
||||
: 'text-foreground'
|
||||
"
|
||||
>
|
||||
{{ item.text }}
|
||||
</span>
|
||||
|
||||
<div
|
||||
v-if="editable"
|
||||
class="flex w-full shrink-0 items-center justify-end gap-0.5 pl-6 opacity-100 transition sm:w-auto sm:pl-0 sm:opacity-0 sm:group-focus-within/ac-row:opacity-100 sm:group-hover/ac-row:opacity-100"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-6"
|
||||
:disabled="disabled"
|
||||
aria-label="Add criterion below"
|
||||
title="Add below"
|
||||
@mousedown.prevent
|
||||
@click="addCriterion(index)"
|
||||
>
|
||||
<Plus class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-6"
|
||||
:disabled="disabled || item.indent === 0"
|
||||
aria-label="Outdent criterion"
|
||||
title="Outdent (Shift+Tab)"
|
||||
@mousedown.prevent
|
||||
@click="outdentCriterion(index)"
|
||||
>
|
||||
<IndentDecrease class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-6"
|
||||
:disabled="
|
||||
disabled ||
|
||||
previousAcceptanceCriteriaSiblingIndex(
|
||||
modelValue,
|
||||
index,
|
||||
) === null
|
||||
"
|
||||
aria-label="Indent criterion"
|
||||
title="Indent (Tab)"
|
||||
@mousedown.prevent
|
||||
@click="indentCriterion(index)"
|
||||
>
|
||||
<IndentIncrease class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-6"
|
||||
:disabled="
|
||||
disabled ||
|
||||
previousAcceptanceCriteriaSiblingIndex(
|
||||
modelValue,
|
||||
index,
|
||||
) === null
|
||||
"
|
||||
aria-label="Move criterion up"
|
||||
title="Move up"
|
||||
@mousedown.prevent
|
||||
@click="moveCriterion(index, 'up')"
|
||||
>
|
||||
<ArrowUp class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-6"
|
||||
:disabled="
|
||||
disabled ||
|
||||
nextAcceptanceCriteriaSiblingIndex(
|
||||
modelValue,
|
||||
index,
|
||||
) === null
|
||||
"
|
||||
aria-label="Move criterion down"
|
||||
title="Move down"
|
||||
@mousedown.prevent
|
||||
@click="moveCriterion(index, 'down')"
|
||||
>
|
||||
<ArrowDown class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-destructive hover:text-destructive size-6"
|
||||
:disabled="disabled"
|
||||
aria-label="Delete criterion"
|
||||
title="Delete"
|
||||
@mousedown.prevent
|
||||
@click="deleteCriterion(index)"
|
||||
>
|
||||
<Trash2 class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="flex min-h-5 items-center justify-between gap-3">
|
||||
<Button
|
||||
v-if="editable && modelValue.length > 0"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs"
|
||||
:disabled="disabled"
|
||||
@click="addCriterion(null)"
|
||||
>
|
||||
<Plus class="size-3.5" />
|
||||
Add criterion
|
||||
</Button>
|
||||
<span v-else />
|
||||
|
||||
<span
|
||||
v-if="saveState === 'error' || error"
|
||||
class="text-destructive flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
<CircleAlert class="size-3.5" />
|
||||
{{ error || 'Could not save changes.' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { CircleAlert } from 'lucide-vue-next';
|
||||
import type { InlineSaveStatusProps } from '@/types';
|
||||
|
||||
withDefaults(defineProps<InlineSaveStatusProps>(), {
|
||||
error: '',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="state === 'error' || error"
|
||||
class="flex items-center gap-1.5 pt-1 text-[11px]"
|
||||
>
|
||||
<CircleAlert class="text-destructive size-3" />
|
||||
<span class="text-destructive">{{ error || 'Could not save.' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import { CircleAlert, Pencil } from 'lucide-vue-next';
|
||||
import { nextTick, ref } from 'vue';
|
||||
import type {
|
||||
InlineTextEditorEmits,
|
||||
InlineTextEditorProps,
|
||||
InlineTextEditorValue,
|
||||
} from '@/types';
|
||||
|
||||
const props = withDefaults(defineProps<InlineTextEditorProps>(), {
|
||||
disabled: false,
|
||||
error: '',
|
||||
inputType: 'text',
|
||||
multiline: false,
|
||||
saveState: 'idle',
|
||||
variant: 'body',
|
||||
});
|
||||
const emit = defineEmits<InlineTextEditorEmits>();
|
||||
|
||||
const isEditing = ref(false);
|
||||
const originalValue = ref<InlineTextEditorValue>(null);
|
||||
const skipNextBlur = ref(false);
|
||||
const editor = ref<HTMLInputElement | HTMLTextAreaElement | null>(null);
|
||||
|
||||
const startEditing = (): void => {
|
||||
if (!props.editable || props.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
originalValue.value = props.value;
|
||||
isEditing.value = true;
|
||||
|
||||
void nextTick(() => {
|
||||
editor.value?.focus();
|
||||
|
||||
if (editor.value instanceof HTMLInputElement) {
|
||||
editor.value.select();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateValue = (event: Event): void => {
|
||||
const target = event.target;
|
||||
|
||||
if (
|
||||
!(target instanceof HTMLInputElement) &&
|
||||
!(target instanceof HTMLTextAreaElement)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('update:value', target.value);
|
||||
};
|
||||
|
||||
const finishEditing = (): void => {
|
||||
if (skipNextBlur.value) {
|
||||
skipNextBlur.value = false;
|
||||
isEditing.value = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
isEditing.value = false;
|
||||
|
||||
if (props.value !== originalValue.value || props.error !== '') {
|
||||
emit('commit');
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEditing = (): void => {
|
||||
skipNextBlur.value = true;
|
||||
emit('update:value', originalValue.value);
|
||||
editor.value?.blur();
|
||||
};
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelEditing();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(!props.multiline && event.key === 'Enter') ||
|
||||
(props.multiline &&
|
||||
event.key === 'Enter' &&
|
||||
(event.metaKey || event.ctrlKey))
|
||||
) {
|
||||
event.preventDefault();
|
||||
editor.value?.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const displayValue = (): string => {
|
||||
if (props.value === null || props.value === '') {
|
||||
return props.placeholder;
|
||||
}
|
||||
|
||||
return String(props.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-w-0">
|
||||
<div v-if="isEditing" class="space-y-1.5">
|
||||
<textarea
|
||||
v-if="multiline"
|
||||
ref="editor"
|
||||
:value="value ?? ''"
|
||||
rows="5"
|
||||
:disabled="disabled"
|
||||
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 min-h-32 w-full resize-y rounded border px-3 py-2 text-sm leading-6 outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
@input="updateValue"
|
||||
@blur="finishEditing"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
ref="editor"
|
||||
:value="value ?? ''"
|
||||
:type="inputType"
|
||||
:disabled="disabled"
|
||||
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 w-full rounded border px-3 py-2 text-sm outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
@input="updateValue"
|
||||
@blur="finishEditing"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
<p class="text-muted-foreground text-[11px]">
|
||||
{{
|
||||
multiline
|
||||
? 'Cmd/Ctrl+Enter to save · Escape to cancel'
|
||||
: 'Enter to save · Escape to cancel'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-else-if="editable"
|
||||
type="button"
|
||||
class="group/inline hover:bg-primary/5 focus-visible:ring-ring relative -m-1 flex w-[calc(100%+0.5rem)] min-w-0 items-start gap-2 rounded p-1 text-left outline-none transition focus-visible:ring-2"
|
||||
:disabled="disabled"
|
||||
@click="startEditing"
|
||||
>
|
||||
<span
|
||||
class="min-w-0 flex-1 whitespace-pre-wrap"
|
||||
:class="[
|
||||
variant === 'title'
|
||||
? 'text-2xl font-semibold leading-tight tracking-tight sm:text-[1.75rem]'
|
||||
: variant === 'property'
|
||||
? 'text-sm font-medium'
|
||||
: 'text-sm leading-6',
|
||||
value === null || value === ''
|
||||
? 'text-muted-foreground italic'
|
||||
: 'text-foreground',
|
||||
]"
|
||||
>
|
||||
{{ displayValue() }}
|
||||
</span>
|
||||
<Pencil
|
||||
class="text-muted-foreground mt-0.5 size-3.5 shrink-0 opacity-0 transition group-hover/inline:opacity-100 group-focus-visible/inline:opacity-100"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div v-else>
|
||||
<p
|
||||
class="whitespace-pre-wrap"
|
||||
:class="[
|
||||
variant === 'title'
|
||||
? 'text-2xl font-semibold leading-tight tracking-tight sm:text-[1.75rem]'
|
||||
: variant === 'property'
|
||||
? 'text-sm font-medium'
|
||||
: 'text-sm leading-6',
|
||||
value === null || value === ''
|
||||
? 'text-muted-foreground italic'
|
||||
: 'text-foreground',
|
||||
]"
|
||||
>
|
||||
{{ displayValue() }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="saveState === 'error' || error"
|
||||
class="mt-1 flex items-center gap-1.5 text-[11px]"
|
||||
>
|
||||
<CircleAlert class="text-destructive size-3" />
|
||||
<span class="text-destructive">{{
|
||||
error || 'Could not save.'
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { ProjectPageHeaderProps } from '@/types/project-navigation';
|
||||
|
||||
defineProps<ProjectPageHeaderProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="outline" class="rounded px-2.5 py-1">
|
||||
{{ project.key }}
|
||||
</Badge>
|
||||
<span class="text-muted-foreground text-xs font-medium">
|
||||
{{ context }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 class="mt-2 truncate text-2xl font-bold tracking-tight">
|
||||
{{ project.name }}
|
||||
</h1>
|
||||
<p class="text-muted-foreground mt-1 max-w-3xl text-sm leading-6">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap gap-2 self-start sm:self-auto">
|
||||
<slot />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { KanbanSquare, ListTodo, Settings2 } from 'lucide-vue-next';
|
||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||
import { index as boardIndex } from '@/routes/projects/board';
|
||||
import { index as settingsIndex } from '@/routes/projects/settings';
|
||||
import type { ProjectTabsProps } from '@/types';
|
||||
|
||||
defineProps<ProjectTabsProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
aria-label="Project sections"
|
||||
class="border-outline-variant/70 flex min-w-max items-center gap-1 border-b"
|
||||
>
|
||||
<Link
|
||||
:href="backlogIndex(project.id)"
|
||||
prefetch
|
||||
:class="[
|
||||
'group relative inline-flex h-11 items-center gap-2 px-3 text-sm font-semibold transition-colors',
|
||||
active === 'backlog'
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
]"
|
||||
>
|
||||
<ListTodo class="size-4" />
|
||||
Backlog
|
||||
<span
|
||||
v-if="active === 'backlog'"
|
||||
class="bg-primary absolute inset-x-2 -bottom-px h-0.5"
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
:href="boardIndex(project.id)"
|
||||
prefetch
|
||||
:class="[
|
||||
'group relative inline-flex h-11 items-center gap-2 px-3 text-sm font-semibold transition-colors',
|
||||
active === 'board'
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
]"
|
||||
>
|
||||
<KanbanSquare class="size-4" />
|
||||
Board
|
||||
<span
|
||||
v-if="active === 'board'"
|
||||
class="bg-primary absolute inset-x-2 -bottom-px h-0.5"
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
v-if="canManageProject"
|
||||
:href="settingsIndex(project.id)"
|
||||
prefetch
|
||||
:class="[
|
||||
'group relative inline-flex h-11 items-center gap-2 px-3 text-sm font-semibold transition-colors',
|
||||
active === 'settings'
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
]"
|
||||
>
|
||||
<Settings2 class="size-4" />
|
||||
Settings
|
||||
<span
|
||||
v-if="active === 'settings'"
|
||||
class="bg-primary absolute inset-x-2 -bottom-px h-0.5"
|
||||
/>
|
||||
</Link>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,174 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronDown, Search } from 'lucide-vue-next';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type {
|
||||
PropertySelectDropdownEmits,
|
||||
PropertySelectDropdownProps,
|
||||
} from '@/types';
|
||||
|
||||
const props = withDefaults(defineProps<PropertySelectDropdownProps>(), {
|
||||
disabled: false,
|
||||
searchable: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<PropertySelectDropdownEmits>();
|
||||
const maximumVisibleOptions = 12;
|
||||
const isOpen = ref(false);
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const normalizedValue = computed(() => String(props.modelValue ?? ''));
|
||||
const normalizedSearchQuery = computed(() =>
|
||||
searchQuery.value.trim().toLocaleLowerCase(),
|
||||
);
|
||||
const selectedOption = computed(
|
||||
() =>
|
||||
props.options.find(
|
||||
(option) => String(option.value) === normalizedValue.value,
|
||||
) ?? null,
|
||||
);
|
||||
const visibleOptions = computed(() => {
|
||||
const query = normalizedSearchQuery.value;
|
||||
|
||||
return props.options
|
||||
.filter((option) => {
|
||||
if (!props.searchable || query === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [option.label, option.description].some((value) =>
|
||||
value?.toLocaleLowerCase().includes(query),
|
||||
);
|
||||
})
|
||||
.slice(0, maximumVisibleOptions);
|
||||
});
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
searchQuery.value = '';
|
||||
|
||||
if (open && props.searchable) {
|
||||
void nextTick(() => searchInput.value?.focus());
|
||||
}
|
||||
});
|
||||
|
||||
const focusSearch = (event: Event): void => {
|
||||
if (!props.searchable) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
void nextTick(() => searchInput.value?.focus());
|
||||
};
|
||||
|
||||
const handleSearchKeydown = (event: KeyboardEvent): void => {
|
||||
if (event.key !== 'Escape' && event.key !== 'Tab') {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const selectOption = (value: number | string): void => {
|
||||
if (
|
||||
!props.editable ||
|
||||
props.disabled ||
|
||||
String(value) === normalizedValue.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('update:modelValue', value);
|
||||
emit('commit', value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu v-if="editable" v-model:open="isOpen">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring/50 data-[state=open]:bg-primary/[0.08] data-[state=open]:text-primary hover:bg-primary/[0.055] hover:text-primary group -mx-1 flex min-h-7 w-[calc(100%+0.5rem)] items-center gap-2 rounded px-1 text-left text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-60"
|
||||
:aria-label="`Select ${label}`"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
{{ selectedOption?.label ?? 'Not set' }}
|
||||
</span>
|
||||
<ChevronDown
|
||||
class="text-muted-foreground size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180"
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
:side-offset="6"
|
||||
class="w-(--reka-dropdown-menu-trigger-width) min-w-52 rounded p-1"
|
||||
@open-auto-focus="focusSearch"
|
||||
>
|
||||
<div
|
||||
v-if="searchable"
|
||||
class="border-outline-variant bg-surface-container-low mb-1 flex items-center gap-2 rounded border px-2.5 py-2"
|
||||
@click.stop
|
||||
@pointerdown.stop
|
||||
>
|
||||
<Search class="text-muted-foreground size-3.5 shrink-0" />
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
class="placeholder:text-muted-foreground min-w-0 flex-1 border-0 bg-transparent p-0 text-sm outline-none"
|
||||
:placeholder="`Search ${label.toLocaleLowerCase()}...`"
|
||||
:aria-label="`Search ${label}`"
|
||||
@keydown="handleSearchKeydown"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DropdownMenuItem
|
||||
v-for="option in visibleOptions"
|
||||
:key="String(option.value)"
|
||||
class="hover:bg-primary/[0.04] focus:bg-primary/[0.08] cursor-pointer gap-2.5 rounded px-2 py-2"
|
||||
:class="
|
||||
normalizedValue === String(option.value)
|
||||
? 'bg-primary/[0.055] text-primary'
|
||||
: ''
|
||||
"
|
||||
@select="selectOption(option.value)"
|
||||
>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate font-medium">
|
||||
{{ option.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="option.description"
|
||||
class="text-muted-foreground block truncate text-[11px]"
|
||||
>
|
||||
{{ option.description }}
|
||||
</span>
|
||||
</span>
|
||||
<Check
|
||||
v-if="normalizedValue === String(option.value)"
|
||||
class="text-primary size-4 shrink-0"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<p
|
||||
v-if="visibleOptions.length === 0"
|
||||
class="text-muted-foreground px-2 py-4 text-center text-xs"
|
||||
>
|
||||
No matching {{ label.toLocaleLowerCase() }} found.
|
||||
</p>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex min-h-7 min-w-0 items-center gap-2 text-sm font-medium"
|
||||
>
|
||||
<span class="truncate">{{ selectedOption?.label ?? 'Not set' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,220 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronDown, Search, UserRound } from 'lucide-vue-next';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { UserSelectDropdownEmits, UserSelectDropdownProps } from '@/types';
|
||||
|
||||
const props = withDefaults(defineProps<UserSelectDropdownProps>(), {
|
||||
emptyLabel: 'Unassigned',
|
||||
});
|
||||
|
||||
const emit = defineEmits<UserSelectDropdownEmits>();
|
||||
const maximumVisibleOptions = 12;
|
||||
const isOpen = ref(false);
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const normalizedValue = computed(() => String(props.modelValue ?? ''));
|
||||
const normalizedSearchQuery = computed(() =>
|
||||
searchQuery.value.trim().toLocaleLowerCase(),
|
||||
);
|
||||
const selectedUser = computed(
|
||||
() =>
|
||||
props.users.find((user) => String(user.id) === normalizedValue.value) ??
|
||||
null,
|
||||
);
|
||||
const showEmptyOption = computed(
|
||||
() =>
|
||||
normalizedSearchQuery.value === '' ||
|
||||
props.emptyLabel
|
||||
.toLocaleLowerCase()
|
||||
.includes(normalizedSearchQuery.value),
|
||||
);
|
||||
const visibleUsers = computed(() => {
|
||||
const query = normalizedSearchQuery.value;
|
||||
const maximumVisibleUsers = Math.max(
|
||||
0,
|
||||
maximumVisibleOptions - (showEmptyOption.value ? 1 : 0),
|
||||
);
|
||||
|
||||
return props.users
|
||||
.filter((user) => {
|
||||
if (query === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [user.name, user.email].some((value) =>
|
||||
value?.toLocaleLowerCase().includes(query),
|
||||
);
|
||||
})
|
||||
.slice(0, maximumVisibleUsers);
|
||||
});
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
searchQuery.value = '';
|
||||
|
||||
if (open) {
|
||||
void nextTick(() => searchInput.value?.focus());
|
||||
}
|
||||
});
|
||||
|
||||
const focusSearch = (event: Event): void => {
|
||||
event.preventDefault();
|
||||
void nextTick(() => searchInput.value?.focus());
|
||||
};
|
||||
|
||||
const handleSearchKeydown = (event: KeyboardEvent): void => {
|
||||
if (event.key !== 'Escape' && event.key !== 'Tab') {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const selectUser = (value: string): void => {
|
||||
if (!props.editable || value === normalizedValue.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextValue = value === '' ? '' : Number(value);
|
||||
|
||||
emit('update:modelValue', nextValue);
|
||||
emit('commit', nextValue);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu v-if="editable" v-model:open="isOpen">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="focus-visible:ring-ring/50 data-[state=open]:bg-primary/[0.08] data-[state=open]:text-primary hover:bg-primary/[0.055] hover:text-primary group -mx-1 flex min-h-7 w-[calc(100%+0.5rem)] items-center gap-2 rounded px-1 text-left text-sm font-medium outline-none transition-colors focus-visible:ring-[3px]"
|
||||
:aria-label="`Select ${label}`"
|
||||
>
|
||||
<UserAvatar
|
||||
v-if="selectedUser"
|
||||
:user="selectedUser"
|
||||
class="size-6 shrink-0"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="border-outline-variant bg-surface-container-high text-muted-foreground flex size-6 shrink-0 items-center justify-center rounded-full border"
|
||||
>
|
||||
<UserRound class="size-3.5" />
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
{{ selectedUser?.name ?? emptyLabel }}
|
||||
</span>
|
||||
<ChevronDown
|
||||
class="text-muted-foreground size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180"
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
:side-offset="6"
|
||||
class="w-(--reka-dropdown-menu-trigger-width) min-w-64 rounded p-1"
|
||||
@open-auto-focus="focusSearch"
|
||||
>
|
||||
<div
|
||||
class="border-outline-variant bg-surface-container-low mb-1 flex items-center gap-2 rounded border px-2.5 py-2"
|
||||
@click.stop
|
||||
@pointerdown.stop
|
||||
>
|
||||
<Search class="text-muted-foreground size-3.5 shrink-0" />
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
class="placeholder:text-muted-foreground min-w-0 flex-1 border-0 bg-transparent p-0 text-sm outline-none"
|
||||
:placeholder="`Search ${label.toLocaleLowerCase()}...`"
|
||||
:aria-label="`Search ${label}`"
|
||||
@keydown="handleSearchKeydown"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DropdownMenuItem
|
||||
v-if="showEmptyOption"
|
||||
class="hover:bg-primary/[0.04] focus:bg-primary/[0.08] cursor-pointer gap-2.5 rounded px-2 py-2"
|
||||
:class="
|
||||
normalizedValue === ''
|
||||
? 'bg-primary/[0.055] text-primary'
|
||||
: ''
|
||||
"
|
||||
@select="selectUser('')"
|
||||
>
|
||||
<span
|
||||
class="border-outline-variant bg-surface-container-high text-muted-foreground flex size-7 shrink-0 items-center justify-center rounded-full border"
|
||||
>
|
||||
<UserRound class="size-3.5" />
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate font-medium">
|
||||
{{ emptyLabel }}
|
||||
</span>
|
||||
<Check
|
||||
v-if="normalizedValue === ''"
|
||||
class="text-primary size-4 shrink-0"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
v-for="user in visibleUsers"
|
||||
:key="user.id"
|
||||
class="hover:bg-primary/[0.04] focus:bg-primary/[0.08] cursor-pointer gap-2.5 rounded px-2 py-2"
|
||||
:class="
|
||||
normalizedValue === String(user.id)
|
||||
? 'bg-primary/[0.055] text-primary'
|
||||
: ''
|
||||
"
|
||||
@select="selectUser(String(user.id))"
|
||||
>
|
||||
<UserAvatar :user="user" class="size-7 shrink-0" />
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate font-medium">
|
||||
{{ user.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="user.email"
|
||||
class="text-muted-foreground block truncate text-[11px]"
|
||||
>
|
||||
{{ user.email }}
|
||||
</span>
|
||||
</span>
|
||||
<Check
|
||||
v-if="normalizedValue === String(user.id)"
|
||||
class="text-primary size-4 shrink-0"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<p
|
||||
v-if="!showEmptyOption && visibleUsers.length === 0"
|
||||
class="text-muted-foreground px-2 py-4 text-center text-xs"
|
||||
>
|
||||
No matching {{ label.toLocaleLowerCase() }} found.
|
||||
</p>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex min-h-7 min-w-0 items-center gap-2 text-sm font-medium"
|
||||
>
|
||||
<UserAvatar
|
||||
v-if="selectedUser"
|
||||
:user="selectedUser"
|
||||
class="size-6 shrink-0"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="border-outline-variant bg-surface-container-high text-muted-foreground flex size-6 shrink-0 items-center justify-center rounded-full border"
|
||||
>
|
||||
<UserRound class="size-3.5" />
|
||||
</span>
|
||||
<span class="truncate">{{ selectedUser?.name ?? emptyLabel }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,24 +1,25 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
export { default as Alert } from "./Alert.vue"
|
||||
export { default as AlertDescription } from "./AlertDescription.vue"
|
||||
export { default as AlertTitle } from "./AlertTitle.vue"
|
||||
export { default as Alert } from "./Alert.vue";
|
||||
export { default as AlertDescription } from "./AlertDescription.vue";
|
||||
export { default as AlertTitle } from "./AlertTitle.vue";
|
||||
|
||||
export const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-outline-variant bg-surface-container-lowest text-card-foreground",
|
||||
destructive:
|
||||
"border-destructive/35 text-destructive bg-destructive/8 [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
export type AlertVariants = VariantProps<typeof alertVariants>
|
||||
export type AlertVariants = VariantProps<typeof alertVariants>;
|
||||
|
||||
@@ -6,36 +6,30 @@ import {
|
||||
AvatarImage,
|
||||
} from "@/components/ui/avatar/index";
|
||||
import { usePage } from "@inertiajs/vue3";
|
||||
import { computed, PropType } from "vue";
|
||||
import { UserModel } from "@/types/models/user-model";
|
||||
import { computed } from "vue";
|
||||
import type { UserAvatarProps } from "@/types";
|
||||
|
||||
const props = defineProps({
|
||||
user: {
|
||||
type: Object as PropType<UserModel>,
|
||||
},
|
||||
rounded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
const props = withDefaults(defineProps<UserAvatarProps>(), {
|
||||
rounded: true,
|
||||
});
|
||||
|
||||
const page = usePage();
|
||||
const auth = computed(() => page.props.auth);
|
||||
|
||||
const user = computed(() => props.user ?? auth.value.user)
|
||||
const user = computed(() => props.user ?? auth.value.user);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Avatar class="size-8 overflow-hidden" :class="rounded ? 'rounded-full' : 'rounded-lg'">
|
||||
<AvatarImage
|
||||
v-if="user.avatar"
|
||||
:src="user.avatar"
|
||||
:alt="user.name"
|
||||
/>
|
||||
<Avatar
|
||||
class="border-outline-variant bg-surface-container-high size-8 overflow-hidden border"
|
||||
:class="rounded ? 'rounded-full' : 'rounded-lg'"
|
||||
>
|
||||
<AvatarImage v-if="user.avatar" :src="user.avatar" :alt="user.name" />
|
||||
<AvatarFallback
|
||||
class="rounded-lg bg-neutral-200 font-semibold text-black dark:bg-neutral-700 dark:text-white"
|
||||
class="bg-primary/12 text-on-primary-container text-xs font-bold dark:text-primary-fixed"
|
||||
:class="rounded ? 'rounded-full' : 'rounded-lg'"
|
||||
>
|
||||
{{ getInitials(auth.user?.name) }}
|
||||
{{ getInitials(user.name) }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</template>
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
export { default as Badge } from "./Badge.vue"
|
||||
export { default as Badge } from "./Badge.vue";
|
||||
|
||||
export const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
"inline-flex items-center justify-center rounded-full border px-2.5 py-1 text-[0.7rem] font-semibold leading-none w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/25 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-primary/20 bg-primary/12 text-on-primary-container dark:text-primary-fixed [a&]:hover:bg-primary/18",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border-outline-variant bg-surface-container-lowest text-on-surface-variant [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||
);
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>;
|
||||
|
||||
@@ -1,38 +1,37 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
export { default as Button } from "./Button.vue"
|
||||
export { default as Button } from "./Button.vue";
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
"icon": "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-semibold transition-all duration-150 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/25 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-[0_6px_18px_rgba(0,110,47,0.14)] hover:-translate-y-px hover:bg-primary/90 hover:shadow-[0_8px_22px_rgba(0,110,47,0.2)]",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border border-outline-variant bg-surface-container-lowest shadow-xs hover:border-outline hover:bg-surface-container-low hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
);
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>;
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
class?: HTMLAttributes["class"];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card"
|
||||
:class="
|
||||
cn(
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
data-slot="card"
|
||||
:class="
|
||||
cn(
|
||||
'bg-surface-container-lowest text-card-foreground border-outline-variant flex flex-col gap-6 rounded-xl border py-6 shadow-[0_1px_2px_rgba(16,24,40,0.03)] transition-[border-color,box-shadow] hover:border-outline/65 hover:shadow-kanban-card',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,53 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { X } from "lucide-vue-next"
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { X } from "lucide-vue-next";
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import DialogOverlay from "./DialogOverlay.vue"
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import DialogOverlay from "./DialogOverlay.vue";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
|
||||
showCloseButton: true,
|
||||
})
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
const props = withDefaults(
|
||||
defineProps<
|
||||
DialogContentProps & {
|
||||
class?: HTMLAttributes["class"];
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
>(),
|
||||
{
|
||||
showCloseButton: true,
|
||||
},
|
||||
);
|
||||
const emits = defineEmits<DialogContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
data-slot="dialog-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
data-slot="dialog-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-surface-container-lowest border-outline-variant 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-xl border p-6 shadow-2xl duration-200 sm:max-w-lg',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
v-if="showCloseButton"
|
||||
data-slot="dialog-close"
|
||||
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<X />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
<DialogClose
|
||||
v-if="showCloseButton"
|
||||
data-slot="dialog-close"
|
||||
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<X />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogOverlayProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogOverlay } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { DialogOverlayProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { DialogOverlay } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
|
||||
const props = defineProps<
|
||||
DialogOverlayProps & { class?: HTMLAttributes["class"] }
|
||||
>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogOverlay
|
||||
data-slot="dialog-overlay"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogOverlay>
|
||||
<DialogOverlay
|
||||
data-slot="dialog-overlay"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</DialogOverlay>
|
||||
</template>
|
||||
|
||||
@@ -1,39 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import type {
|
||||
DropdownMenuContentEmits,
|
||||
DropdownMenuContentProps,
|
||||
} from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||
{
|
||||
sideOffset: 4,
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<DropdownMenuContentEmits>()
|
||||
defineProps<
|
||||
DropdownMenuContentProps & { class?: HTMLAttributes["class"] }
|
||||
>(),
|
||||
{
|
||||
sideOffset: 4,
|
||||
},
|
||||
);
|
||||
const emits = defineEmits<DropdownMenuContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground border-outline-variant 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-lg border p-1.5 shadow-xl',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</template>
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { useVModel } from "@vueuse/core"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { useVModel } from "@vueuse/core";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<{
|
||||
defaultValue?: string | number
|
||||
modelValue?: string | number | null
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
defaultValue?: string | number;
|
||||
modelValue?: string | number | null;
|
||||
class?: HTMLAttributes["class"];
|
||||
}>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: "update:modelValue", payload: string | number): void
|
||||
}>()
|
||||
(e: "update:modelValue", payload: string | number): void;
|
||||
}>();
|
||||
|
||||
const modelValue = useVModel(props, "modelValue", emits, {
|
||||
passive: true,
|
||||
defaultValue: props.defaultValue,
|
||||
})
|
||||
passive: true,
|
||||
defaultValue: props.defaultValue,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
v-model="modelValue"
|
||||
data-slot="input"
|
||||
:class="cn(
|
||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<input
|
||||
v-model="modelValue"
|
||||
data-slot="input"
|
||||
:class="
|
||||
cn(
|
||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input bg-surface-container-lowest h-10 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow,background-color] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/25 focus-visible:ring-[3px]',
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,51 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectContentEmits, SelectContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import type { SelectContentEmits, SelectContentProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import {
|
||||
SelectContent,
|
||||
SelectPortal,
|
||||
SelectViewport,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { SelectScrollDownButton, SelectScrollUpButton } from "."
|
||||
SelectContent,
|
||||
SelectPortal,
|
||||
SelectViewport,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SelectScrollDownButton, SelectScrollUpButton } from ".";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||
{
|
||||
position: "popper",
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<SelectContentEmits>()
|
||||
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||
{
|
||||
position: "popper",
|
||||
},
|
||||
);
|
||||
const emits = defineEmits<SelectContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectPortal>
|
||||
<SelectContent
|
||||
data-slot="select-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
||||
position === 'popper'
|
||||
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectViewport :class="cn('p-1', position === 'popper' && 'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1')">
|
||||
<slot />
|
||||
</SelectViewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
<SelectPortal>
|
||||
<SelectContent
|
||||
data-slot="select-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground border-outline-variant data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-lg border shadow-xl',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectViewport
|
||||
:class="
|
||||
cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1',
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</SelectViewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</template>
|
||||
|
||||
@@ -1,33 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectTriggerProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ChevronDown } from "lucide-vue-next"
|
||||
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { SelectTriggerProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ChevronDown } from "lucide-vue-next";
|
||||
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"], size?: "sm" | "default" }>(),
|
||||
{ size: "default" },
|
||||
)
|
||||
defineProps<
|
||||
SelectTriggerProps & {
|
||||
class?: HTMLAttributes["class"];
|
||||
size?: "sm" | "default";
|
||||
}
|
||||
>(),
|
||||
{ size: "default" },
|
||||
);
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "size")
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
const delegatedProps = reactiveOmit(props, "class", "size");
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectTrigger
|
||||
data-slot="select-trigger"
|
||||
:data-size="size"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn(
|
||||
'border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
<SelectIcon as-child>
|
||||
<ChevronDown class="size-4 opacity-50" />
|
||||
</SelectIcon>
|
||||
</SelectTrigger>
|
||||
<SelectTrigger
|
||||
data-slot="select-trigger"
|
||||
:data-size="size"
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'border-input bg-surface-container-lowest data-[placeholder]:text-muted-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/25 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex w-fit items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow,background-color] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-10 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<SelectIcon as-child>
|
||||
<ChevronDown class="size-4 opacity-50" />
|
||||
</SelectIcon>
|
||||
</SelectTrigger>
|
||||
</template>
|
||||
|
||||
@@ -1,62 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { X } from "lucide-vue-next"
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { X } from "lucide-vue-next";
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import SheetOverlay from "./SheetOverlay.vue"
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import SheetOverlay from "./SheetOverlay.vue";
|
||||
|
||||
interface SheetContentProps extends DialogContentProps {
|
||||
class?: HTMLAttributes["class"]
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
class?: HTMLAttributes["class"];
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<SheetContentProps>(), {
|
||||
side: "right",
|
||||
})
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
side: "right",
|
||||
});
|
||||
const emits = defineEmits<DialogContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "side")
|
||||
const delegatedProps = reactiveOmit(props, "class", "side");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<SheetOverlay />
|
||||
<DialogContent
|
||||
data-slot="sheet-content"
|
||||
:class="cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
side === 'right'
|
||||
&& 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
||||
side === 'left'
|
||||
&& 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
||||
side === 'top'
|
||||
&& 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||
side === 'bottom'
|
||||
&& 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
||||
props.class)"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
>
|
||||
<slot />
|
||||
<DialogPortal>
|
||||
<SheetOverlay />
|
||||
<DialogContent
|
||||
data-slot="sheet-content"
|
||||
:class="
|
||||
cn(
|
||||
'bg-surface-container-lowest data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-2xl transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
side === 'right' &&
|
||||
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
||||
side === 'left' &&
|
||||
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
||||
side === 'top' &&
|
||||
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||
side === 'bottom' &&
|
||||
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
|
||||
>
|
||||
<X class="size-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
<DialogClose
|
||||
class="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
|
||||
>
|
||||
<X class="size-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogOverlayProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogOverlay } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { DialogOverlayProps } from "reka-ui";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { DialogOverlay } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
|
||||
const props = defineProps<
|
||||
DialogOverlayProps & { class?: HTMLAttributes["class"] }
|
||||
>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogOverlay
|
||||
data-slot="sheet-overlay"
|
||||
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</DialogOverlay>
|
||||
<DialogOverlay
|
||||
data-slot="sheet-overlay"
|
||||
:class="
|
||||
cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</DialogOverlay>
|
||||
</template>
|
||||
|
||||
@@ -1,60 +1,63 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cva } from "class-variance-authority"
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
export interface SidebarProps {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
class?: HTMLAttributes["class"]
|
||||
side?: "left" | "right";
|
||||
variant?: "sidebar" | "floating" | "inset";
|
||||
collapsible?: "offcanvas" | "icon" | "none";
|
||||
class?: HTMLAttributes["class"];
|
||||
}
|
||||
|
||||
export { default as Sidebar } from "./Sidebar.vue"
|
||||
export { default as SidebarContent } from "./SidebarContent.vue"
|
||||
export { default as SidebarFooter } from "./SidebarFooter.vue"
|
||||
export { default as SidebarGroup } from "./SidebarGroup.vue"
|
||||
export { default as SidebarGroupAction } from "./SidebarGroupAction.vue"
|
||||
export { default as SidebarGroupContent } from "./SidebarGroupContent.vue"
|
||||
export { default as SidebarGroupLabel } from "./SidebarGroupLabel.vue"
|
||||
export { default as SidebarHeader } from "./SidebarHeader.vue"
|
||||
export { default as SidebarInput } from "./SidebarInput.vue"
|
||||
export { default as SidebarInset } from "./SidebarInset.vue"
|
||||
export { default as SidebarMenu } from "./SidebarMenu.vue"
|
||||
export { default as SidebarMenuAction } from "./SidebarMenuAction.vue"
|
||||
export { default as SidebarMenuBadge } from "./SidebarMenuBadge.vue"
|
||||
export { default as SidebarMenuButton } from "./SidebarMenuButton.vue"
|
||||
export { default as SidebarMenuItem } from "./SidebarMenuItem.vue"
|
||||
export { default as SidebarMenuSkeleton } from "./SidebarMenuSkeleton.vue"
|
||||
export { default as SidebarMenuSub } from "./SidebarMenuSub.vue"
|
||||
export { default as SidebarMenuSubButton } from "./SidebarMenuSubButton.vue"
|
||||
export { default as SidebarMenuSubItem } from "./SidebarMenuSubItem.vue"
|
||||
export { default as SidebarProvider } from "./SidebarProvider.vue"
|
||||
export { default as SidebarRail } from "./SidebarRail.vue"
|
||||
export { default as SidebarSeparator } from "./SidebarSeparator.vue"
|
||||
export { default as SidebarTrigger } from "./SidebarTrigger.vue"
|
||||
export { default as Sidebar } from "./Sidebar.vue";
|
||||
export { default as SidebarContent } from "./SidebarContent.vue";
|
||||
export { default as SidebarFooter } from "./SidebarFooter.vue";
|
||||
export { default as SidebarGroup } from "./SidebarGroup.vue";
|
||||
export { default as SidebarGroupAction } from "./SidebarGroupAction.vue";
|
||||
export { default as SidebarGroupContent } from "./SidebarGroupContent.vue";
|
||||
export { default as SidebarGroupLabel } from "./SidebarGroupLabel.vue";
|
||||
export { default as SidebarHeader } from "./SidebarHeader.vue";
|
||||
export { default as SidebarInput } from "./SidebarInput.vue";
|
||||
export { default as SidebarInset } from "./SidebarInset.vue";
|
||||
export { default as SidebarMenu } from "./SidebarMenu.vue";
|
||||
export { default as SidebarMenuAction } from "./SidebarMenuAction.vue";
|
||||
export { default as SidebarMenuBadge } from "./SidebarMenuBadge.vue";
|
||||
export { default as SidebarMenuButton } from "./SidebarMenuButton.vue";
|
||||
export { default as SidebarMenuItem } from "./SidebarMenuItem.vue";
|
||||
export { default as SidebarMenuSkeleton } from "./SidebarMenuSkeleton.vue";
|
||||
export { default as SidebarMenuSub } from "./SidebarMenuSub.vue";
|
||||
export { default as SidebarMenuSubButton } from "./SidebarMenuSubButton.vue";
|
||||
export { default as SidebarMenuSubItem } from "./SidebarMenuSubItem.vue";
|
||||
export { default as SidebarProvider } from "./SidebarProvider.vue";
|
||||
export { default as SidebarRail } from "./SidebarRail.vue";
|
||||
export { default as SidebarSeparator } from "./SidebarSeparator.vue";
|
||||
export { default as SidebarTrigger } from "./SidebarTrigger.vue";
|
||||
|
||||
export { useSidebar } from "./utils"
|
||||
export { useSidebar } from "./utils";
|
||||
|
||||
export const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding,background-color,color] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-primary/12 data-[active=true]:font-semibold data-[active=true]:text-primary dark:data-[active=true]:text-primary-fixed data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
export type SidebarMenuButtonVariants = VariantProps<typeof sidebarMenuButtonVariants>
|
||||
export type SidebarMenuButtonVariants = VariantProps<
|
||||
typeof sidebarMenuButtonVariants
|
||||
>;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createContext } from "reka-ui"
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
import { createContext } from "reka-ui";
|
||||
import type { ComputedRef, Ref } from "vue";
|
||||
|
||||
export type SidebarContext = {
|
||||
state: ComputedRef<'expanded' | 'collapsed'>;
|
||||
state: ComputedRef<"expanded" | "collapsed">;
|
||||
open: Ref<boolean>;
|
||||
setOpen: (value: boolean) => void;
|
||||
isMobile: Ref<boolean>;
|
||||
@@ -11,11 +11,12 @@ export type SidebarContext = {
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
export const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
export const SIDEBAR_WIDTH = "16rem"
|
||||
export const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
export const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
export const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
export const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
export const SIDEBAR_WIDTH = "15.5rem";
|
||||
export const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
export const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
export const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
|
||||
export const [useSidebar, provideSidebarContext] = createContext<SidebarContext>("Sidebar")
|
||||
export const [useSidebar, provideSidebarContext] =
|
||||
createContext<SidebarContext>("Sidebar");
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import AppLayout from '@/layouts/app/AppSidebarLayout.vue';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
import type { AppLayoutProps } from '@/types';
|
||||
|
||||
type Props = {
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
};
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
withDefaults(defineProps<AppLayoutProps>(), {
|
||||
breadcrumbs: () => [],
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
|
||||
<slot />
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import AuthLayout from '@/layouts/auth/AuthSimpleLayout.vue';
|
||||
import AuthLayout from '@/layouts/auth/AuthCardLayout.vue';
|
||||
import type { AuthLayoutProps } from '@/types';
|
||||
|
||||
defineProps<{
|
||||
title?: string;
|
||||
description?: string;
|
||||
}>();
|
||||
defineProps<AuthLayoutProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -3,21 +3,17 @@ import AppContent from '@/components/AppContent.vue';
|
||||
import AppShell from '@/components/AppShell.vue';
|
||||
import AppSidebar from '@/components/AppSidebar.vue';
|
||||
import AppSidebarHeader from '@/components/AppSidebarHeader.vue';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
import type { AppLayoutProps } from '@/types';
|
||||
|
||||
type Props = {
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
};
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
withDefaults(defineProps<AppLayoutProps>(), {
|
||||
breadcrumbs: () => [],
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell variant="sidebar">
|
||||
<AppSidebar />
|
||||
<AppContent variant="sidebar" class="overflow-x-hidden">
|
||||
<AppSidebar :project="project" />
|
||||
<AppContent variant="sidebar" class="min-w-0 overflow-x-hidden">
|
||||
<AppSidebarHeader :breadcrumbs="breadcrumbs" />
|
||||
<slot />
|
||||
</AppContent>
|
||||
|
||||
@@ -9,41 +9,65 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { home } from '@/routes/index';
|
||||
import type { AuthLayoutProps } from '@/types';
|
||||
|
||||
defineProps<{
|
||||
title?: string;
|
||||
description?: string;
|
||||
}>();
|
||||
defineProps<AuthLayoutProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10"
|
||||
class="bg-background relative flex min-h-svh flex-col items-center justify-center overflow-hidden p-5 sm:p-8"
|
||||
>
|
||||
<div class="flex w-full max-w-md flex-col gap-6">
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="bg-primary/12 absolute -left-24 -top-24 size-80 rounded-full blur-3xl"
|
||||
/>
|
||||
<div
|
||||
class="bg-secondary/55 absolute -bottom-36 -right-20 size-[28rem] rounded-full blur-3xl"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-[linear-gradient(to_right,var(--outline-variant)_1px,transparent_1px),linear-gradient(to_bottom,var(--outline-variant)_1px,transparent_1px)] bg-[size:48px_48px] opacity-[0.08]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="relative flex w-full max-w-[28rem] flex-col gap-7">
|
||||
<Link
|
||||
:href="home()"
|
||||
class="flex items-center gap-2 self-center font-medium"
|
||||
class="group flex items-center gap-3 self-center font-semibold"
|
||||
>
|
||||
<div class="flex h-9 w-9 items-center justify-center">
|
||||
<AppLogoIcon
|
||||
class="size-9 fill-current text-black dark:text-white"
|
||||
/>
|
||||
<div
|
||||
class="bg-primary text-primary-foreground flex size-10 items-center justify-center rounded-lg shadow-[0_10px_28px_rgba(34,197,94,0.22)] transition-transform group-hover:-translate-y-0.5"
|
||||
>
|
||||
<AppLogoIcon class="size-5 fill-current" />
|
||||
</div>
|
||||
<span class="text-lg tracking-tight">KanbanGit</span>
|
||||
</Link>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<Card class="rounded-xl">
|
||||
<CardHeader class="px-10 pb-0 pt-8 text-center">
|
||||
<CardTitle class="text-xl">{{ title }}</CardTitle>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Card
|
||||
class="border-outline-variant/80 rounded-xl py-0 shadow-[0_24px_70px_rgba(10,20,35,0.12)] hover:shadow-[0_24px_70px_rgba(10,20,35,0.12)]"
|
||||
>
|
||||
<CardHeader
|
||||
class="px-6 pb-0 pt-7 text-center sm:px-9 sm:pt-9"
|
||||
>
|
||||
<CardTitle
|
||||
class="text-2xl font-semibold tracking-[-0.025em]"
|
||||
>{{ title }}</CardTitle
|
||||
>
|
||||
<CardDescription>
|
||||
{{ description }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="px-10 py-8">
|
||||
<CardContent class="px-6 pb-7 pt-7 sm:px-9 sm:pb-9">
|
||||
<slot />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p class="text-muted-foreground text-center text-xs">
|
||||
Open source project management, hosted on your terms.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import {
|
||||
GitBranch,
|
||||
KeyRound,
|
||||
Palette,
|
||||
ShieldCheck,
|
||||
UserRound,
|
||||
} from 'lucide-vue-next';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -16,22 +23,27 @@ const sidebarNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Profile',
|
||||
href: editProfile(),
|
||||
icon: UserRound,
|
||||
},
|
||||
{
|
||||
title: 'Security',
|
||||
href: editSecurity(),
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
title: 'Git connections',
|
||||
href: editGitConnections(),
|
||||
icon: GitBranch,
|
||||
},
|
||||
{
|
||||
title: 'Applications',
|
||||
href: editApplications(),
|
||||
icon: KeyRound,
|
||||
},
|
||||
{
|
||||
title: 'Appearance',
|
||||
href: editAppearance(),
|
||||
icon: Palette,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -39,16 +51,16 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 py-6">
|
||||
<div class="app-page-narrow">
|
||||
<Heading
|
||||
title="Settings"
|
||||
description="Manage your profile, security, and Git integration settings"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col lg:flex-row lg:space-x-12">
|
||||
<aside class="w-full max-w-xl lg:w-48">
|
||||
<div class="grid gap-6 lg:grid-cols-[14rem_minmax(0,1fr)]">
|
||||
<aside class="w-full">
|
||||
<nav
|
||||
class="flex flex-col space-x-0 space-y-1"
|
||||
class="border-outline-variant bg-surface-container-lowest shadow-xs flex gap-1 overflow-x-auto rounded-xl border p-2 lg:flex-col"
|
||||
aria-label="Settings"
|
||||
>
|
||||
<Button
|
||||
@@ -56,8 +68,11 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
|
||||
:key="toUrl(item.href)"
|
||||
variant="ghost"
|
||||
:class="[
|
||||
'w-full justify-start',
|
||||
{ 'bg-muted': isCurrentOrParentUrl(item.href) },
|
||||
'shrink-0 justify-start lg:w-full',
|
||||
{
|
||||
'bg-primary/12 text-on-primary-container hover:bg-primary/16 dark:text-primary-fixed':
|
||||
isCurrentOrParentUrl(item.href),
|
||||
},
|
||||
]"
|
||||
as-child
|
||||
>
|
||||
@@ -69,10 +84,12 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<Separator class="my-6 lg:hidden" />
|
||||
<Separator class="lg:hidden" />
|
||||
|
||||
<div class="flex-1 md:max-w-2xl">
|
||||
<section class="max-w-xl space-y-12">
|
||||
<div class="min-w-0 flex-1">
|
||||
<section
|
||||
class="border-outline-variant bg-surface-container-lowest shadow-xs space-y-12 rounded-xl border p-5 sm:p-7"
|
||||
>
|
||||
<slot />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -96,6 +96,226 @@ export const syncAcceptanceCriteriaDescendantCheckedState = (
|
||||
});
|
||||
};
|
||||
|
||||
export const acceptanceCriteriaSubtreeEnd = (
|
||||
items: AcceptanceCriteriaModel,
|
||||
index: number,
|
||||
): number => {
|
||||
const indent = items[index]?.indent;
|
||||
|
||||
if (indent === undefined) {
|
||||
return index;
|
||||
}
|
||||
|
||||
let end = index + 1;
|
||||
|
||||
while (end < items.length && items[end].indent > indent) {
|
||||
end++;
|
||||
}
|
||||
|
||||
return end;
|
||||
};
|
||||
|
||||
export const previousAcceptanceCriteriaSiblingIndex = (
|
||||
items: AcceptanceCriteriaModel,
|
||||
index: number,
|
||||
): number | null => {
|
||||
const indent = items[index]?.indent;
|
||||
|
||||
if (indent === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (
|
||||
let candidateIndex = index - 1;
|
||||
candidateIndex >= 0;
|
||||
candidateIndex--
|
||||
) {
|
||||
const candidateIndent = items[candidateIndex].indent;
|
||||
|
||||
if (candidateIndent < indent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (candidateIndent === indent) {
|
||||
return candidateIndex;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const nextAcceptanceCriteriaSiblingIndex = (
|
||||
items: AcceptanceCriteriaModel,
|
||||
index: number,
|
||||
): number | null => {
|
||||
const indent = items[index]?.indent;
|
||||
const candidateIndex = acceptanceCriteriaSubtreeEnd(items, index);
|
||||
|
||||
if (
|
||||
indent === undefined ||
|
||||
candidateIndex >= items.length ||
|
||||
items[candidateIndex].indent !== indent
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return candidateIndex;
|
||||
};
|
||||
|
||||
export const addAcceptanceCriterionAfter = (
|
||||
items: AcceptanceCriteriaModel,
|
||||
index: number | null,
|
||||
): { index: number; items: AcceptanceCriteriaModel } => {
|
||||
const nextItems = items.map((item) => ({ ...item }));
|
||||
const insertionIndex =
|
||||
index === null
|
||||
? nextItems.length
|
||||
: acceptanceCriteriaSubtreeEnd(nextItems, index);
|
||||
const indent = index === null ? 0 : (nextItems[index]?.indent ?? 0);
|
||||
|
||||
nextItems.splice(insertionIndex, 0, {
|
||||
checked: false,
|
||||
indent,
|
||||
text: '',
|
||||
});
|
||||
|
||||
return {
|
||||
index: insertionIndex,
|
||||
items: nextItems,
|
||||
};
|
||||
};
|
||||
|
||||
export const indentAcceptanceCriterionSubtree = (
|
||||
items: AcceptanceCriteriaModel,
|
||||
index: number,
|
||||
): AcceptanceCriteriaModel => {
|
||||
const previousSiblingIndex = previousAcceptanceCriteriaSiblingIndex(
|
||||
items,
|
||||
index,
|
||||
);
|
||||
const item = items[index];
|
||||
|
||||
if (
|
||||
item === undefined ||
|
||||
item.indent >= MAX_INDENT_LEVEL ||
|
||||
previousSiblingIndex === null
|
||||
) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const subtreeEnd = acceptanceCriteriaSubtreeEnd(items, index);
|
||||
|
||||
return items.map((criterion, criterionIndex) => ({
|
||||
...criterion,
|
||||
indent:
|
||||
criterionIndex >= index && criterionIndex < subtreeEnd
|
||||
? criterion.indent + 1
|
||||
: criterion.indent,
|
||||
}));
|
||||
};
|
||||
|
||||
export const outdentAcceptanceCriterionSubtree = (
|
||||
items: AcceptanceCriteriaModel,
|
||||
index: number,
|
||||
): AcceptanceCriteriaModel => {
|
||||
const item = items[index];
|
||||
|
||||
if (item === undefined || item.indent === 0) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const subtreeEnd = acceptanceCriteriaSubtreeEnd(items, index);
|
||||
|
||||
return items.map((criterion, criterionIndex) => ({
|
||||
...criterion,
|
||||
indent:
|
||||
criterionIndex >= index && criterionIndex < subtreeEnd
|
||||
? criterion.indent - 1
|
||||
: criterion.indent,
|
||||
}));
|
||||
};
|
||||
|
||||
export const removeAcceptanceCriterion = (
|
||||
items: AcceptanceCriteriaModel,
|
||||
index: number,
|
||||
): AcceptanceCriteriaModel => {
|
||||
const item = items[index];
|
||||
|
||||
if (item === undefined) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const subtreeEnd = acceptanceCriteriaSubtreeEnd(items, index);
|
||||
|
||||
return items
|
||||
.filter((_criterion, criterionIndex) => criterionIndex !== index)
|
||||
.map((criterion, criterionIndex) => {
|
||||
const originalIndex =
|
||||
criterionIndex >= index ? criterionIndex + 1 : criterionIndex;
|
||||
|
||||
return {
|
||||
...criterion,
|
||||
indent:
|
||||
originalIndex > index && originalIndex < subtreeEnd
|
||||
? criterion.indent - 1
|
||||
: criterion.indent,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const moveAcceptanceCriterionSubtree = (
|
||||
items: AcceptanceCriteriaModel,
|
||||
index: number,
|
||||
direction: 'down' | 'up',
|
||||
): AcceptanceCriteriaModel => {
|
||||
const subtreeEnd = acceptanceCriteriaSubtreeEnd(items, index);
|
||||
const currentSubtree = items
|
||||
.slice(index, subtreeEnd)
|
||||
.map((item) => ({ ...item }));
|
||||
|
||||
if (direction === 'up') {
|
||||
const previousSiblingIndex = previousAcceptanceCriteriaSiblingIndex(
|
||||
items,
|
||||
index,
|
||||
);
|
||||
|
||||
if (previousSiblingIndex === null) {
|
||||
return items;
|
||||
}
|
||||
|
||||
return [
|
||||
...items
|
||||
.slice(0, previousSiblingIndex)
|
||||
.map((item) => ({ ...item })),
|
||||
...currentSubtree,
|
||||
...items
|
||||
.slice(previousSiblingIndex, index)
|
||||
.map((item) => ({ ...item })),
|
||||
...items.slice(subtreeEnd).map((item) => ({ ...item })),
|
||||
];
|
||||
}
|
||||
|
||||
const nextSiblingIndex = nextAcceptanceCriteriaSiblingIndex(items, index);
|
||||
|
||||
if (nextSiblingIndex === null) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const nextSubtreeEnd = acceptanceCriteriaSubtreeEnd(
|
||||
items,
|
||||
nextSiblingIndex,
|
||||
);
|
||||
|
||||
return [
|
||||
...items.slice(0, index).map((item) => ({ ...item })),
|
||||
...items
|
||||
.slice(nextSiblingIndex, nextSubtreeEnd)
|
||||
.map((item) => ({ ...item })),
|
||||
...currentSubtree,
|
||||
...items.slice(nextSubtreeEnd).map((item) => ({ ...item })),
|
||||
];
|
||||
};
|
||||
|
||||
export const acceptanceCriteriaToEditorState = (
|
||||
value: AcceptanceCriteriaModel | null | undefined,
|
||||
): AcceptanceCriteriaEditorState => {
|
||||
@@ -121,9 +341,7 @@ const parseChecklistLine = (
|
||||
return null;
|
||||
}
|
||||
|
||||
const indentLevel = clampIndent(
|
||||
Math.floor(match.groups.indent.length / 2),
|
||||
);
|
||||
const indentLevel = clampIndent(Math.floor(match.groups.indent.length / 2));
|
||||
let content = match.groups.content.trim();
|
||||
let checked = false;
|
||||
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
|
||||
import { Head, Link, usePage } from '@inertiajs/vue3';
|
||||
import {
|
||||
ArrowRight,
|
||||
FolderKanban,
|
||||
Layers3,
|
||||
Plus,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
} from 'lucide-vue-next';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { dashboard } from '@/routes/index';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
import { index as adminUsers } from '@/routes/admin/users';
|
||||
import { index as projectTemplates } from '@/routes/project-templates';
|
||||
import {
|
||||
create as projectCreate,
|
||||
index as projectsIndex,
|
||||
} from '@/routes/projects';
|
||||
import { index as teamsIndex } from '@/routes/teams';
|
||||
import type { BreadcrumbItem, NavItem } from '@/types';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
@@ -11,37 +26,131 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
href: dashboard(),
|
||||
},
|
||||
];
|
||||
|
||||
const page = usePage();
|
||||
|
||||
const quickLinks = computed<NavItem[]>(() => {
|
||||
const items: NavItem[] = [
|
||||
{
|
||||
title: 'Browse projects',
|
||||
href: projectsIndex(),
|
||||
icon: FolderKanban,
|
||||
},
|
||||
];
|
||||
|
||||
if (page.props.auth.permissions.projectCreate) {
|
||||
items.push(
|
||||
{ title: 'Create a project', href: projectCreate(), icon: Plus },
|
||||
{
|
||||
title: 'Project templates',
|
||||
href: projectTemplates(),
|
||||
icon: Layers3,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (page.props.auth.permissions.globalAdmin) {
|
||||
items.push({
|
||||
title: 'Manage users',
|
||||
href: adminUsers(),
|
||||
icon: ShieldCheck,
|
||||
});
|
||||
}
|
||||
|
||||
if (page.props.auth.permissions.canManageTeams) {
|
||||
items.push({ title: 'Manage teams', href: teamsIndex(), icon: Users });
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Dashboard" />
|
||||
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<div
|
||||
class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4"
|
||||
>
|
||||
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<div
|
||||
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
|
||||
>
|
||||
<PlaceholderPattern />
|
||||
</div>
|
||||
<div
|
||||
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
|
||||
>
|
||||
<PlaceholderPattern />
|
||||
</div>
|
||||
<div
|
||||
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
|
||||
>
|
||||
<PlaceholderPattern />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="border-sidebar-border/70 dark:border-sidebar-border relative min-h-[100vh] flex-1 rounded-xl border md:min-h-min"
|
||||
<div class="app-page">
|
||||
<section
|
||||
class="border-outline-variant bg-surface-container-lowest shadow-xs relative overflow-hidden rounded-xl border px-5 py-8 sm:px-8 sm:py-10"
|
||||
>
|
||||
<PlaceholderPattern />
|
||||
</div>
|
||||
<div
|
||||
class="bg-primary/12 absolute -right-16 -top-20 size-64 rounded-full blur-3xl"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="relative max-w-3xl">
|
||||
<div class="eyebrow">Your workspace</div>
|
||||
<h1
|
||||
class="mt-3 text-3xl font-semibold tracking-[-0.035em] sm:text-4xl"
|
||||
>
|
||||
Welcome back, {{ $page.props.auth.user.name }}.
|
||||
</h1>
|
||||
<p
|
||||
class="text-muted-foreground mt-3 max-w-2xl text-sm leading-6 sm:text-base"
|
||||
>
|
||||
Pick up a project, shape its workflow, or manage the
|
||||
people who make the work happen.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="mb-4 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<div class="eyebrow">Shortcuts</div>
|
||||
<h2 class="mt-1 text-xl font-semibold tracking-tight">
|
||||
Where do you want to go?
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<Link
|
||||
v-for="item in quickLinks"
|
||||
:key="item.title"
|
||||
:href="item.href"
|
||||
class="border-outline-variant bg-surface-container-lowest shadow-xs hover:border-primary/45 hover:shadow-kanban-card group flex min-h-36 flex-col justify-between rounded-xl border p-5 transition-all hover:-translate-y-0.5"
|
||||
>
|
||||
<div
|
||||
class="bg-primary/10 text-primary flex size-10 items-center justify-center rounded-lg"
|
||||
>
|
||||
<component :is="item.icon" class="size-5" />
|
||||
</div>
|
||||
<div
|
||||
class="mt-6 flex items-center justify-between gap-4"
|
||||
>
|
||||
<span class="font-semibold">{{ item.title }}</span>
|
||||
<ArrowRight
|
||||
class="text-muted-foreground group-hover:text-primary size-4 transition-transform group-hover:translate-x-1"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="border-outline-variant bg-surface-container-low rounded-xl border p-5 sm:p-6"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div>
|
||||
<h2 class="font-semibold">
|
||||
Built for focused delivery
|
||||
</h2>
|
||||
<p class="text-muted-foreground mt-1 text-sm">
|
||||
Projects keep boards, backlog, metadata,
|
||||
repositories, and notifications together.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
:href="projectsIndex()"
|
||||
class="text-primary inline-flex items-center gap-2 text-sm font-semibold hover:underline"
|
||||
>
|
||||
View all projects
|
||||
<ArrowRight class="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
+342
-408
@@ -1,436 +1,370 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
Code2,
|
||||
GitBranch,
|
||||
LayoutDashboard,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Workflow,
|
||||
} from 'lucide-vue-next';
|
||||
import AppLogoIcon from '@/components/AppLogoIcon.vue';
|
||||
import { dashboard, login } from '@/routes/index';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Welcome">
|
||||
<link rel="preconnect" href="https://rsms.me/" />
|
||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
|
||||
</Head>
|
||||
<div
|
||||
class="flex min-h-screen flex-col items-center bg-[#FDFDFC] p-6 text-[#1b1b18] lg:justify-center lg:p-8 dark:bg-[#0a0a0a]"
|
||||
>
|
||||
<Head title="Open source Kanban for Git teams" />
|
||||
|
||||
<div class="bg-background text-foreground min-h-screen overflow-hidden">
|
||||
<header
|
||||
class="not-has-[nav]:hidden mb-6 w-full max-w-[335px] text-sm lg:max-w-4xl"
|
||||
class="border-outline-variant/70 bg-background/90 sticky top-0 z-30 border-b backdrop-blur-xl"
|
||||
>
|
||||
<nav class="flex items-center justify-end gap-4">
|
||||
<div
|
||||
class="mx-auto flex h-16 max-w-7xl items-center justify-between gap-6 px-4 sm:px-6 lg:px-8"
|
||||
>
|
||||
<Link
|
||||
v-if="$page.props.auth.user"
|
||||
:href="dashboard()"
|
||||
class="inline-block rounded-sm border border-[#19140035] px-5 py-1.5 text-sm leading-normal text-[#1b1b18] hover:border-[#1915014a] dark:border-[#3E3E3A] dark:text-[#EDEDEC] dark:hover:border-[#62605b]"
|
||||
:href="$page.props.auth.user ? dashboard() : login()"
|
||||
class="flex items-center gap-3 font-semibold tracking-tight"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<template v-else>
|
||||
<Link
|
||||
:href="login()"
|
||||
class="inline-block rounded-sm border border-transparent px-5 py-1.5 text-sm leading-normal text-[#1b1b18] hover:border-[#19140035] dark:text-[#EDEDEC] dark:hover:border-[#3E3E3A]"
|
||||
<span
|
||||
class="bg-primary text-primary-foreground flex size-9 items-center justify-center rounded-lg shadow-[0_8px_22px_rgba(34,197,94,0.2)]"
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
</template>
|
||||
</nav>
|
||||
<AppLogoIcon class="size-5 fill-current" />
|
||||
</span>
|
||||
<span>KanbanGit</span>
|
||||
</Link>
|
||||
|
||||
<nav
|
||||
class="text-muted-foreground hidden items-center gap-7 text-sm md:flex"
|
||||
aria-label="Primary navigation"
|
||||
>
|
||||
<a
|
||||
href="#product"
|
||||
class="hover:text-foreground transition-colors"
|
||||
>Product</a
|
||||
>
|
||||
<a
|
||||
href="#self-hosted"
|
||||
class="hover:text-foreground transition-colors"
|
||||
>Self-hosted</a
|
||||
>
|
||||
<a
|
||||
href="https://git.twyco.de/OpenSource/KanbanGit"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-foreground transition-colors"
|
||||
>
|
||||
Repository
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<Link
|
||||
:href="$page.props.auth.user ? dashboard() : login()"
|
||||
class="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-9 items-center justify-center gap-2 rounded-md px-4 text-sm font-semibold shadow-[0_8px_22px_rgba(34,197,94,0.18)] transition-all hover:-translate-y-px"
|
||||
>
|
||||
{{ $page.props.auth.user ? 'Open dashboard' : 'Log in' }}
|
||||
<ArrowRight class="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
class="duration-750 starting:opacity-0 flex w-full items-center justify-center opacity-100 transition-opacity lg:grow"
|
||||
>
|
||||
<main
|
||||
class="flex w-full max-w-[335px] flex-col-reverse overflow-hidden rounded-lg lg:max-w-4xl lg:flex-row"
|
||||
|
||||
<main>
|
||||
<section class="relative">
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="bg-primary/12 absolute left-[8%] top-12 size-80 rounded-full blur-3xl"
|
||||
/>
|
||||
<div
|
||||
class="bg-secondary/65 absolute right-[4%] top-28 size-[28rem] rounded-full blur-3xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative mx-auto grid max-w-7xl gap-12 px-4 pb-16 pt-20 sm:px-6 sm:pt-24 lg:grid-cols-[0.9fr_1.1fr] lg:items-center lg:px-8 lg:pb-24 lg:pt-32"
|
||||
>
|
||||
<div class="max-w-2xl">
|
||||
<div
|
||||
class="border-primary/20 bg-primary/10 text-on-primary-container dark:text-primary-fixed inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold"
|
||||
>
|
||||
<GitBranch class="size-3.5" />
|
||||
Open source and built for Git workflows
|
||||
</div>
|
||||
<h1
|
||||
class="mt-6 text-4xl font-semibold tracking-[-0.05em] sm:text-6xl sm:leading-[1.04]"
|
||||
>
|
||||
Move work forward without giving up control.
|
||||
</h1>
|
||||
<p
|
||||
class="text-muted-foreground mt-6 max-w-xl text-base leading-7 sm:text-lg"
|
||||
>
|
||||
KanbanGit brings projects, adaptable boards, ticket
|
||||
metadata, repository connections, and team access
|
||||
into one focused workspace you can host yourself.
|
||||
</p>
|
||||
<div class="mt-8 flex flex-wrap gap-3">
|
||||
<Link
|
||||
:href="
|
||||
$page.props.auth.user
|
||||
? dashboard()
|
||||
: login()
|
||||
"
|
||||
class="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center gap-2 rounded-md px-5 text-sm font-semibold shadow-[0_10px_28px_rgba(34,197,94,0.22)] transition-all hover:-translate-y-px"
|
||||
>
|
||||
{{
|
||||
$page.props.auth.user
|
||||
? 'Go to workspace'
|
||||
: 'Log in to your workspace'
|
||||
}}
|
||||
<ArrowRight class="size-4" />
|
||||
</Link>
|
||||
<a
|
||||
href="https://git.twyco.de/OpenSource/KanbanGit"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="border-outline-variant bg-surface-container-lowest shadow-xs hover:bg-surface-container-low inline-flex h-11 items-center gap-2 rounded-md border px-5 text-sm font-semibold transition-colors"
|
||||
>
|
||||
<Code2 class="size-4" />
|
||||
View source
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="border-outline-variant bg-surface-container-lowest relative rounded-xl border p-3 shadow-[0_30px_90px_rgba(10,20,35,0.16)] sm:p-4"
|
||||
>
|
||||
<div
|
||||
class="border-outline-variant bg-surface-container-low flex items-center gap-2 rounded-lg border px-4 py-3"
|
||||
>
|
||||
<span class="bg-destructive size-2 rounded-full" />
|
||||
<span class="size-2 rounded-full bg-amber-400" />
|
||||
<span class="bg-primary size-2 rounded-full" />
|
||||
<div
|
||||
class="bg-outline-variant/50 ml-3 h-2 w-28 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
<div
|
||||
v-for="(column, index) in [
|
||||
'Ready',
|
||||
'In progress',
|
||||
'Review',
|
||||
]"
|
||||
:key="column"
|
||||
class="bg-surface-container-low rounded-lg p-3"
|
||||
>
|
||||
<div
|
||||
class="mb-3 flex items-center justify-between gap-2"
|
||||
>
|
||||
<span class="text-xs font-semibold">{{
|
||||
column
|
||||
}}</span>
|
||||
<span
|
||||
class="bg-surface-container-highest text-muted-foreground rounded-full px-2 py-0.5 text-[0.65rem]"
|
||||
>{{ index + 2 }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<article
|
||||
v-for="ticket in index === 1 ? 3 : 2"
|
||||
:key="ticket"
|
||||
class="border-outline-variant bg-surface-container-lowest shadow-xs rounded-lg border p-3"
|
||||
>
|
||||
<div
|
||||
class="text-primary text-[0.65rem] font-bold"
|
||||
>
|
||||
KG-{{ index * 3 + ticket + 10 }}
|
||||
</div>
|
||||
<div
|
||||
class="bg-foreground/75 mt-2 h-2 w-4/5 rounded-full"
|
||||
/>
|
||||
<div
|
||||
class="bg-outline-variant/65 mt-2 h-2 w-2/3 rounded-full"
|
||||
/>
|
||||
<div
|
||||
class="mt-4 flex items-center justify-between"
|
||||
>
|
||||
<span
|
||||
class="bg-primary/12 text-primary rounded-full px-2 py-1 text-[0.6rem] font-semibold"
|
||||
>Feature</span
|
||||
>
|
||||
<span
|
||||
class="bg-secondary text-secondary-foreground flex size-6 items-center justify-center rounded-full text-[0.6rem] font-bold"
|
||||
>KG</span
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="product"
|
||||
class="border-outline-variant bg-surface-container-low border-y"
|
||||
>
|
||||
<div
|
||||
class="flex-1 rounded-bl-lg rounded-br-lg bg-white p-6 pb-12 text-[13px] leading-[20px] shadow-[inset_0px_0px_0px_1px_rgba(26,26,0,0.16)] lg:rounded-br-none lg:rounded-tl-lg lg:p-20 dark:bg-[#161615] dark:text-[#EDEDEC] dark:shadow-[inset_0px_0px_0px_1px_#fffaed2d]"
|
||||
class="mx-auto max-w-7xl px-4 py-20 sm:px-6 lg:px-8 lg:py-24"
|
||||
>
|
||||
<h1 class="mb-1 font-medium">Let's get started</h1>
|
||||
<p class="mb-2 text-[#706f6c] dark:text-[#A1A09A]">
|
||||
Laravel has an incredibly rich ecosystem. <br />We
|
||||
suggest starting with the following.
|
||||
<div class="max-w-2xl">
|
||||
<div class="eyebrow">One coherent workspace</div>
|
||||
<h2
|
||||
class="mt-3 text-3xl font-semibold tracking-[-0.035em] sm:text-4xl"
|
||||
>
|
||||
Everything around the board stays connected.
|
||||
</h2>
|
||||
<p class="text-muted-foreground mt-4 leading-7">
|
||||
Keep the workflow visual while preserving the
|
||||
project structure your team needs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-10 grid gap-4 md:grid-cols-3">
|
||||
<article
|
||||
v-for="feature in [
|
||||
{
|
||||
icon: LayoutDashboard,
|
||||
title: 'Flexible workflows',
|
||||
text: 'Shape board columns, backlog states, ticket types, priorities, and custom metadata per project.',
|
||||
},
|
||||
{
|
||||
icon: GitBranch,
|
||||
title: 'Repository context',
|
||||
text: 'Connect repositories and keep ticket branches and pull requests visible beside the work.',
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
title: 'Controlled access',
|
||||
text: 'Manage users, teams, and project permissions without turning the interface into an admin console.',
|
||||
},
|
||||
]"
|
||||
:key="feature.title"
|
||||
class="border-outline-variant bg-surface-container-lowest shadow-xs rounded-xl border p-6"
|
||||
>
|
||||
<div
|
||||
class="bg-primary/10 text-primary flex size-11 items-center justify-center rounded-lg"
|
||||
>
|
||||
<component :is="feature.icon" class="size-5" />
|
||||
</div>
|
||||
<h3 class="mt-6 text-lg font-semibold">
|
||||
{{ feature.title }}
|
||||
</h3>
|
||||
<p
|
||||
class="text-muted-foreground mt-2 text-sm leading-6"
|
||||
>
|
||||
{{ feature.text }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="self-hosted"
|
||||
class="mx-auto grid max-w-7xl gap-10 px-4 py-20 sm:px-6 lg:grid-cols-2 lg:items-center lg:px-8 lg:py-28"
|
||||
>
|
||||
<div>
|
||||
<div class="eyebrow">Run it on your infrastructure</div>
|
||||
<h2
|
||||
class="mt-3 text-3xl font-semibold tracking-[-0.035em] sm:text-4xl"
|
||||
>
|
||||
Self-hosted by design.
|
||||
</h2>
|
||||
<p class="text-muted-foreground mt-5 max-w-xl leading-7">
|
||||
Keep project data close, inspect the code, and evolve
|
||||
the installation with your own deployment practices.
|
||||
</p>
|
||||
<ul class="mb-4 flex flex-col lg:mb-6">
|
||||
<ul class="mt-7 space-y-3 text-sm">
|
||||
<li
|
||||
class="relative flex items-center gap-4 py-2 before:absolute before:bottom-0 before:left-[0.4rem] before:top-1/2 before:border-l before:border-[#e3e3e0] dark:before:border-[#3E3E3A]"
|
||||
v-for="item in [
|
||||
'Open source codebase',
|
||||
'No external product dependency',
|
||||
'Designed around your Git repositories',
|
||||
]"
|
||||
:key="item"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<span
|
||||
class="relative bg-white py-1 dark:bg-[#161615]"
|
||||
>
|
||||
<span
|
||||
class="flex h-3.5 w-3.5 items-center justify-center rounded-full border border-[#e3e3e0] bg-[#FDFDFC] shadow-[0px_0px_1px_0px_rgba(0,0,0,0.03),0px_1px_2px_0px_rgba(0,0,0,0.06)] dark:border-[#3E3E3A] dark:bg-[#161615]"
|
||||
>
|
||||
<span
|
||||
class="h-1.5 w-1.5 rounded-full bg-[#dbdbd7] dark:bg-[#3E3E3A]"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
Read the
|
||||
<a
|
||||
href="https://laravel.com/docs"
|
||||
target="_blank"
|
||||
class="ml-1 inline-flex items-center space-x-1 font-medium text-[#f53003] underline underline-offset-4 dark:text-[#FF4433]"
|
||||
>
|
||||
<span>Documentation</span>
|
||||
<svg
|
||||
width="10"
|
||||
height="11"
|
||||
viewBox="0 0 10 11"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-2.5 w-2.5"
|
||||
>
|
||||
<path
|
||||
d="M7.70833 6.95834V2.79167H3.54167M2.5 8L7.5 3.00001"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
class="relative flex items-center gap-4 py-2 before:absolute before:bottom-1/2 before:left-[0.4rem] before:top-0 before:border-l before:border-[#e3e3e0] dark:before:border-[#3E3E3A]"
|
||||
>
|
||||
<span
|
||||
class="relative bg-white py-1 dark:bg-[#161615]"
|
||||
>
|
||||
<span
|
||||
class="flex h-3.5 w-3.5 items-center justify-center rounded-full border border-[#e3e3e0] bg-[#FDFDFC] shadow-[0px_0px_1px_0px_rgba(0,0,0,0.03),0px_1px_2px_0px_rgba(0,0,0,0.06)] dark:border-[#3E3E3A] dark:bg-[#161615]"
|
||||
>
|
||||
<span
|
||||
class="h-1.5 w-1.5 rounded-full bg-[#dbdbd7] dark:bg-[#3E3E3A]"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
Watch video tutorials at
|
||||
<a
|
||||
href="https://laracasts.com"
|
||||
target="_blank"
|
||||
class="ml-1 inline-flex items-center space-x-1 font-medium text-[#f53003] underline underline-offset-4 dark:text-[#FF4433]"
|
||||
>
|
||||
<span>Laracasts</span>
|
||||
<svg
|
||||
width="10"
|
||||
height="11"
|
||||
viewBox="0 0 10 11"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-2.5 w-2.5"
|
||||
>
|
||||
<path
|
||||
d="M7.70833 6.95834V2.79167H3.54167M2.5 8L7.5 3.00001"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="flex gap-3 text-sm leading-normal">
|
||||
<li>
|
||||
<a
|
||||
href="https://cloud.laravel.com"
|
||||
target="_blank"
|
||||
class="inline-block rounded-sm border border-black bg-[#1b1b18] px-5 py-1.5 text-sm leading-normal text-white hover:border-black hover:bg-black dark:border-[#eeeeec] dark:bg-[#eeeeec] dark:text-[#1C1C1A] dark:hover:border-white dark:hover:bg-white"
|
||||
>
|
||||
Deploy now
|
||||
</a>
|
||||
class="bg-primary/12 text-primary flex size-6 items-center justify-center rounded-full"
|
||||
><Check class="size-3.5"
|
||||
/></span>
|
||||
{{ item }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative -mb-px aspect-[335/364] w-full shrink-0 overflow-hidden rounded-t-lg bg-[#fff2f2] lg:-ml-px lg:mb-0 lg:aspect-auto lg:w-[438px] lg:rounded-r-lg lg:rounded-t-none dark:bg-[#1D0002]"
|
||||
class="border-outline-variant bg-surface-container-lowest overflow-hidden rounded-xl border shadow-[0_24px_70px_rgba(10,20,35,0.12)]"
|
||||
>
|
||||
<!-- Laravel Logo -->
|
||||
<svg
|
||||
class="duration-750 starting:opacity-0 motion-safe:starting:translate-y-6 w-full max-w-none translate-y-0 text-[#F53003] opacity-100 transition-all dark:text-[#F61500]"
|
||||
viewBox="0 0 438 104"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M17.2036 -3H0V102.197H49.5189V86.7187H17.2036V-3Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M110.256 41.6337C108.061 38.1275 104.945 35.3731 100.905 33.3681C96.8667 31.3647 92.8016 30.3618 88.7131 30.3618C83.4247 30.3618 78.5885 31.3389 74.201 33.2923C69.8111 35.2456 66.0474 37.928 62.9059 41.3333C59.7643 44.7401 57.3198 48.6726 55.5754 53.1293C53.8287 57.589 52.9572 62.274 52.9572 67.1813C52.9572 72.1925 53.8287 76.8995 55.5754 81.3069C57.3191 85.7173 59.7636 89.6241 62.9059 93.0293C66.0474 96.4361 69.8119 99.1155 74.201 101.069C78.5885 103.022 83.4247 103.999 88.7131 103.999C92.8016 103.999 96.8667 102.997 100.905 100.994C104.945 98.9911 108.061 96.2359 110.256 92.7282V102.195H126.563V32.1642H110.256V41.6337ZM108.76 75.7472C107.762 78.4531 106.366 80.8078 104.572 82.8112C102.776 84.8161 100.606 86.4183 98.0637 87.6206C95.5202 88.823 92.7004 89.4238 89.6103 89.4238C86.5178 89.4238 83.7252 88.823 81.2324 87.6206C78.7388 86.4183 76.5949 84.8161 74.7998 82.8112C73.004 80.8078 71.6319 78.4531 70.6856 75.7472C69.7356 73.0421 69.2644 70.1868 69.2644 67.1821C69.2644 64.1758 69.7356 61.3205 70.6856 58.6154C71.6319 55.9102 73.004 53.5571 74.7998 51.5522C76.5949 49.5495 78.738 47.9451 81.2324 46.7427C83.7252 45.5404 86.5178 44.9396 89.6103 44.9396C92.7012 44.9396 95.5202 45.5404 98.0637 46.7427C100.606 47.9451 102.776 49.5487 104.572 51.5522C106.367 53.5571 107.762 55.9102 108.76 58.6154C109.756 61.3205 110.256 64.1758 110.256 67.1821C110.256 70.1868 109.756 73.0421 108.76 75.7472Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M242.805 41.6337C240.611 38.1275 237.494 35.3731 233.455 33.3681C229.416 31.3647 225.351 30.3618 221.262 30.3618C215.974 30.3618 211.138 31.3389 206.75 33.2923C202.36 35.2456 198.597 37.928 195.455 41.3333C192.314 44.7401 189.869 48.6726 188.125 53.1293C186.378 57.589 185.507 62.274 185.507 67.1813C185.507 72.1925 186.378 76.8995 188.125 81.3069C189.868 85.7173 192.313 89.6241 195.455 93.0293C198.597 96.4361 202.361 99.1155 206.75 101.069C211.138 103.022 215.974 103.999 221.262 103.999C225.351 103.999 229.416 102.997 233.455 100.994C237.494 98.9911 240.611 96.2359 242.805 92.7282V102.195H259.112V32.1642H242.805V41.6337ZM241.31 75.7472C240.312 78.4531 238.916 80.8078 237.122 82.8112C235.326 84.8161 233.156 86.4183 230.614 87.6206C228.07 88.823 225.251 89.4238 222.16 89.4238C219.068 89.4238 216.275 88.823 213.782 87.6206C211.289 86.4183 209.145 84.8161 207.35 82.8112C205.554 80.8078 204.182 78.4531 203.236 75.7472C202.286 73.0421 201.814 70.1868 201.814 67.1821C201.814 64.1758 202.286 61.3205 203.236 58.6154C204.182 55.9102 205.554 53.5571 207.35 51.5522C209.145 49.5495 211.288 47.9451 213.782 46.7427C216.275 45.5404 219.068 44.9396 222.16 44.9396C225.251 44.9396 228.07 45.5404 230.614 46.7427C233.156 47.9451 235.326 49.5487 237.122 51.5522C238.917 53.5571 240.312 55.9102 241.31 58.6154C242.306 61.3205 242.806 64.1758 242.806 67.1821C242.805 70.1868 242.305 73.0421 241.31 75.7472Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M438 -3H421.694V102.197H438V-3Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M139.43 102.197H155.735V48.2834H183.712V32.1665H139.43V102.197Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M324.49 32.1665L303.995 85.794L283.498 32.1665H266.983L293.748 102.197H314.242L341.006 32.1665H324.49Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M376.571 30.3656C356.603 30.3656 340.797 46.8497 340.797 67.1828C340.797 89.6597 356.094 104 378.661 104C391.29 104 399.354 99.1488 409.206 88.5848L398.189 80.0226C398.183 80.031 389.874 90.9895 377.468 90.9895C363.048 90.9895 356.977 79.3111 356.977 73.269H411.075C413.917 50.1328 398.775 30.3656 376.571 30.3656ZM357.02 61.0967C357.145 59.7487 359.023 43.3761 376.442 43.3761C393.861 43.3761 395.978 59.7464 396.099 61.0967H357.02Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- 13 -->
|
||||
<svg
|
||||
class="relative -ml-8 -mt-[6.6rem] w-[438px] max-w-none [--stroke-color:#1B1B18] lg:ml-0 dark:[--stroke-color:#FF750F]"
|
||||
viewBox="0 0 440 392"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
class="duration-750 starting:opacity-0 text-[#1B1B18] opacity-100 mix-blend-darken transition-all delay-300 dark:text-black dark:mix-blend-normal"
|
||||
>
|
||||
<mask
|
||||
id="path-1-mask"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="-0.328613"
|
||||
y="103"
|
||||
width="338"
|
||||
height="299"
|
||||
fill="black"
|
||||
>
|
||||
<rect
|
||||
fill="white"
|
||||
x="-0.328613"
|
||||
y="103"
|
||||
width="338"
|
||||
height="299"
|
||||
/>
|
||||
<path
|
||||
d="M234.936 400.8C204.136 400.8 178.936 392.4 159.336 375.6C140.136 358.8 130.536 337 130.536 310.2H200.736C200.736 318.2 203.736 324.8 209.736 330C215.736 335.2 223.736 337.8 233.736 337.8C243.336 337.8 251.136 335 257.136 329.4C263.536 323.8 266.736 316.6 266.736 307.8C266.736 299.8 263.936 293.2 258.336 288C252.736 282.8 245.536 280.2 236.736 280.2H199.536V218.4H236.736C243.536 218.4 249.336 216 254.136 211.2C258.936 206.4 261.336 200.4 261.336 193.2C261.336 184.8 258.736 178.2 253.536 173.4C248.336 168.6 241.736 166.2 233.736 166.2C226.536 166.2 220.336 168.4 215.136 172.8C210.336 177.2 207.936 182.8 207.936 189.6H141.336C141.336 164.8 150.136 144.6 167.736 129C185.336 113 207.936 105 235.536 105C263.136 105 285.536 112.2 302.736 126.6C320.336 141 329.136 160 329.136 183.6C329.136 200.8 324.536 214.8 315.336 225.6C306.136 236 294.336 243.2 279.936 247.2C297.136 252 310.736 260.2 320.736 271.8C331.136 283.4 336.336 298 336.336 315.6C336.336 340.4 326.936 360.8 308.136 376.8C289.336 392.8 264.936 400.8 234.936 400.8Z"
|
||||
/>
|
||||
<path
|
||||
d="M26.8714 167.6H1.67139V105.2H94.6714V400.2H26.8714V167.6Z"
|
||||
/>
|
||||
</mask>
|
||||
<path
|
||||
d="M234.936 400.8C204.136 400.8 178.936 392.4 159.336 375.6C140.136 358.8 130.536 337 130.536 310.2H200.736C200.736 318.2 203.736 324.8 209.736 330C215.736 335.2 223.736 337.8 233.736 337.8C243.336 337.8 251.136 335 257.136 329.4C263.536 323.8 266.736 316.6 266.736 307.8C266.736 299.8 263.936 293.2 258.336 288C252.736 282.8 245.536 280.2 236.736 280.2H199.536V218.4H236.736C243.536 218.4 249.336 216 254.136 211.2C258.936 206.4 261.336 200.4 261.336 193.2C261.336 184.8 258.736 178.2 253.536 173.4C248.336 168.6 241.736 166.2 233.736 166.2C226.536 166.2 220.336 168.4 215.136 172.8C210.336 177.2 207.936 182.8 207.936 189.6H141.336C141.336 164.8 150.136 144.6 167.736 129C185.336 113 207.936 105 235.536 105C263.136 105 285.536 112.2 302.736 126.6C320.336 141 329.136 160 329.136 183.6C329.136 200.8 324.536 214.8 315.336 225.6C306.136 236 294.336 243.2 279.936 247.2C297.136 252 310.736 260.2 320.736 271.8C331.136 283.4 336.336 298 336.336 315.6C336.336 340.4 326.936 360.8 308.136 376.8C289.336 392.8 264.936 400.8 234.936 400.8Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M26.8714 167.6H1.67139V105.2H94.6714V400.2H26.8714V167.6Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M234.936 400.8C204.136 400.8 178.936 392.4 159.336 375.6C140.136 358.8 130.536 337 130.536 310.2H200.736C200.736 318.2 203.736 324.8 209.736 330C215.736 335.2 223.736 337.8 233.736 337.8C243.336 337.8 251.136 335 257.136 329.4C263.536 323.8 266.736 316.6 266.736 307.8C266.736 299.8 263.936 293.2 258.336 288C252.736 282.8 245.536 280.2 236.736 280.2H199.536V218.4H236.736C243.536 218.4 249.336 216 254.136 211.2C258.936 206.4 261.336 200.4 261.336 193.2C261.336 184.8 258.736 178.2 253.536 173.4C248.336 168.6 241.736 166.2 233.736 166.2C226.536 166.2 220.336 168.4 215.136 172.8C210.336 177.2 207.936 182.8 207.936 189.6H141.336C141.336 164.8 150.136 144.6 167.736 129C185.336 113 207.936 105 235.536 105C263.136 105 285.536 112.2 302.736 126.6C320.336 141 329.136 160 329.136 183.6C329.136 200.8 324.536 214.8 315.336 225.6C306.136 236 294.336 243.2 279.936 247.2C297.136 252 310.736 260.2 320.736 271.8C331.136 283.4 336.336 298 336.336 315.6C336.336 340.4 326.936 360.8 308.136 376.8C289.336 392.8 264.936 400.8 234.936 400.8Z"
|
||||
stroke="var(--stroke-color)"
|
||||
stroke-width="2.4"
|
||||
mask="url(#path-1-mask)"
|
||||
/>
|
||||
<path
|
||||
d="M26.8714 167.6H1.67139V105.2H94.6714V400.2H26.8714V167.6Z"
|
||||
stroke="var(--stroke-color)"
|
||||
stroke-width="2.4"
|
||||
mask="url(#path-1-mask)"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<g
|
||||
class="delay-400 duration-750 starting:opacity-0 motion-safe:starting:-translate-x-[26px] text-[#F3BEC7] opacity-100 transition-all dark:text-[#4B0600]"
|
||||
>
|
||||
<mask
|
||||
id="path-2-mask"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="25.3357"
|
||||
y="103"
|
||||
width="338"
|
||||
height="299"
|
||||
fill="black"
|
||||
>
|
||||
<rect
|
||||
fill="white"
|
||||
x="25.3357"
|
||||
y="103"
|
||||
width="338"
|
||||
height="299"
|
||||
/>
|
||||
<path
|
||||
d="M260.6 400.8C229.8 400.8 204.6 392.4 185 375.6C165.8 358.8 156.2 337 156.2 310.2H226.4C226.4 318.2 229.4 324.8 235.4 330C241.4 335.2 249.4 337.8 259.4 337.8C269 337.8 276.8 335 282.8 329.4C289.2 323.8 292.4 316.6 292.4 307.8C292.4 299.8 289.6 293.2 284 288C278.4 282.8 271.2 280.2 262.4 280.2H225.2V218.4H262.4C269.2 218.4 275 216 279.8 211.2C284.6 206.4 287 200.4 287 193.2C287 184.8 284.4 178.2 279.2 173.4C274 168.6 267.4 166.2 259.4 166.2C252.2 166.2 246 168.4 240.8 172.8C236 177.2 233.6 182.8 233.6 189.6H167C167 164.8 175.8 144.6 193.4 129C211 113 233.6 105 261.2 105C288.8 105 311.2 112.2 328.4 126.6C346 141 354.8 160 354.8 183.6C354.8 200.8 350.2 214.8 341 225.6C331.8 236 320 243.2 305.6 247.2C322.8 252 336.4 260.2 346.4 271.8C356.8 283.4 362 298 362 315.6C362 340.4 352.6 360.8 333.8 376.8C315 392.8 290.6 400.8 260.6 400.8Z"
|
||||
/>
|
||||
<path
|
||||
d="M52.5357 167.6H27.3357V105.2H120.336V400.2H52.5357V167.6Z"
|
||||
/>
|
||||
</mask>
|
||||
<path
|
||||
d="M260.6 400.8C229.8 400.8 204.6 392.4 185 375.6C165.8 358.8 156.2 337 156.2 310.2H226.4C226.4 318.2 229.4 324.8 235.4 330C241.4 335.2 249.4 337.8 259.4 337.8C269 337.8 276.8 335 282.8 329.4C289.2 323.8 292.4 316.6 292.4 307.8C292.4 299.8 289.6 293.2 284 288C278.4 282.8 271.2 280.2 262.4 280.2H225.2V218.4H262.4C269.2 218.4 275 216 279.8 211.2C284.6 206.4 287 200.4 287 193.2C287 184.8 284.4 178.2 279.2 173.4C274 168.6 267.4 166.2 259.4 166.2C252.2 166.2 246 168.4 240.8 172.8C236 177.2 233.6 182.8 233.6 189.6H167C167 164.8 175.8 144.6 193.4 129C211 113 233.6 105 261.2 105C288.8 105 311.2 112.2 328.4 126.6C346 141 354.8 160 354.8 183.6C354.8 200.8 350.2 214.8 341 225.6C331.8 236 320 243.2 305.6 247.2C322.8 252 336.4 260.2 346.4 271.8C356.8 283.4 362 298 362 315.6C362 340.4 352.6 360.8 333.8 376.8C315 392.8 290.6 400.8 260.6 400.8Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M52.5357 167.6H27.3357V105.2H120.336V400.2H52.5357V167.6Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M260.6 400.8C229.8 400.8 204.6 392.4 185 375.6C165.8 358.8 156.2 337 156.2 310.2H226.4C226.4 318.2 229.4 324.8 235.4 330C241.4 335.2 249.4 337.8 259.4 337.8C269 337.8 276.8 335 282.8 329.4C289.2 323.8 292.4 316.6 292.4 307.8C292.4 299.8 289.6 293.2 284 288C278.4 282.8 271.2 280.2 262.4 280.2H225.2V218.4H262.4C269.2 218.4 275 216 279.8 211.2C284.6 206.4 287 200.4 287 193.2C287 184.8 284.4 178.2 279.2 173.4C274 168.6 267.4 166.2 259.4 166.2C252.2 166.2 246 168.4 240.8 172.8C236 177.2 233.6 182.8 233.6 189.6H167C167 164.8 175.8 144.6 193.4 129C211 113 233.6 105 261.2 105C288.8 105 311.2 112.2 328.4 126.6C346 141 354.8 160 354.8 183.6C354.8 200.8 350.2 214.8 341 225.6C331.8 236 320 243.2 305.6 247.2C322.8 252 336.4 260.2 346.4 271.8C356.8 283.4 362 298 362 315.6C362 340.4 352.6 360.8 333.8 376.8C315 392.8 290.6 400.8 260.6 400.8Z"
|
||||
stroke="var(--stroke-color)"
|
||||
stroke-width="2.4"
|
||||
mask="url(#path-2-mask)"
|
||||
/>
|
||||
<path
|
||||
d="M52.5357 167.6H27.3357V105.2H120.336V400.2H52.5357V167.6Z"
|
||||
stroke="var(--stroke-color)"
|
||||
stroke-width="2.4"
|
||||
mask="url(#path-2-mask)"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<g
|
||||
class="delay-400 duration-750 starting:opacity-0 motion-safe:starting:-translate-x-[51px] text-[#F8B803] opacity-100 mix-blend-color transition-all dark:text-[#391800] dark:mix-blend-hard-light"
|
||||
>
|
||||
<mask
|
||||
id="path-3-mask"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="51"
|
||||
y="103"
|
||||
width="338"
|
||||
height="299"
|
||||
fill="black"
|
||||
>
|
||||
<rect
|
||||
fill="white"
|
||||
x="51"
|
||||
y="103"
|
||||
width="338"
|
||||
height="299"
|
||||
/>
|
||||
<path
|
||||
d="M286.264 400.8C255.464 400.8 230.264 392.4 210.664 375.6C191.464 358.8 181.864 337 181.864 310.2H252.064C252.064 318.2 255.064 324.8 261.064 330C267.064 335.2 275.064 337.8 285.064 337.8C294.664 337.8 302.464 335 308.464 329.4C314.864 323.8 318.064 316.6 318.064 307.8C318.064 299.8 315.264 293.2 309.664 288C304.064 282.8 296.864 280.2 288.064 280.2H250.864V218.4H288.064C294.864 218.4 300.664 216 305.464 211.2C310.264 206.4 312.664 200.4 312.664 193.2C312.664 184.8 310.064 178.2 304.864 173.4C299.664 168.6 293.064 166.2 285.064 166.2C277.864 166.2 271.664 168.4 266.464 172.8C261.664 177.2 259.264 182.8 259.264 189.6H192.664C192.664 164.8 201.464 144.6 219.064 129C236.664 113 259.264 105 286.864 105C314.464 105 336.864 112.2 354.064 126.6C371.664 141 380.464 160 380.464 183.6C380.464 200.8 375.864 214.8 366.664 225.6C357.464 236 345.664 243.2 331.264 247.2C348.464 252 362.064 260.2 372.064 271.8C382.464 283.4 387.664 298 387.664 315.6C387.664 340.4 378.264 360.8 359.464 376.8C340.664 392.8 316.264 400.8 286.264 400.8Z"
|
||||
/>
|
||||
<path
|
||||
d="M78.2 167.6H53V105.2H146V400.2H78.2V167.6Z"
|
||||
/>
|
||||
</mask>
|
||||
<path
|
||||
d="M286.264 400.8C255.464 400.8 230.264 392.4 210.664 375.6C191.464 358.8 181.864 337 181.864 310.2H252.064C252.064 318.2 255.064 324.8 261.064 330C267.064 335.2 275.064 337.8 285.064 337.8C294.664 337.8 302.464 335 308.464 329.4C314.864 323.8 318.064 316.6 318.064 307.8C318.064 299.8 315.264 293.2 309.664 288C304.064 282.8 296.864 280.2 288.064 280.2H250.864V218.4H288.064C294.864 218.4 300.664 216 305.464 211.2C310.264 206.4 312.664 200.4 312.664 193.2C312.664 184.8 310.064 178.2 304.864 173.4C299.664 168.6 293.064 166.2 285.064 166.2C277.864 166.2 271.664 168.4 266.464 172.8C261.664 177.2 259.264 182.8 259.264 189.6H192.664C192.664 164.8 201.464 144.6 219.064 129C236.664 113 259.264 105 286.864 105C314.464 105 336.864 112.2 354.064 126.6C371.664 141 380.464 160 380.464 183.6C380.464 200.8 375.864 214.8 366.664 225.6C357.464 236 345.664 243.2 331.264 247.2C348.464 252 362.064 260.2 372.064 271.8C382.464 283.4 387.664 298 387.664 315.6C387.664 340.4 378.264 360.8 359.464 376.8C340.664 392.8 316.264 400.8 286.264 400.8Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M78.2 167.6H53V105.2H146V400.2H78.2V167.6Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M286.264 400.8C255.464 400.8 230.264 392.4 210.664 375.6C191.464 358.8 181.864 337 181.864 310.2H252.064C252.064 318.2 255.064 324.8 261.064 330C267.064 335.2 275.064 337.8 285.064 337.8C294.664 337.8 302.464 335 308.464 329.4C314.864 323.8 318.064 316.6 318.064 307.8C318.064 299.8 315.264 293.2 309.664 288C304.064 282.8 296.864 280.2 288.064 280.2H250.864V218.4H288.064C294.864 218.4 300.664 216 305.464 211.2C310.264 206.4 312.664 200.4 312.664 193.2C312.664 184.8 310.064 178.2 304.864 173.4C299.664 168.6 293.064 166.2 285.064 166.2C277.864 166.2 271.664 168.4 266.464 172.8C261.664 177.2 259.264 182.8 259.264 189.6H192.664C192.664 164.8 201.464 144.6 219.064 129C236.664 113 259.264 105 286.864 105C314.464 105 336.864 112.2 354.064 126.6C371.664 141 380.464 160 380.464 183.6C380.464 200.8 375.864 214.8 366.664 225.6C357.464 236 345.664 243.2 331.264 247.2C348.464 252 362.064 260.2 372.064 271.8C382.464 283.4 387.664 298 387.664 315.6C387.664 340.4 378.264 360.8 359.464 376.8C340.664 392.8 316.264 400.8 286.264 400.8Z"
|
||||
stroke="var(--stroke-color)"
|
||||
stroke-width="2.4"
|
||||
mask="url(#path-3-mask)"
|
||||
/>
|
||||
<path
|
||||
d="M78.2 167.6H53V105.2H146V400.2H78.2V167.6Z"
|
||||
stroke="var(--stroke-color)"
|
||||
stroke-width="2.4"
|
||||
mask="url(#path-3-mask)"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<g
|
||||
class="delay-400 duration-750 starting:opacity-0 motion-safe:starting:-translate-x-[78px] text-[#F3BEC7] opacity-100 mix-blend-multiply transition-all dark:text-[#733000] dark:mix-blend-normal"
|
||||
>
|
||||
<mask
|
||||
id="path-4-mask"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="76.6643"
|
||||
y="103"
|
||||
width="338"
|
||||
height="299"
|
||||
fill="black"
|
||||
>
|
||||
<rect
|
||||
fill="white"
|
||||
x="76.6643"
|
||||
y="103"
|
||||
width="338"
|
||||
height="299"
|
||||
/>
|
||||
<path
|
||||
d="M311.929 400.8C281.129 400.8 255.929 392.4 236.329 375.6C217.129 358.8 207.529 337 207.529 310.2H277.729C277.729 318.2 280.729 324.8 286.729 330C292.729 335.2 300.729 337.8 310.729 337.8C320.329 337.8 328.129 335 334.129 329.4C340.529 323.8 343.729 316.6 343.729 307.8C343.729 299.8 340.929 293.2 335.329 288C329.729 282.8 322.529 280.2 313.729 280.2H276.529V218.4H313.729C320.529 218.4 326.329 216 331.129 211.2C335.929 206.4 338.329 200.4 338.329 193.2C338.329 184.8 335.729 178.2 330.529 173.4C325.329 168.6 318.729 166.2 310.729 166.2C303.529 166.2 297.329 168.4 292.129 172.8C287.329 177.2 284.929 182.8 284.929 189.6H218.329C218.329 164.8 227.129 144.6 244.729 129C262.329 113 284.929 105 312.529 105C340.129 105 362.529 112.2 379.729 126.6C397.329 141 406.129 160 406.129 183.6C406.129 200.8 401.529 214.8 392.329 225.6C383.129 236 371.329 243.2 356.929 247.2C374.129 252 387.729 260.2 397.729 271.8C408.129 283.4 413.329 298 413.329 315.6C413.329 340.4 403.929 360.8 385.129 376.8C366.329 392.8 341.929 400.8 311.929 400.8Z"
|
||||
/>
|
||||
<path
|
||||
d="M103.864 167.6H78.6643V105.2H171.664V400.2H103.864V167.6Z"
|
||||
/>
|
||||
</mask>
|
||||
<path
|
||||
d="M311.929 400.8C281.129 400.8 255.929 392.4 236.329 375.6C217.129 358.8 207.529 337 207.529 310.2H277.729C277.729 318.2 280.729 324.8 286.729 330C292.729 335.2 300.729 337.8 310.729 337.8C320.329 337.8 328.129 335 334.129 329.4C340.529 323.8 343.729 316.6 343.729 307.8C343.729 299.8 340.929 293.2 335.329 288C329.729 282.8 322.529 280.2 313.729 280.2H276.529V218.4H313.729C320.529 218.4 326.329 216 331.129 211.2C335.929 206.4 338.329 200.4 338.329 193.2C338.329 184.8 335.729 178.2 330.529 173.4C325.329 168.6 318.729 166.2 310.729 166.2C303.529 166.2 297.329 168.4 292.129 172.8C287.329 177.2 284.929 182.8 284.929 189.6H218.329C218.329 164.8 227.129 144.6 244.729 129C262.329 113 284.929 105 312.529 105C340.129 105 362.529 112.2 379.729 126.6C397.329 141 406.129 160 406.129 183.6C406.129 200.8 401.529 214.8 392.329 225.6C383.129 236 371.329 243.2 356.929 247.2C374.129 252 387.729 260.2 397.729 271.8C408.129 283.4 413.329 298 413.329 315.6C413.329 340.4 403.929 360.8 385.129 376.8C366.329 392.8 341.929 400.8 311.929 400.8Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M103.864 167.6H78.6643V105.2H171.664V400.2H103.864V167.6Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M311.929 400.8C281.129 400.8 255.929 392.4 236.329 375.6C217.129 358.8 207.529 337 207.529 310.2H277.729C277.729 318.2 280.729 324.8 286.729 330C292.729 335.2 300.729 337.8 310.729 337.8C320.329 337.8 328.129 335 334.129 329.4C340.529 323.8 343.729 316.6 343.729 307.8C343.729 299.8 340.929 293.2 335.329 288C329.729 282.8 322.529 280.2 313.729 280.2H276.529V218.4H313.729C320.529 218.4 326.329 216 331.129 211.2C335.929 206.4 338.329 200.4 338.329 193.2C338.329 184.8 335.729 178.2 330.529 173.4C325.329 168.6 318.729 166.2 310.729 166.2C303.529 166.2 297.329 168.4 292.129 172.8C287.329 177.2 284.929 182.8 284.929 189.6H218.329C218.329 164.8 227.129 144.6 244.729 129C262.329 113 284.929 105 312.529 105C340.129 105 362.529 112.2 379.729 126.6C397.329 141 406.129 160 406.129 183.6C406.129 200.8 401.529 214.8 392.329 225.6C383.129 236 371.329 243.2 356.929 247.2C374.129 252 387.729 260.2 397.729 271.8C408.129 283.4 413.329 298 413.329 315.6C413.329 340.4 403.929 360.8 385.129 376.8C366.329 392.8 341.929 400.8 311.929 400.8Z"
|
||||
stroke="var(--stroke-color)"
|
||||
stroke-width="2.4"
|
||||
mask="url(#path-4-mask)"
|
||||
/>
|
||||
<path
|
||||
d="M103.864 167.6H78.6643V105.2H171.664V400.2H103.864V167.6Z"
|
||||
stroke="var(--stroke-color)"
|
||||
stroke-width="2.4"
|
||||
mask="url(#path-4-mask)"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<g
|
||||
class="delay-400 duration-750 starting:opacity-0 motion-safe:starting:-translate-x-[102px] text-[#F3BEC7] opacity-100 mix-blend-hard-light transition-all dark:text-[#4B0600]"
|
||||
>
|
||||
<mask
|
||||
id="path-5-mask"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="102.329"
|
||||
y="103"
|
||||
width="338"
|
||||
height="299"
|
||||
fill="black"
|
||||
>
|
||||
<rect
|
||||
fill="white"
|
||||
x="102.329"
|
||||
y="103"
|
||||
width="338"
|
||||
height="299"
|
||||
/>
|
||||
<path
|
||||
d="M337.593 400.8C306.793 400.8 281.593 392.4 261.993 375.6C242.793 358.8 233.193 337 233.193 310.2H303.393C303.393 318.2 306.393 324.8 312.393 330C318.393 335.2 326.393 337.8 336.393 337.8C345.993 337.8 353.793 335 359.793 329.4C366.193 323.8 369.393 316.6 369.393 307.8C369.393 299.8 366.593 293.2 360.993 288C355.393 282.8 348.193 280.2 339.393 280.2H302.193V218.4H339.393C346.193 218.4 351.993 216 356.793 211.2C361.593 206.4 363.993 200.4 363.993 193.2C363.993 184.8 361.393 178.2 356.193 173.4C350.993 168.6 344.393 166.2 336.393 166.2C329.193 166.2 322.993 168.4 317.793 172.8C312.993 177.2 310.593 182.8 310.593 189.6H243.993C243.993 164.8 252.793 144.6 270.393 129C287.993 113 310.593 105 338.193 105C365.793 105 388.193 112.2 405.393 126.6C422.993 141 431.793 160 431.793 183.6C431.793 200.8 427.193 214.8 417.993 225.6C408.793 236 396.993 243.2 382.593 247.2C399.793 252 413.393 260.2 423.393 271.8C433.793 283.4 438.993 298 438.993 315.6C438.993 340.4 429.593 360.8 410.793 376.8C391.993 392.8 367.593 400.8 337.593 400.8Z"
|
||||
/>
|
||||
<path
|
||||
d="M129.529 167.6H104.329V105.2H197.329V400.2H129.529V167.6Z"
|
||||
/>
|
||||
</mask>
|
||||
<path
|
||||
d="M337.593 400.8C306.793 400.8 281.593 392.4 261.993 375.6C242.793 358.8 233.193 337 233.193 310.2H303.393C303.393 318.2 306.393 324.8 312.393 330C318.393 335.2 326.393 337.8 336.393 337.8C345.993 337.8 353.793 335 359.793 329.4C366.193 323.8 369.393 316.6 369.393 307.8C369.393 299.8 366.593 293.2 360.993 288C355.393 282.8 348.193 280.2 339.393 280.2H302.193V218.4H339.393C346.193 218.4 351.993 216 356.793 211.2C361.593 206.4 363.993 200.4 363.993 193.2C363.993 184.8 361.393 178.2 356.193 173.4C350.993 168.6 344.393 166.2 336.393 166.2C329.193 166.2 322.993 168.4 317.793 172.8C312.993 177.2 310.593 182.8 310.593 189.6H243.993C243.993 164.8 252.793 144.6 270.393 129C287.993 113 310.593 105 338.193 105C365.793 105 388.193 112.2 405.393 126.6C422.993 141 431.793 160 431.793 183.6C431.793 200.8 427.193 214.8 417.993 225.6C408.793 236 396.993 243.2 382.593 247.2C399.793 252 413.393 260.2 423.393 271.8C433.793 283.4 438.993 298 438.993 315.6C438.993 340.4 429.593 360.8 410.793 376.8C391.993 392.8 367.593 400.8 337.593 400.8Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M129.529 167.6H104.329V105.2H197.329V400.2H129.529V167.6Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M337.593 400.8C306.793 400.8 281.593 392.4 261.993 375.6C242.793 358.8 233.193 337 233.193 310.2H303.393C303.393 318.2 306.393 324.8 312.393 330C318.393 335.2 326.393 337.8 336.393 337.8C345.993 337.8 353.793 335 359.793 329.4C366.193 323.8 369.393 316.6 369.393 307.8C369.393 299.8 366.593 293.2 360.993 288C355.393 282.8 348.193 280.2 339.393 280.2H302.193V218.4H339.393C346.193 218.4 351.993 216 356.793 211.2C361.593 206.4 363.993 200.4 363.993 193.2C363.993 184.8 361.393 178.2 356.193 173.4C350.993 168.6 344.393 166.2 336.393 166.2C329.193 166.2 322.993 168.4 317.793 172.8C312.993 177.2 310.593 182.8 310.593 189.6H243.993C243.993 164.8 252.793 144.6 270.393 129C287.993 113 310.593 105 338.193 105C365.793 105 388.193 112.2 405.393 126.6C422.993 141 431.793 160 431.793 183.6C431.793 200.8 427.193 214.8 417.993 225.6C408.793 236 396.993 243.2 382.593 247.2C399.793 252 413.393 260.2 423.393 271.8C433.793 283.4 438.993 298 438.993 315.6C438.993 340.4 429.593 360.8 410.793 376.8C391.993 392.8 367.593 400.8 337.593 400.8Z"
|
||||
stroke="var(--stroke-color)"
|
||||
stroke-width="2.4"
|
||||
mask="url(#path-5-mask)"
|
||||
/>
|
||||
<path
|
||||
d="M129.529 167.6H104.329V105.2H197.329V400.2H129.529V167.6Z"
|
||||
stroke="var(--stroke-color)"
|
||||
stroke-width="2.4"
|
||||
mask="url(#path-5-mask)"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<div
|
||||
class="absolute inset-0 rounded-t-lg shadow-[inset_0px_0px_0px_1px_rgba(26,26,0,0.16)] lg:rounded-r-lg lg:rounded-t-none dark:shadow-[inset_0px_0px_0px_1px_#fffaed2d]"
|
||||
></div>
|
||||
class="border-outline-variant bg-surface-container-low text-muted-foreground flex items-center gap-3 border-b px-4 py-3 text-xs"
|
||||
>
|
||||
<Server class="size-4" />
|
||||
your-infrastructure
|
||||
</div>
|
||||
<div
|
||||
class="bg-surface-dim text-on-surface dark:bg-surface-container-lowest p-5 font-mono text-sm leading-7"
|
||||
>
|
||||
<p>
|
||||
<span class="text-primary">$</span> git clone
|
||||
kanbangit
|
||||
</p>
|
||||
<p>
|
||||
<span class="text-primary">$</span> configure your
|
||||
environment
|
||||
</p>
|
||||
<p>
|
||||
<span class="text-primary">$</span> deploy on your
|
||||
terms
|
||||
</p>
|
||||
<p class="text-muted-foreground mt-3">
|
||||
Workspace ready for your team.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<div class="h-14.5 hidden lg:block"></div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="border-outline-variant bg-primary text-primary-foreground border-y"
|
||||
>
|
||||
<div
|
||||
class="mx-auto flex max-w-7xl flex-col gap-6 px-4 py-12 sm:px-6 md:flex-row md:items-center md:justify-between lg:px-8"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold tracking-tight">
|
||||
Your work. Your workflow. Your server.
|
||||
</h2>
|
||||
<p class="mt-2 text-sm opacity-80">
|
||||
Open the workspace and keep delivery moving.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
:href="$page.props.auth.user ? dashboard() : login()"
|
||||
class="bg-primary-foreground text-primary inline-flex h-10 items-center justify-center gap-2 rounded-md px-5 text-sm font-semibold shadow-lg transition-transform hover:-translate-y-px"
|
||||
>
|
||||
{{
|
||||
$page.props.auth.user ? 'Open dashboard' : 'Log in'
|
||||
}}
|
||||
<ArrowRight class="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer
|
||||
class="text-muted-foreground mx-auto flex max-w-7xl flex-col gap-4 px-4 py-8 text-sm sm:px-6 md:flex-row md:items-center md:justify-between lg:px-8"
|
||||
>
|
||||
<div class="text-foreground flex items-center gap-2 font-semibold">
|
||||
<Workflow class="text-primary size-4" />
|
||||
KanbanGit
|
||||
</div>
|
||||
<p>Open source project management for Git teams.</p>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -34,7 +34,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head title="Create user" />
|
||||
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
||||
<div class="app-page-narrow max-w-3xl">
|
||||
<Heading
|
||||
title="Create user"
|
||||
description="Create a user account and optionally force a password change on the next login."
|
||||
@@ -52,12 +52,16 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
<CardContent>
|
||||
<Form
|
||||
v-bind="UserManagementController.store.form()"
|
||||
:transform="(data) => ({
|
||||
...data,
|
||||
must_change_password_on_next_login:
|
||||
data.must_change_password_on_next_login === true ||
|
||||
data.must_change_password_on_next_login === 'on',
|
||||
})"
|
||||
:transform="
|
||||
(data) => ({
|
||||
...data,
|
||||
must_change_password_on_next_login:
|
||||
data.must_change_password_on_next_login ===
|
||||
true ||
|
||||
data.must_change_password_on_next_login ===
|
||||
'on',
|
||||
})
|
||||
"
|
||||
class="space-y-6"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
@@ -128,13 +132,19 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
<span class="block font-medium">
|
||||
Must change password on next login
|
||||
</span>
|
||||
<span class="text-muted-foreground block text-sm">
|
||||
Redirect the user to the security settings page until they save a new password.
|
||||
<span
|
||||
class="text-muted-foreground block text-sm"
|
||||
>
|
||||
Redirect the user to the security
|
||||
settings page until they save a new
|
||||
password.
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
<InputError
|
||||
:message="errors.must_change_password_on_next_login"
|
||||
:message="
|
||||
errors.must_change_password_on_next_login
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ const submit = (): void => {
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="`Edit ${user.name}`" />
|
||||
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
||||
<div class="app-page-narrow max-w-3xl">
|
||||
<Heading
|
||||
:title="`Edit ${user.name}`"
|
||||
description="Update user details, reset the password if needed, and control whether the user must change it on next login."
|
||||
|
||||
@@ -45,7 +45,7 @@ const hasPermission = (actorId: number, permission: string) =>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head title="User management" />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div class="app-page">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<Heading
|
||||
title="User management"
|
||||
@@ -67,10 +67,7 @@ const hasPermission = (actorId: number, permission: string) =>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status"
|
||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||
>
|
||||
<div v-if="status" class="status-success">
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { index as projectsIndex, show as projectShow } from '@/routes/projects/index';
|
||||
import { index as projectsIndex } from '@/routes/projects/index';
|
||||
import { index as projectBoard } from '@/routes/projects/board';
|
||||
import {
|
||||
create as projectTemplateCreate,
|
||||
store as projectTemplateStore,
|
||||
@@ -31,7 +32,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
},
|
||||
{
|
||||
title: props.project.name,
|
||||
href: projectShow(props.project.id),
|
||||
href: projectBoard(props.project.id),
|
||||
},
|
||||
{
|
||||
title: 'Save as new template',
|
||||
@@ -40,7 +41,9 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
];
|
||||
|
||||
const form = useForm({
|
||||
copy_areas: props.copyAreaOptions.map((copyAreaOption) => copyAreaOption.value),
|
||||
copy_areas: props.copyAreaOptions.map(
|
||||
(copyAreaOption) => copyAreaOption.value,
|
||||
),
|
||||
name: `${props.project.name} template`,
|
||||
slug: `${props.project.key.toLowerCase()}-template`,
|
||||
});
|
||||
@@ -48,7 +51,9 @@ const form = useForm({
|
||||
const copyAreasError = computed(() => {
|
||||
return (
|
||||
form.errors.copy_areas ??
|
||||
Object.entries(form.errors).find(([key]) => key.startsWith('copy_areas.'))?.[1] ??
|
||||
Object.entries(form.errors).find(([key]) =>
|
||||
key.startsWith('copy_areas.'),
|
||||
)?.[1] ??
|
||||
''
|
||||
);
|
||||
});
|
||||
@@ -61,12 +66,14 @@ const submit = (): void => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
|
||||
<Head :title="`Save ${project.name} as new template`" />
|
||||
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
||||
<div class="app-page-narrow max-w-3xl">
|
||||
<div class="space-y-3">
|
||||
<Badge variant="outline" class="px-3 py-1">{{ project.key }}</Badge>
|
||||
<Badge variant="outline" class="px-3 py-1">{{
|
||||
project.key
|
||||
}}</Badge>
|
||||
<Heading
|
||||
:title="`Save ${project.name} as new template`"
|
||||
description="Capture reusable project configuration without copying operational ticket data."
|
||||
@@ -77,7 +84,8 @@ const submit = (): void => {
|
||||
<CardHeader>
|
||||
<CardTitle>Template details</CardTitle>
|
||||
<CardDescription>
|
||||
Pick the configuration groups that should be reusable in future projects.
|
||||
Pick the configuration groups that should be reusable in
|
||||
future projects.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -85,15 +93,26 @@ const submit = (): void => {
|
||||
<form class="space-y-6" @submit.prevent="submit">
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">Template name</Label>
|
||||
<Input id="name" v-model="form.name" required placeholder="Foundation template" />
|
||||
<Input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
required
|
||||
placeholder="Foundation template"
|
||||
/>
|
||||
<InputError :message="form.errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="slug">Template slug</Label>
|
||||
<Input id="slug" v-model="form.slug" required placeholder="foundation-template" />
|
||||
<Input
|
||||
id="slug"
|
||||
v-model="form.slug"
|
||||
required
|
||||
placeholder="foundation-template"
|
||||
/>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Slugs are normalized automatically and used to identify reusable templates.
|
||||
Slugs are normalized automatically and used to
|
||||
identify reusable templates.
|
||||
</p>
|
||||
<InputError :message="form.errors.slug" />
|
||||
</div>
|
||||
@@ -102,7 +121,8 @@ const submit = (): void => {
|
||||
<div>
|
||||
<Label>Configuration groups</Label>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Only the selected groups will be stored in the template.
|
||||
Only the selected groups will be stored in
|
||||
the template.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -119,8 +139,12 @@ const submit = (): void => {
|
||||
class="mt-1"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium">{{ copyAreaOption.label }}</div>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
<div class="font-medium">
|
||||
{{ copyAreaOption.label }}
|
||||
</div>
|
||||
<p
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
{{ copyAreaOption.description }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -135,7 +159,9 @@ const submit = (): void => {
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="projectShow(project.id)">Cancel</Link>
|
||||
<Link :href="projectBoard(project.id)"
|
||||
>Cancel</Link
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -44,12 +44,16 @@ const form = useForm({
|
||||
const copyAreasError = computed(() => {
|
||||
return (
|
||||
form.errors.copy_areas ??
|
||||
Object.entries(form.errors).find(([key]) => key.startsWith('copy_areas.'))?.[1] ??
|
||||
Object.entries(form.errors).find(([key]) =>
|
||||
key.startsWith('copy_areas.'),
|
||||
)?.[1] ??
|
||||
''
|
||||
);
|
||||
});
|
||||
|
||||
const linkedProjectCount = computed(() => props.template.linked_project_count ?? 0);
|
||||
const linkedProjectCount = computed(
|
||||
() => props.template.linked_project_count ?? 0,
|
||||
);
|
||||
|
||||
const submit = (): void => {
|
||||
form.patch(projectTemplatesUpdate.url(props.template.id), {
|
||||
@@ -62,12 +66,15 @@ const submit = (): void => {
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="`Edit ${template.name}`" />
|
||||
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
||||
<div class="app-page-narrow max-w-3xl">
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Badge variant="outline" class="px-3 py-1">{{ template.slug }}</Badge>
|
||||
<Badge variant="outline" class="px-3 py-1">{{
|
||||
template.slug
|
||||
}}</Badge>
|
||||
<Badge v-if="linkedProjectCount > 0" variant="secondary">
|
||||
{{ linkedProjectCount }} linked {{ linkedProjectCount === 1 ? 'project' : 'projects' }}
|
||||
{{ linkedProjectCount }} linked
|
||||
{{ linkedProjectCount === 1 ? 'project' : 'projects' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<Heading
|
||||
@@ -81,10 +88,14 @@ const submit = (): void => {
|
||||
<CardTitle>Template details</CardTitle>
|
||||
<CardDescription>
|
||||
<template v-if="template.source_project">
|
||||
This template baseline is rebuilt from {{ template.source_project.name }} ({{ template.source_project.key }}) when you save.
|
||||
This template baseline is rebuilt from
|
||||
{{ template.source_project.name }} ({{
|
||||
template.source_project.key
|
||||
}}) when you save.
|
||||
</template>
|
||||
<template v-else>
|
||||
This template baseline is rebuilt from its source project when you save.
|
||||
This template baseline is rebuilt from its source
|
||||
project when you save.
|
||||
</template>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -93,15 +104,25 @@ const submit = (): void => {
|
||||
<form class="space-y-6" @submit.prevent="submit">
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">Template name</Label>
|
||||
<Input id="name" v-model="form.name" required placeholder="Foundation template" />
|
||||
<Input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
required
|
||||
placeholder="Foundation template"
|
||||
/>
|
||||
<InputError :message="form.errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="slug">Template slug</Label>
|
||||
<Input id="slug" :model-value="template.slug" disabled />
|
||||
<Input
|
||||
id="slug"
|
||||
:model-value="template.slug"
|
||||
disabled
|
||||
/>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
The slug stays locked so existing template references remain stable.
|
||||
The slug stays locked so existing template
|
||||
references remain stable.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -109,7 +130,8 @@ const submit = (): void => {
|
||||
<div>
|
||||
<Label>Configuration groups</Label>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Saving rebuilds the selected groups from the current source-project configuration.
|
||||
Saving rebuilds the selected groups from the
|
||||
current source-project configuration.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -126,8 +148,12 @@ const submit = (): void => {
|
||||
class="mt-1"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium">{{ copyAreaOption.label }}</div>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
<div class="font-medium">
|
||||
{{ copyAreaOption.label }}
|
||||
</div>
|
||||
<p
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
{{ copyAreaOption.description }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -143,19 +169,39 @@ const submit = (): void => {
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium">Warning</div>
|
||||
<p class="text-sm">
|
||||
Saving this template updates the baseline for {{ linkedProjectCount }} linked {{ linkedProjectCount === 1 ? 'project' : 'projects' }}.
|
||||
Inherited areas will follow the new template version, while existing overrides stay untouched.
|
||||
Saving this template updates the baseline
|
||||
for {{ linkedProjectCount }} linked
|
||||
{{
|
||||
linkedProjectCount === 1
|
||||
? 'project'
|
||||
: 'projects'
|
||||
}}. Inherited areas will follow the new
|
||||
template version, while existing overrides
|
||||
stay untouched.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Label class="flex cursor-pointer items-start gap-3">
|
||||
<input v-model="form.confirm_linked_projects_update" type="checkbox" class="mt-1" />
|
||||
<Label
|
||||
class="flex cursor-pointer items-start gap-3"
|
||||
>
|
||||
<input
|
||||
v-model="
|
||||
form.confirm_linked_projects_update
|
||||
"
|
||||
type="checkbox"
|
||||
class="mt-1"
|
||||
/>
|
||||
<span class="text-sm">
|
||||
I understand that linked projects inheriting from this template will be affected.
|
||||
I understand that linked projects inheriting
|
||||
from this template will be affected.
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
<InputError :message="form.errors.confirm_linked_projects_update" />
|
||||
<InputError
|
||||
:message="
|
||||
form.errors.confirm_linked_projects_update
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -164,7 +210,9 @@ const submit = (): void => {
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="projectTemplatesIndex()">Cancel</Link>
|
||||
<Link :href="projectTemplatesIndex()"
|
||||
>Cancel</Link
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -16,7 +16,10 @@ import {
|
||||
edit as projectTemplatesEdit,
|
||||
index as projectTemplatesIndex,
|
||||
} from '@/routes/project-templates';
|
||||
import { create as projectsCreate, index as projectsIndex } from '@/routes/projects/index';
|
||||
import {
|
||||
create as projectsCreate,
|
||||
index as projectsIndex,
|
||||
} from '@/routes/projects/index';
|
||||
import type { BreadcrumbItem, ProjectTemplatesIndexPageProps } from '@/types';
|
||||
|
||||
const props = defineProps<ProjectTemplatesIndexPageProps>();
|
||||
@@ -39,8 +42,10 @@ const removeTemplate = (templateId: number): void => {
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head title="Project templates" />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="app-page">
|
||||
<div
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<Heading
|
||||
title="Project templates"
|
||||
description="Reusable published project templates. Internal project base templates stay hidden here by default."
|
||||
@@ -57,30 +62,44 @@ const removeTemplate = (templateId: number): void => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status"
|
||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||
>
|
||||
<div v-if="status" class="status-success">
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<div v-if="templates.length === 0" class="text-muted-foreground text-sm">
|
||||
<div v-if="templates.length === 0" class="empty-state">
|
||||
No published project templates have been saved yet.
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<Card v-for="template in templates" :key="template.id" class="h-full">
|
||||
<Card
|
||||
v-for="template in templates"
|
||||
:key="template.id"
|
||||
class="h-full"
|
||||
>
|
||||
<CardHeader>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<CardTitle>{{ template.name }}</CardTitle>
|
||||
<Badge variant="secondary">{{ template.slug }}</Badge>
|
||||
<Badge v-if="template.linked_project_count" variant="outline">
|
||||
{{ template.linked_project_count }} linked {{ template.linked_project_count === 1 ? 'project' : 'projects' }}
|
||||
<Badge variant="secondary">{{
|
||||
template.slug
|
||||
}}</Badge>
|
||||
<Badge
|
||||
v-if="template.linked_project_count"
|
||||
variant="outline"
|
||||
>
|
||||
{{ template.linked_project_count }} linked
|
||||
{{
|
||||
template.linked_project_count === 1
|
||||
? 'project'
|
||||
: 'projects'
|
||||
}}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription>
|
||||
<template v-if="template.source_project">
|
||||
Saved from {{ template.source_project.name }} ({{ template.source_project.key }}).
|
||||
Saved from
|
||||
{{ template.source_project.name }} ({{
|
||||
template.source_project.key
|
||||
}}).
|
||||
</template>
|
||||
<template v-else>
|
||||
Saved template configuration.
|
||||
@@ -106,7 +125,13 @@ const removeTemplate = (templateId: number): void => {
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="projectsCreate({ query: { template: template.id } })">
|
||||
<Link
|
||||
:href="
|
||||
projectsCreate({
|
||||
query: { template: template.id },
|
||||
})
|
||||
"
|
||||
>
|
||||
Use template
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { access, index, show } from '@/routes/projects/index';
|
||||
import { access, index } from '@/routes/projects/index';
|
||||
import { index as boardIndex } from '@/routes/projects/board';
|
||||
import { general as generalSettings } from '@/routes/projects/settings';
|
||||
import type { BreadcrumbItem, ProjectAccessPageProps } from '@/types';
|
||||
|
||||
@@ -25,7 +26,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
},
|
||||
{
|
||||
title: props.project.name,
|
||||
href: show(props.project.id),
|
||||
href: boardIndex(props.project.id),
|
||||
},
|
||||
{
|
||||
title: 'Access',
|
||||
@@ -41,11 +42,15 @@ const hasPermission = (
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="`${project.name} access`" />
|
||||
<component
|
||||
:is="embedded ? 'div' : AppLayout"
|
||||
v-bind="embedded ? {} : { breadcrumbs, project }"
|
||||
>
|
||||
<Head v-if="!embedded" :title="`${project.name} access`" />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div :class="embedded ? 'space-y-6' : 'app-page'">
|
||||
<div
|
||||
v-if="!embedded"
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
@@ -70,15 +75,14 @@ const hasPermission = (
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="show(project.id)">Back to project</Link>
|
||||
<Link :href="boardIndex(project.id)"
|
||||
>Back to project</Link
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status"
|
||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||
>
|
||||
<div v-if="!embedded && status" class="status-success">
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
@@ -304,5 +308,5 @@ const hasPermission = (
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||
import { Plus } from 'lucide-vue-next';
|
||||
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import ProjectPageHeader from '@/components/projects/ProjectPageHeader.vue';
|
||||
import ProjectTabs from '@/components/projects/ProjectTabs.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -12,10 +14,13 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { index as boardIndex } from '@/routes/projects/board';
|
||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||
import { index, show } from '@/routes/projects/index';
|
||||
import { create as createTicket, show as showTicket } from '@/routes/projects/tickets';
|
||||
import { index } from '@/routes/projects/index';
|
||||
import { index as boardIndex } from '@/routes/projects/board';
|
||||
import {
|
||||
create as createTicket,
|
||||
show as showTicket,
|
||||
} from '@/routes/projects/tickets';
|
||||
import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types';
|
||||
import type { TicketModel } from '@/types/models/ticket-model';
|
||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||
@@ -29,7 +34,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
},
|
||||
{
|
||||
title: props.project.name,
|
||||
href: show(props.project.id),
|
||||
href: boardIndex(props.project.id),
|
||||
},
|
||||
{
|
||||
title: 'Backlog',
|
||||
@@ -64,56 +69,46 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
|
||||
<Head :title="`${project.name} backlog`" />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
<div class="app-page max-w-none gap-5">
|
||||
<ProjectPageHeader
|
||||
:project="project"
|
||||
context="Project backlog"
|
||||
description="Plan and refine work before moving it onto the board."
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<Badge variant="outline" class="px-3 py-1">
|
||||
{{ project.key }}
|
||||
</Badge>
|
||||
<Heading
|
||||
:title="`${project.name} backlog`"
|
||||
description="All newly created tickets start here before they move onto the board."
|
||||
/>
|
||||
</div>
|
||||
<Button v-if="canCreateTickets" as-child size="sm">
|
||||
<Link
|
||||
:href="
|
||||
createTicket(
|
||||
{ project: project.id },
|
||||
{ query: { source: 'backlog' } },
|
||||
)
|
||||
"
|
||||
>
|
||||
<Plus class="size-4" />
|
||||
Create ticket
|
||||
</Link>
|
||||
</Button>
|
||||
</ProjectPageHeader>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button v-if="canCreateTickets" as-child>
|
||||
<Link
|
||||
:href="
|
||||
createTicket(
|
||||
{ project: project.id },
|
||||
{ query: { source: 'backlog' } },
|
||||
)
|
||||
"
|
||||
>
|
||||
Create ticket
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="boardIndex(project.id)">Open board</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="show(project.id)">Back to project</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<ProjectTabs
|
||||
active="backlog"
|
||||
:can-manage-project="canManageProject"
|
||||
:project="project"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status"
|
||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||
>
|
||||
<div v-if="status" class="status-success">
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<Card class="gap-0 overflow-hidden">
|
||||
<CardHeader class="border-b">
|
||||
<Card class="gap-0 overflow-hidden py-0">
|
||||
<CardHeader
|
||||
class="border-outline-variant bg-surface-container-low border-b px-5 py-4"
|
||||
>
|
||||
<CardTitle>Backlog tickets</CardTitle>
|
||||
<CardDescription>
|
||||
Tickets remain here while their status belongs to
|
||||
@@ -122,10 +117,7 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="p-0">
|
||||
<div
|
||||
v-if="tickets.length === 0"
|
||||
class="text-muted-foreground p-6 text-sm"
|
||||
>
|
||||
<div v-if="tickets.length === 0" class="empty-state m-4">
|
||||
No backlog tickets exist yet.
|
||||
</div>
|
||||
|
||||
@@ -158,7 +150,7 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
||||
<tr
|
||||
v-for="ticket in tickets"
|
||||
:key="ticket.id"
|
||||
class="bg-background"
|
||||
class="bg-surface-container-lowest"
|
||||
>
|
||||
<td class="px-4 py-4">
|
||||
<Link
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||
import { Head, Link, router } from '@inertiajs/vue3';
|
||||
import { CircleUserRound, GripVertical, Plus } from 'lucide-vue-next';
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import ProjectPageHeader from '@/components/projects/ProjectPageHeader.vue';
|
||||
import ProjectTabs from '@/components/projects/ProjectTabs.vue';
|
||||
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||
import { index as projectIndex, show } from '@/routes/projects/index';
|
||||
import { board as boardSettings } from '@/routes/projects/settings';
|
||||
import { index as projectIndex } from '@/routes/projects';
|
||||
import { index as boardIndex } from '@/routes/projects/board';
|
||||
import { create as createTicket, show as showTicket } from '@/routes/projects/tickets';
|
||||
import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
|
||||
import { index as settingsIndex } from '@/routes/projects/settings';
|
||||
import {
|
||||
create as createTicket,
|
||||
show as showTicket,
|
||||
} from '@/routes/projects/tickets';
|
||||
import type {
|
||||
BoardDragPointer,
|
||||
BoardDragTicket,
|
||||
BoardDropTarget,
|
||||
} from '@/types/board-drag';
|
||||
import type { BoardColumnModel } from '@/types/models/board-column-model';
|
||||
import type { TicketModel } from '@/types/models/ticket-model';
|
||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||
import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
|
||||
|
||||
const props = defineProps<ProjectBoardPageProps>();
|
||||
|
||||
@@ -30,7 +34,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
},
|
||||
{
|
||||
title: props.project.name,
|
||||
href: show(props.project.id),
|
||||
href: boardIndex(props.project.id),
|
||||
},
|
||||
{
|
||||
title: 'Board',
|
||||
@@ -38,299 +42,671 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => {
|
||||
if (ticketStatus === null) {
|
||||
return 'Unknown status';
|
||||
const boardScroller = ref<HTMLElement | null>(null);
|
||||
const pendingDragTicket = ref<BoardDragTicket | null>(null);
|
||||
const draggedTicket = ref<BoardDragTicket | null>(null);
|
||||
const dragPointer = ref<BoardDragPointer | null>(null);
|
||||
const dropTarget = ref<BoardDropTarget | null>(null);
|
||||
const movingTicketId = ref<number | null>(null);
|
||||
const moveError = ref<string | null>(null);
|
||||
const pointerX = ref(0);
|
||||
const pointerY = ref(0);
|
||||
|
||||
let autoScrollFrame: number | undefined;
|
||||
let initialScrollFrame: number | undefined;
|
||||
let longPressTimer: number | undefined;
|
||||
let suppressClickTicketId: number | null = null;
|
||||
|
||||
const allowedBoardTargetStatuses = (ticket: TicketModel): TicketStatusModel[] =>
|
||||
(ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
||||
(ticketStatus) =>
|
||||
ticketStatus.id !== ticket.ticket_status_id &&
|
||||
ticketStatus.board_column_id !== null,
|
||||
);
|
||||
|
||||
const allowedStatusesForColumn = (
|
||||
ticket: TicketModel,
|
||||
boardColumnId: number,
|
||||
): TicketStatusModel[] =>
|
||||
allowedBoardTargetStatuses(ticket).filter(
|
||||
(ticketStatus) => ticketStatus.board_column_id === boardColumnId,
|
||||
);
|
||||
|
||||
const isAllowedColumn = (boardColumnId: number): boolean => {
|
||||
if (draggedTicket.value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
ticketStatus.board_column === null ||
|
||||
ticketStatus.board_column === undefined
|
||||
) {
|
||||
return ticketStatus.name;
|
||||
}
|
||||
|
||||
if (ticketStatus.board_column.name === ticketStatus.name) {
|
||||
return ticketStatus.name;
|
||||
}
|
||||
|
||||
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
|
||||
};
|
||||
|
||||
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
||||
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
|
||||
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
|
||||
return (
|
||||
allowedStatusesForColumn(draggedTicket.value.ticket, boardColumnId)
|
||||
.length > 0
|
||||
);
|
||||
};
|
||||
|
||||
const isActiveDropTarget = (
|
||||
boardColumnId: number,
|
||||
ticketStatusId?: number,
|
||||
): boolean => {
|
||||
if (dropTarget.value?.boardColumnId !== boardColumnId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
ticketStatusId === undefined ||
|
||||
dropTarget.value.ticketStatusId === ticketStatusId
|
||||
);
|
||||
};
|
||||
|
||||
const dropZoneRows = (ticket: TicketModel, boardColumnId: number): string =>
|
||||
`repeat(${allowedStatusesForColumn(ticket, boardColumnId).length}, minmax(0, 1fr))`;
|
||||
|
||||
const clearLongPressTimer = (): void => {
|
||||
if (longPressTimer !== undefined) {
|
||||
window.clearTimeout(longPressTimer);
|
||||
longPressTimer = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const stopAutoScroll = (): void => {
|
||||
if (autoScrollFrame !== undefined) {
|
||||
window.cancelAnimationFrame(autoScrollFrame);
|
||||
autoScrollFrame = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const runAutoScroll = (): void => {
|
||||
const scroller = boardScroller.value;
|
||||
|
||||
if (draggedTicket.value === null || scroller === null) {
|
||||
stopAutoScroll();
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = scroller.getBoundingClientRect();
|
||||
const edgeSize = Math.min(88, bounds.width * 0.18);
|
||||
const maximumStep = 20;
|
||||
|
||||
if (pointerX.value < bounds.left + edgeSize) {
|
||||
const intensity =
|
||||
1 - Math.max(0, pointerX.value - bounds.left) / edgeSize;
|
||||
scroller.scrollLeft -= Math.max(5, maximumStep * intensity);
|
||||
} else if (pointerX.value > bounds.right - edgeSize) {
|
||||
const intensity =
|
||||
1 - Math.max(0, bounds.right - pointerX.value) / edgeSize;
|
||||
scroller.scrollLeft += Math.max(5, maximumStep * intensity);
|
||||
}
|
||||
|
||||
autoScrollFrame = window.requestAnimationFrame(runAutoScroll);
|
||||
};
|
||||
|
||||
const startDragging = (): void => {
|
||||
if (pendingDragTicket.value === null || draggedTicket.value !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearLongPressTimer();
|
||||
draggedTicket.value = pendingDragTicket.value;
|
||||
suppressClickTicketId = pendingDragTicket.value.ticket.id;
|
||||
document.body.classList.add('select-none');
|
||||
autoScrollFrame = window.requestAnimationFrame(runAutoScroll);
|
||||
};
|
||||
|
||||
const updateDropTarget = (clientX: number, clientY: number): void => {
|
||||
const drag = draggedTicket.value;
|
||||
const element = document.elementFromPoint(clientX, clientY);
|
||||
const columnElement = element?.closest<HTMLElement>(
|
||||
'[data-board-column-id]',
|
||||
);
|
||||
|
||||
if (
|
||||
drag === null ||
|
||||
columnElement === undefined ||
|
||||
columnElement === null
|
||||
) {
|
||||
dropTarget.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const boardColumnId = Number(columnElement.dataset.boardColumnId);
|
||||
const allowedStatuses = allowedStatusesForColumn(
|
||||
drag.ticket,
|
||||
boardColumnId,
|
||||
);
|
||||
const statusElement = element?.closest<HTMLElement>(
|
||||
'[data-ticket-status-id]',
|
||||
);
|
||||
const statusId = Number(statusElement?.dataset.ticketStatusId);
|
||||
|
||||
if (
|
||||
statusElement !== undefined &&
|
||||
statusElement !== null &&
|
||||
allowedStatuses.some((ticketStatus) => ticketStatus.id === statusId)
|
||||
) {
|
||||
dropTarget.value = {
|
||||
boardColumnId,
|
||||
ticketStatusId: statusId,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
dropTarget.value =
|
||||
allowedStatuses.length === 1
|
||||
? {
|
||||
boardColumnId,
|
||||
ticketStatusId: allowedStatuses[0].id,
|
||||
}
|
||||
: null;
|
||||
};
|
||||
|
||||
const handlePointerDown = (
|
||||
event: PointerEvent,
|
||||
ticket: TicketModel,
|
||||
boardColumn: BoardColumnModel,
|
||||
): void => {
|
||||
if (
|
||||
!props.canEditTickets ||
|
||||
movingTicketId.value !== null ||
|
||||
event.button !== 0 ||
|
||||
allowedBoardTargetStatuses(ticket).length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
pointerX.value = event.clientX;
|
||||
pointerY.value = event.clientY;
|
||||
pendingDragTicket.value = {
|
||||
originColumnId: boardColumn.id,
|
||||
ticket,
|
||||
};
|
||||
dragPointer.value = {
|
||||
pointerId: event.pointerId,
|
||||
pointerType: event.pointerType,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove, {
|
||||
passive: false,
|
||||
});
|
||||
window.addEventListener('pointerup', finishPointer);
|
||||
window.addEventListener('pointercancel', cancelPointer);
|
||||
|
||||
if (event.pointerType === 'touch') {
|
||||
longPressTimer = window.setTimeout(startDragging, 240);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent): void => {
|
||||
const pointer = dragPointer.value;
|
||||
|
||||
if (pointer === null || pointer.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
pointerX.value = event.clientX;
|
||||
pointerY.value = event.clientY;
|
||||
|
||||
const distance = Math.hypot(
|
||||
event.clientX - pointer.startX,
|
||||
event.clientY - pointer.startY,
|
||||
);
|
||||
|
||||
if (draggedTicket.value === null) {
|
||||
if (pointer.pointerType === 'touch') {
|
||||
if (distance > 10) {
|
||||
clearLongPressTimer();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (distance < 7) {
|
||||
return;
|
||||
}
|
||||
|
||||
startDragging();
|
||||
}
|
||||
|
||||
if (draggedTicket.value !== null) {
|
||||
event.preventDefault();
|
||||
updateDropTarget(event.clientX, event.clientY);
|
||||
}
|
||||
};
|
||||
|
||||
const resetDragState = (): void => {
|
||||
clearLongPressTimer();
|
||||
stopAutoScroll();
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', finishPointer);
|
||||
window.removeEventListener('pointercancel', cancelPointer);
|
||||
pendingDragTicket.value = null;
|
||||
draggedTicket.value = null;
|
||||
dragPointer.value = null;
|
||||
dropTarget.value = null;
|
||||
document.body.classList.remove('select-none');
|
||||
};
|
||||
|
||||
const moveTicket = (drag: BoardDragTicket, target: BoardDropTarget): void => {
|
||||
moveError.value = null;
|
||||
movingTicketId.value = drag.ticket.id;
|
||||
|
||||
router.patch(
|
||||
ProjectBoardController.move.url({
|
||||
project: props.project.id,
|
||||
ticket: drag.ticket.id,
|
||||
}),
|
||||
{
|
||||
redirect_to: 'board',
|
||||
ticket_status_id: target.ticketStatusId,
|
||||
},
|
||||
{
|
||||
onError: (errors) => {
|
||||
moveError.value =
|
||||
Object.values(errors)[0] ??
|
||||
'The ticket could not be moved. Please try again.';
|
||||
},
|
||||
onFinish: () => {
|
||||
movingTicketId.value = null;
|
||||
},
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const finishPointer = (event: PointerEvent): void => {
|
||||
const pointer = dragPointer.value;
|
||||
const drag = draggedTicket.value;
|
||||
const target = dropTarget.value;
|
||||
|
||||
if (pointer === null || pointer.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
resetDragState();
|
||||
|
||||
if (drag !== null) {
|
||||
window.setTimeout(() => {
|
||||
suppressClickTicketId = null;
|
||||
}, 300);
|
||||
|
||||
if (target !== null) {
|
||||
moveTicket(drag, target);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cancelPointer = (event: PointerEvent): void => {
|
||||
if (dragPointer.value?.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
resetDragState();
|
||||
suppressClickTicketId = null;
|
||||
};
|
||||
|
||||
const handleTicketClick = (event: MouseEvent, ticketId: number): void => {
|
||||
if (suppressClickTicketId !== ticketId) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initialScrollFrame = window.requestAnimationFrame(() => {
|
||||
if (boardScroller.value !== null) {
|
||||
boardScroller.value.scrollLeft = 0;
|
||||
}
|
||||
|
||||
initialScrollFrame = undefined;
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (initialScrollFrame !== undefined) {
|
||||
window.cancelAnimationFrame(initialScrollFrame);
|
||||
}
|
||||
|
||||
resetDragState();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
|
||||
<Head :title="`${project.name} board`" />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
<div
|
||||
class="app-page min-h-[calc(100dvh-3.5rem)] max-w-none gap-5 overflow-hidden"
|
||||
>
|
||||
<ProjectPageHeader
|
||||
:project="project"
|
||||
context="Project board"
|
||||
description="Move tickets through the project's active workflow."
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<Badge variant="outline" class="px-3 py-1">
|
||||
{{ project.key }}
|
||||
</Badge>
|
||||
<Heading
|
||||
:title="`${project.name} board`"
|
||||
description="Tickets only appear here once they are assigned to one of the project's workflow columns."
|
||||
/>
|
||||
</div>
|
||||
<Button v-if="canCreateTickets" as-child size="sm">
|
||||
<Link
|
||||
:href="
|
||||
createTicket(
|
||||
{ project: project.id },
|
||||
{ query: { source: 'board' } },
|
||||
)
|
||||
"
|
||||
>
|
||||
<Plus class="size-4" />
|
||||
Create ticket
|
||||
</Link>
|
||||
</Button>
|
||||
</ProjectPageHeader>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button v-if="canCreateTickets" as-child>
|
||||
<Link
|
||||
:href="
|
||||
createTicket(
|
||||
{ project: project.id },
|
||||
{ query: { source: 'board' } },
|
||||
)
|
||||
"
|
||||
>
|
||||
Create ticket
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="backlogIndex(project.id)"
|
||||
>Open backlog</Link
|
||||
>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="show(project.id)">Back to project</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<ProjectTabs
|
||||
active="board"
|
||||
:can-manage-project="canManageProject"
|
||||
:project="project"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status"
|
||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||
>
|
||||
<div v-if="status" class="status-success">
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<Card v-if="boardColumns.length === 0">
|
||||
<CardContent class="flex flex-col gap-4 p-6 text-sm">
|
||||
<p class="text-muted-foreground">
|
||||
The board has no workflow columns yet, so no tickets can
|
||||
appear here.
|
||||
</p>
|
||||
<div v-if="canEditTickets">
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="boardSettings(project.id)">
|
||||
Open board settings
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div
|
||||
v-if="moveError"
|
||||
class="rounded border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-200"
|
||||
>
|
||||
{{ moveError }}
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-4 xl:grid-cols-3">
|
||||
<Card
|
||||
<div
|
||||
v-if="boardColumns.length === 0"
|
||||
class="empty-state flex min-h-[calc(100dvh-15rem)] flex-col items-center justify-center gap-4"
|
||||
>
|
||||
<p>The board has no workflow columns yet.</p>
|
||||
<Button v-if="canManageProject" as-child variant="outline">
|
||||
<Link
|
||||
:href="
|
||||
settingsIndex(project.id, {
|
||||
query: { section: 'board' },
|
||||
})
|
||||
"
|
||||
>
|
||||
Configure board
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
ref="boardScroller"
|
||||
class="flex min-h-[max(28rem,calc(100dvh-14rem))] min-w-0 flex-1 snap-x snap-proximity items-stretch gap-3 overflow-x-auto overscroll-x-contain pb-3"
|
||||
>
|
||||
<section
|
||||
v-for="boardColumn in boardColumns"
|
||||
:key="boardColumn.id"
|
||||
class="flex h-full flex-col gap-0 overflow-hidden"
|
||||
:data-board-column-id="boardColumn.id"
|
||||
:class="[
|
||||
'bg-surface-container-lowest relative flex min-h-full w-[min(21rem,calc(100vw-2.5rem))] shrink-0 snap-start flex-col rounded border transition-[border-color,background-color,filter,opacity,box-shadow] duration-150 sm:w-[21rem]',
|
||||
draggedTicket === null
|
||||
? 'border-outline-variant/70'
|
||||
: isAllowedColumn(boardColumn.id)
|
||||
? isActiveDropTarget(boardColumn.id)
|
||||
? 'border-tertiary bg-tertiary/5 ring-tertiary/30 shadow-lg ring-2'
|
||||
: 'border-primary/80 bg-primary/3 ring-primary/15 ring-1'
|
||||
: 'border-outline-variant/40 bg-muted/40 opacity-45 grayscale',
|
||||
]"
|
||||
>
|
||||
<CardHeader class="bg-muted/20 border-b">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>{{ boardColumn.name }}</CardTitle>
|
||||
<CardDescription>
|
||||
<header
|
||||
class="bg-surface-container-lowest/95 border-outline-variant/70 sticky top-0 z-10 rounded-t border-b px-3.5 py-3 backdrop-blur"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h2 class="truncate text-sm font-bold">
|
||||
{{ boardColumn.name }}
|
||||
</h2>
|
||||
<p class="text-muted-foreground mt-0.5 text-xs">
|
||||
{{ boardColumn.tickets?.length ?? 0 }}
|
||||
active ticket(s)
|
||||
</CardDescription>
|
||||
<div
|
||||
v-if="
|
||||
(boardColumn.ticket_statuses?.length ??
|
||||
0) > 0
|
||||
"
|
||||
class="mt-3 flex flex-wrap gap-2"
|
||||
>
|
||||
<Badge
|
||||
v-for="ticketStatus in boardColumn.ticket_statuses"
|
||||
:key="ticketStatus.id"
|
||||
variant="outline"
|
||||
>
|
||||
{{ ticketStatus.name }}
|
||||
</Badge>
|
||||
</div>
|
||||
tickets
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Badge variant="secondary">
|
||||
#{{ boardColumn.position }}
|
||||
<Badge variant="secondary" class="rounded">
|
||||
{{ boardColumn.tickets?.length ?? 0 }}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</header>
|
||||
|
||||
<CardContent class="flex flex-1 flex-col gap-4 p-4">
|
||||
<div class="relative flex flex-1 flex-col gap-2.5 p-2.5">
|
||||
<div
|
||||
v-if="(boardColumn.tickets?.length ?? 0) === 0"
|
||||
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
||||
v-if="
|
||||
draggedTicket !== null &&
|
||||
allowedStatusesForColumn(
|
||||
draggedTicket.ticket,
|
||||
boardColumn.id,
|
||||
).length > 0
|
||||
"
|
||||
class="absolute inset-2.5 z-20 grid gap-2"
|
||||
:style="{
|
||||
gridTemplateRows: dropZoneRows(
|
||||
draggedTicket.ticket,
|
||||
boardColumn.id,
|
||||
),
|
||||
}"
|
||||
>
|
||||
No tickets are currently in this column.
|
||||
<div
|
||||
v-for="ticketStatus in allowedStatusesForColumn(
|
||||
draggedTicket.ticket,
|
||||
boardColumn.id,
|
||||
)"
|
||||
:key="ticketStatus.id"
|
||||
:data-ticket-status-id="ticketStatus.id"
|
||||
:class="[
|
||||
'board-drop-zone flex min-h-0 items-center justify-center rounded border-2 border-dashed p-4 text-center text-xs font-bold transition-[border-color,background-color,box-shadow,color] duration-150',
|
||||
isActiveDropTarget(
|
||||
boardColumn.id,
|
||||
ticketStatus.id,
|
||||
)
|
||||
? 'board-drop-zone-active border-tertiary text-on-tertiary-container ring-tertiary/30 dark:text-tertiary-fixed ring-2'
|
||||
: 'border-primary/70 text-on-primary-container dark:text-primary-fixed',
|
||||
]"
|
||||
>
|
||||
Drop as {{ ticketStatus.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<Card
|
||||
<div
|
||||
v-if="(boardColumn.tickets?.length ?? 0) === 0"
|
||||
class="text-muted-foreground flex min-h-28 flex-1 items-center justify-center rounded border border-dashed p-4 text-center text-xs"
|
||||
>
|
||||
No tickets in this column.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<article
|
||||
v-for="ticket in boardColumn.tickets"
|
||||
:key="ticket.id"
|
||||
:class="[
|
||||
'gap-0',
|
||||
'bg-card shadow-xs hover:border-primary/40 group relative rounded border transition-[border-color,box-shadow,opacity,transform] hover:shadow-sm',
|
||||
ticket.is_blocked
|
||||
? 'border-amber-300 bg-amber-50/70 dark:border-amber-700/60 dark:bg-amber-950/30'
|
||||
? 'border-amber-300 dark:border-amber-800'
|
||||
: 'border-outline-variant/70',
|
||||
canEditTickets &&
|
||||
allowedBoardTargetStatuses(ticket).length >
|
||||
0
|
||||
? 'cursor-grab active:cursor-grabbing'
|
||||
: '',
|
||||
draggedTicket?.ticket.id === ticket.id
|
||||
? 'border-primary scale-[0.99] opacity-30'
|
||||
: '',
|
||||
movingTicketId === ticket.id
|
||||
? 'pointer-events-none animate-pulse opacity-55'
|
||||
: '',
|
||||
]"
|
||||
>
|
||||
<CardHeader class="pb-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">
|
||||
{{ ticket.ticket_key }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="ticket.is_blocked"
|
||||
variant="destructive"
|
||||
<Link
|
||||
:href="
|
||||
showTicket({
|
||||
project: project.id,
|
||||
ticket: ticket.id,
|
||||
})
|
||||
"
|
||||
class="block touch-pan-y select-none p-3.5"
|
||||
draggable="false"
|
||||
@pointerdown="
|
||||
handlePointerDown(
|
||||
$event,
|
||||
ticket,
|
||||
boardColumn,
|
||||
)
|
||||
"
|
||||
@click="
|
||||
handleTicketClick($event, ticket.id)
|
||||
"
|
||||
@dragstart.prevent
|
||||
>
|
||||
<div
|
||||
class="flex items-start justify-between gap-2"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-1.5"
|
||||
>
|
||||
Blocked
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{{ ticket.ticket_type.name }}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{{ ticket.ticket_priority.name }}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{{
|
||||
ticketStatusLabel(
|
||||
ticket.ticket_status,
|
||||
)
|
||||
}}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardTitle class="text-base">
|
||||
<Link
|
||||
:href="
|
||||
showTicket({
|
||||
project: project.id,
|
||||
ticket: ticket.id,
|
||||
})
|
||||
<span
|
||||
class="text-primary text-[0.68rem] font-bold uppercase tracking-wide"
|
||||
>
|
||||
{{ ticket.ticket_key }}
|
||||
</span>
|
||||
<Badge
|
||||
v-if="ticket.is_blocked"
|
||||
variant="destructive"
|
||||
class="rounded text-[0.65rem]"
|
||||
>
|
||||
Blocked
|
||||
</Badge>
|
||||
</div>
|
||||
<GripVertical
|
||||
v-if="
|
||||
canEditTickets &&
|
||||
allowedBoardTargetStatuses(
|
||||
ticket,
|
||||
).length > 0
|
||||
"
|
||||
class="hover:text-primary transition"
|
||||
>
|
||||
{{ ticket.title }}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<CardDescription
|
||||
class="text-muted-foreground/50 size-4 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
class="group-hover:text-primary mt-2 text-sm font-semibold leading-snug transition-colors"
|
||||
>
|
||||
{{ ticket.title }}
|
||||
</h3>
|
||||
<p
|
||||
v-if="ticket.description"
|
||||
class="line-clamp-3"
|
||||
class="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed"
|
||||
>
|
||||
{{ ticket.description }}
|
||||
</CardDescription>
|
||||
<CardDescription
|
||||
v-if="
|
||||
ticket.is_blocked &&
|
||||
ticket.blocked_comment
|
||||
"
|
||||
class="mt-2 text-amber-900 dark:text-amber-200"
|
||||
>
|
||||
{{ ticket.blocked_comment }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</p>
|
||||
|
||||
<CardContent v-if="canEditTickets" class="pt-0">
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardController.move.form({
|
||||
project: project.id,
|
||||
ticket: ticket.id,
|
||||
})
|
||||
"
|
||||
class="flex flex-col gap-2"
|
||||
v-slot="{ processing }"
|
||||
<div
|
||||
class="border-outline-variant/60 mt-3 flex items-center justify-between gap-3 border-t pt-3"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="redirect_to"
|
||||
value="board"
|
||||
/>
|
||||
<label
|
||||
:for="`ticket-status-${ticket.id}`"
|
||||
class="text-muted-foreground text-xs font-medium uppercase tracking-wide"
|
||||
>
|
||||
Move ticket
|
||||
</label>
|
||||
<div
|
||||
class="flex flex-col gap-2 sm:flex-row"
|
||||
class="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
<select
|
||||
:id="`ticket-status-${ticket.id}`"
|
||||
name="ticket_status_id"
|
||||
:disabled="
|
||||
allowedTargetStatuses(
|
||||
ticket,
|
||||
).length === 0 || processing
|
||||
"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-w-44 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
<UserAvatar
|
||||
v-if="ticket.assignee"
|
||||
:user="ticket.assignee"
|
||||
class="size-6"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="border-outline-variant text-muted-foreground flex size-6 items-center justify-center rounded-full border border-dashed"
|
||||
>
|
||||
<option
|
||||
v-if="
|
||||
allowedTargetStatuses(
|
||||
ticket,
|
||||
).length === 0
|
||||
"
|
||||
value=""
|
||||
>
|
||||
No allowed moves
|
||||
</option>
|
||||
<option
|
||||
v-for="ticketStatus in allowedTargetStatuses(
|
||||
ticket,
|
||||
)"
|
||||
:key="ticketStatus.id"
|
||||
:value="ticketStatus.id"
|
||||
>
|
||||
{{
|
||||
ticketStatusLabel(
|
||||
ticketStatus,
|
||||
)
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
:disabled="
|
||||
processing ||
|
||||
allowedTargetStatuses(
|
||||
ticket,
|
||||
).length === 0
|
||||
"
|
||||
<CircleUserRound
|
||||
class="size-3.5"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="text-muted-foreground truncate text-[0.68rem]"
|
||||
>
|
||||
Update status
|
||||
</Button>
|
||||
{{
|
||||
ticket.assignee?.name ??
|
||||
'Unassigned'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="
|
||||
allowedTargetStatuses(ticket)
|
||||
.length === 0
|
||||
"
|
||||
class="text-muted-foreground text-xs"
|
||||
<div
|
||||
class="text-muted-foreground flex shrink-0 items-center gap-1.5 text-[0.68rem] font-medium"
|
||||
>
|
||||
This status does not allow moving
|
||||
anywhere else.
|
||||
</p>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<span
|
||||
class="bg-primary size-1.5 rounded-full"
|
||||
/>
|
||||
{{ ticket.ticket_priority.name }}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="draggedTicket"
|
||||
class="bg-card border-primary pointer-events-none fixed z-[100] w-72 -translate-x-1/2 -translate-y-1/2 rounded border px-4 py-3 shadow-2xl"
|
||||
:style="{
|
||||
left: `${pointerX}px`,
|
||||
top: `${pointerY}px`,
|
||||
}"
|
||||
>
|
||||
<div class="text-primary text-[0.68rem] font-bold uppercase">
|
||||
{{ draggedTicket.ticket.ticket_key }}
|
||||
</div>
|
||||
<div class="mt-1 line-clamp-2 text-sm font-semibold">
|
||||
{{ draggedTicket.ticket.title }}
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.board-drop-zone {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--primary) 8%,
|
||||
var(--surface-container-lowest)
|
||||
);
|
||||
background-image:
|
||||
linear-gradient(
|
||||
color-mix(in srgb, var(--primary) 12%, transparent) 1px,
|
||||
transparent 1px
|
||||
),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--primary) 12%, transparent) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.board-drop-zone-active {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--tertiary) 14%,
|
||||
var(--surface-container-lowest)
|
||||
);
|
||||
background-image:
|
||||
linear-gradient(
|
||||
color-mix(in srgb, var(--tertiary) 20%, transparent) 1px,
|
||||
transparent 1px
|
||||
),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--tertiary) 20%, transparent) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
box-shadow: inset 0 0 0 1px
|
||||
color-mix(in srgb, var(--tertiary) 22%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -31,15 +31,19 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const defaultTemplateId = props.selectedSourceTemplateId ?? props.sourceTemplates[0]?.id ?? null;
|
||||
const defaultTemplateId =
|
||||
props.selectedSourceTemplateId ?? props.sourceTemplates[0]?.id ?? null;
|
||||
|
||||
const form = useForm({
|
||||
copy_areas: [] as string[],
|
||||
creation_mode: defaultTemplateId !== null ? 'template' : 'scratch',
|
||||
key: '',
|
||||
name: '',
|
||||
source_project_id: props.sourceProjects[0]?.id ? String(props.sourceProjects[0].id) : '',
|
||||
source_template_id: defaultTemplateId !== null ? String(defaultTemplateId) : '',
|
||||
source_project_id: props.sourceProjects[0]?.id
|
||||
? String(props.sourceProjects[0].id)
|
||||
: '',
|
||||
source_template_id:
|
||||
defaultTemplateId !== null ? String(defaultTemplateId) : '',
|
||||
});
|
||||
|
||||
const hasSourceProjects = computed(() => props.sourceProjects.length > 0);
|
||||
@@ -48,7 +52,9 @@ const hasSourceTemplates = computed(() => props.sourceTemplates.length > 0);
|
||||
const copyAreasError = computed(() => {
|
||||
return (
|
||||
form.errors.copy_areas ??
|
||||
Object.entries(form.errors).find(([key]) => key.startsWith('copy_areas.'))?.[1] ??
|
||||
Object.entries(form.errors).find(([key]) =>
|
||||
key.startsWith('copy_areas.'),
|
||||
)?.[1] ??
|
||||
''
|
||||
);
|
||||
});
|
||||
@@ -73,7 +79,7 @@ const submit = (): void => {
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head title="New project" />
|
||||
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
||||
<div class="app-page-narrow max-w-3xl">
|
||||
<Heading
|
||||
title="Create project"
|
||||
description="Create a fresh project, duplicate selected settings from an existing project, or start from a saved template."
|
||||
@@ -93,7 +99,9 @@ const submit = (): void => {
|
||||
<div class="grid gap-3">
|
||||
<Label>How do you want to start?</Label>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<Label class="flex cursor-pointer items-start gap-3 rounded-xl border p-4">
|
||||
<Label
|
||||
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
|
||||
>
|
||||
<input
|
||||
v-model="form.creation_mode"
|
||||
type="radio"
|
||||
@@ -101,16 +109,25 @@ const submit = (): void => {
|
||||
class="mt-1"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium">Create from scratch</div>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Start with the default project setup.
|
||||
<div class="font-medium">
|
||||
Create from scratch
|
||||
</div>
|
||||
<p
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
Start with the default project
|
||||
setup.
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<Label
|
||||
class="flex items-start gap-3 rounded-xl border p-4"
|
||||
:class="hasSourceProjects ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
|
||||
:class="
|
||||
hasSourceProjects
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-not-allowed opacity-60'
|
||||
"
|
||||
>
|
||||
<input
|
||||
v-model="form.creation_mode"
|
||||
@@ -120,16 +137,26 @@ const submit = (): void => {
|
||||
:disabled="!hasSourceProjects"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium">Create from existing project</div>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Copy only the settings you explicitly select. Tickets are never duplicated.
|
||||
<div class="font-medium">
|
||||
Create from existing project
|
||||
</div>
|
||||
<p
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
Copy only the settings you
|
||||
explicitly select. Tickets are never
|
||||
duplicated.
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<Label
|
||||
class="flex items-start gap-3 rounded-xl border p-4"
|
||||
:class="hasSourceTemplates ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
|
||||
:class="
|
||||
hasSourceTemplates
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-not-allowed opacity-60'
|
||||
"
|
||||
>
|
||||
<input
|
||||
v-model="form.creation_mode"
|
||||
@@ -139,9 +166,14 @@ const submit = (): void => {
|
||||
:disabled="!hasSourceTemplates"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium">Create from template</div>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Reuse a saved configuration template.
|
||||
<div class="font-medium">
|
||||
Create from template
|
||||
</div>
|
||||
<p
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
Reuse a saved configuration
|
||||
template.
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
@@ -172,7 +204,9 @@ const submit = (): void => {
|
||||
/>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Example ticket keys will look like
|
||||
<span class="text-foreground font-medium">CORE-1</span>.
|
||||
<span class="text-foreground font-medium"
|
||||
>CORE-1</span
|
||||
>.
|
||||
</p>
|
||||
<InputError :message="form.errors.key" />
|
||||
</div>
|
||||
@@ -182,33 +216,43 @@ const submit = (): void => {
|
||||
class="space-y-6 rounded-xl border border-dashed p-4"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="source_project_id">Source project</Label>
|
||||
<Label for="source_project_id"
|
||||
>Source project</Label
|
||||
>
|
||||
<select
|
||||
id="source_project_id"
|
||||
v-model="form.source_project_id"
|
||||
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!hasSourceProjects"
|
||||
>
|
||||
<option value="">Select a source project</option>
|
||||
<option value="">
|
||||
Select a source project
|
||||
</option>
|
||||
<option
|
||||
v-for="sourceProject in sourceProjects"
|
||||
:key="sourceProject.id"
|
||||
:value="String(sourceProject.id)"
|
||||
>
|
||||
{{ sourceProject.name }} ({{ sourceProject.key }})
|
||||
{{ sourceProject.name }} ({{
|
||||
sourceProject.key
|
||||
}})
|
||||
</option>
|
||||
</select>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
The new project keeps its own identity, key, and future ticket numbering.
|
||||
The new project keeps its own identity, key,
|
||||
and future ticket numbering.
|
||||
</p>
|
||||
<InputError :message="form.errors.source_project_id" />
|
||||
<InputError
|
||||
:message="form.errors.source_project_id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<div>
|
||||
<Label>Copy options</Label>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Choose which settings should be copied into the new project.
|
||||
Choose which settings should be copied
|
||||
into the new project.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -225,8 +269,12 @@ const submit = (): void => {
|
||||
class="mt-1"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium">{{ copyAreaOption.label }}</div>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
<div class="font-medium">
|
||||
{{ copyAreaOption.label }}
|
||||
</div>
|
||||
<p
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
{{ copyAreaOption.description }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -234,7 +282,8 @@ const submit = (): void => {
|
||||
</div>
|
||||
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Operational ticket data is always left behind so the new project starts clean.
|
||||
Operational ticket data is always left
|
||||
behind so the new project starts clean.
|
||||
</p>
|
||||
<InputError :message="copyAreasError" />
|
||||
</div>
|
||||
@@ -249,7 +298,7 @@ const submit = (): void => {
|
||||
<select
|
||||
id="source_template_id"
|
||||
v-model="form.source_template_id"
|
||||
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!hasSourceTemplates"
|
||||
>
|
||||
<option value="">Select a template</option>
|
||||
@@ -258,21 +307,29 @@ const submit = (): void => {
|
||||
:key="sourceTemplate.id"
|
||||
:value="String(sourceTemplate.id)"
|
||||
>
|
||||
{{ sourceTemplate.name }} ({{ sourceTemplate.slug }})
|
||||
{{ sourceTemplate.name }} ({{
|
||||
sourceTemplate.slug
|
||||
}})
|
||||
</option>
|
||||
</select>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Templates apply their saved configuration groups to the new project.
|
||||
Templates apply their saved configuration
|
||||
groups to the new project.
|
||||
</p>
|
||||
<InputError :message="form.errors.source_template_id" />
|
||||
<InputError
|
||||
:message="form.errors.source_template_id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!hasSourceProjects && !hasSourceTemplates"
|
||||
v-else-if="
|
||||
!hasSourceProjects && !hasSourceTemplates
|
||||
"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
No accessible source projects or saved templates are available yet.
|
||||
No accessible source projects or saved templates are
|
||||
available yet.
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { create, index, show } from '@/routes/projects/index';
|
||||
import { create, index } from '@/routes/projects/index';
|
||||
import { index as boardIndex } from '@/routes/projects/board';
|
||||
import type { BreadcrumbItem, ProjectsIndexPageProps } from '@/types';
|
||||
|
||||
defineProps<ProjectsIndexPageProps>();
|
||||
@@ -29,13 +30,13 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head title="Projects" />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div class="app-page">
|
||||
<div
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<Heading
|
||||
title="Projects"
|
||||
description="Create and browse project spaces that will later host boards, backlog, and settings."
|
||||
description="Browse project spaces with their own board, backlog, workflow, repositories, and access."
|
||||
/>
|
||||
|
||||
<Button
|
||||
@@ -49,17 +50,11 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status"
|
||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||
>
|
||||
<div v-if="status" class="status-success">
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="projects.length === 0"
|
||||
class="text-muted-foreground rounded-xl border border-dashed px-6 py-10 text-center text-sm"
|
||||
>
|
||||
<div v-if="projects.length === 0" class="empty-state">
|
||||
No projects exist yet. Create the first one to start building
|
||||
out board, backlog, and settings areas.
|
||||
</div>
|
||||
@@ -68,13 +63,11 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
<Link
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
:href="show(project.id)"
|
||||
:href="boardIndex(project.id)"
|
||||
class="group block"
|
||||
>
|
||||
<Card
|
||||
class="border-border/80 group-hover:border-primary/40 h-full transition-colors"
|
||||
>
|
||||
<CardHeader class="gap-3">
|
||||
<Card class="group-hover:border-primary/40 h-full py-0">
|
||||
<CardHeader class="gap-3 p-5">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<CardTitle>{{ project.name }}</CardTitle>
|
||||
@@ -90,10 +83,10 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
</CardHeader>
|
||||
|
||||
<CardContent
|
||||
class="text-muted-foreground flex items-center gap-2 text-sm"
|
||||
class="border-outline-variant/60 text-muted-foreground flex items-center gap-2 border-t px-5 py-4 text-sm"
|
||||
>
|
||||
<Folder class="size-4" />
|
||||
Open board, backlog, and settings hub
|
||||
Open project board
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
import {
|
||||
BellRing,
|
||||
GitBranch,
|
||||
KanbanSquare,
|
||||
LayoutTemplate,
|
||||
Settings2,
|
||||
ShieldCheck,
|
||||
Tags,
|
||||
} from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import ProjectPageHeader from '@/components/projects/ProjectPageHeader.vue';
|
||||
import ProjectTabs from '@/components/projects/ProjectTabs.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { index as projectsIndex } from '@/routes/projects';
|
||||
import { index as boardIndex } from '@/routes/projects/board';
|
||||
import { index as settingsIndex } from '@/routes/projects/settings';
|
||||
import { create as projectTemplateCreate } from '@/routes/projects/templates';
|
||||
import type {
|
||||
BreadcrumbItem,
|
||||
EmbeddedProjectSettingsSectionProps,
|
||||
ProjectAccessPageProps,
|
||||
ProjectBoardSettingsPageProps,
|
||||
ProjectGeneralSettingsPageProps,
|
||||
ProjectNotificationSettingsPageProps,
|
||||
ProjectRepositorySettingsPageProps,
|
||||
ProjectSettingsPageProps,
|
||||
ProjectSettingsSection,
|
||||
ProjectTicketMetadataSettingsPageProps,
|
||||
} from '@/types';
|
||||
import AccessSection from './Access.vue';
|
||||
import BoardSection from './settings/Board.vue';
|
||||
import GeneralSection from './settings/General.vue';
|
||||
import MetadataSection from './settings/Metadata.vue';
|
||||
import NotificationsSection from './settings/Notifications.vue';
|
||||
import RepositoriesSection from './settings/Repositories.vue';
|
||||
|
||||
const props = defineProps<ProjectSettingsPageProps>();
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Projects',
|
||||
href: projectsIndex(),
|
||||
},
|
||||
{
|
||||
title: props.project.name,
|
||||
href: boardIndex(props.project.id),
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
href: settingsIndex(props.project.id),
|
||||
},
|
||||
];
|
||||
|
||||
const settingsSections = [
|
||||
{
|
||||
description: 'Project identity, templates, and deletion',
|
||||
icon: Settings2,
|
||||
label: 'General',
|
||||
value: 'general' as const,
|
||||
},
|
||||
{
|
||||
description: 'Columns, statuses, and transitions',
|
||||
icon: KanbanSquare,
|
||||
label: 'Board',
|
||||
value: 'board' as const,
|
||||
},
|
||||
{
|
||||
description: 'Types, priorities, fields, and links',
|
||||
icon: Tags,
|
||||
label: 'Metadata',
|
||||
value: 'metadata' as const,
|
||||
},
|
||||
{
|
||||
description: 'Linked source repositories',
|
||||
icon: GitBranch,
|
||||
label: 'Repositories',
|
||||
value: 'repositories' as const,
|
||||
},
|
||||
{
|
||||
description: 'Webhook and Discord automation',
|
||||
icon: BellRing,
|
||||
label: 'Notifications',
|
||||
value: 'notifications' as const,
|
||||
},
|
||||
{
|
||||
description: 'User and team permissions',
|
||||
icon: ShieldCheck,
|
||||
label: 'Access',
|
||||
value: 'access' as const,
|
||||
},
|
||||
];
|
||||
|
||||
const activeSectionDetails = computed(
|
||||
() =>
|
||||
settingsSections.find(
|
||||
(section) => section.value === props.activeSection,
|
||||
) ?? settingsSections[0],
|
||||
);
|
||||
|
||||
const accessSectionData = computed(
|
||||
() =>
|
||||
props.sectionData as EmbeddedProjectSettingsSectionProps<ProjectAccessPageProps>,
|
||||
);
|
||||
const boardSectionData = computed(
|
||||
() =>
|
||||
props.sectionData as EmbeddedProjectSettingsSectionProps<ProjectBoardSettingsPageProps>,
|
||||
);
|
||||
const generalSectionData = computed(
|
||||
() =>
|
||||
props.sectionData as EmbeddedProjectSettingsSectionProps<ProjectGeneralSettingsPageProps>,
|
||||
);
|
||||
const metadataSectionData = computed(
|
||||
() =>
|
||||
props.sectionData as EmbeddedProjectSettingsSectionProps<ProjectTicketMetadataSettingsPageProps>,
|
||||
);
|
||||
const notificationsSectionData = computed(
|
||||
() =>
|
||||
props.sectionData as EmbeddedProjectSettingsSectionProps<ProjectNotificationSettingsPageProps>,
|
||||
);
|
||||
const repositoriesSectionData = computed(
|
||||
() =>
|
||||
props.sectionData as EmbeddedProjectSettingsSectionProps<ProjectRepositorySettingsPageProps>,
|
||||
);
|
||||
|
||||
const sectionHref = (section: ProjectSettingsSection) =>
|
||||
settingsIndex(props.project.id, { query: { section } });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
|
||||
<Head :title="`${project.name} settings`" />
|
||||
|
||||
<div class="app-page max-w-none gap-5">
|
||||
<ProjectPageHeader
|
||||
:project="project"
|
||||
context="Project settings"
|
||||
description="Project configuration and access controls."
|
||||
>
|
||||
<Button
|
||||
v-if="
|
||||
activeSection === 'general' &&
|
||||
$page.props.auth.permissions.projectCreate
|
||||
"
|
||||
as-child
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Link :href="projectTemplateCreate(project.id)">
|
||||
<LayoutTemplate class="size-4" />
|
||||
Save as template
|
||||
</Link>
|
||||
</Button>
|
||||
</ProjectPageHeader>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<ProjectTabs
|
||||
active="settings"
|
||||
:can-manage-project="true"
|
||||
:project="project"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status"
|
||||
:class="
|
||||
error
|
||||
? 'rounded border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-200'
|
||||
: 'status-success'
|
||||
"
|
||||
>
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<div class="grid min-w-0 gap-5 xl:grid-cols-[15rem_minmax(0,1fr)]">
|
||||
<nav
|
||||
aria-label="Project settings"
|
||||
class="border-outline-variant/70 bg-surface-container-lowest flex gap-1 overflow-x-auto rounded-md border p-1.5 xl:sticky xl:top-20 xl:flex-col xl:self-start xl:overflow-visible"
|
||||
>
|
||||
<Link
|
||||
v-for="section in settingsSections"
|
||||
:key="section.value"
|
||||
:href="sectionHref(section.value)"
|
||||
preserve-scroll
|
||||
:class="[
|
||||
'flex min-w-max items-center gap-3 rounded px-3 py-2.5 text-left text-sm transition-colors xl:min-w-0',
|
||||
activeSection === section.value
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||
]"
|
||||
>
|
||||
<component :is="section.icon" class="size-4 shrink-0" />
|
||||
<span class="min-w-0">
|
||||
<span class="block">{{ section.label }}</span>
|
||||
<span
|
||||
class="mt-0.5 hidden text-xs font-normal opacity-75 xl:block"
|
||||
>
|
||||
{{ section.description }}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<section class="min-w-0 space-y-5">
|
||||
<div class="border-outline-variant/70 border-b pb-4">
|
||||
<h2 class="text-lg font-bold tracking-tight">
|
||||
{{ activeSectionDetails.label }}
|
||||
</h2>
|
||||
<p class="text-muted-foreground mt-1 text-sm">
|
||||
{{ activeSectionDetails.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<GeneralSection
|
||||
v-if="activeSection === 'general'"
|
||||
v-bind="generalSectionData"
|
||||
:project="project"
|
||||
:status="status"
|
||||
embedded
|
||||
/>
|
||||
<BoardSection
|
||||
v-else-if="activeSection === 'board'"
|
||||
v-bind="boardSectionData"
|
||||
:project="project"
|
||||
:status="status"
|
||||
embedded
|
||||
/>
|
||||
<MetadataSection
|
||||
v-else-if="activeSection === 'metadata'"
|
||||
v-bind="metadataSectionData"
|
||||
:project="project"
|
||||
:status="status"
|
||||
:error="error"
|
||||
embedded
|
||||
/>
|
||||
<RepositoriesSection
|
||||
v-else-if="activeSection === 'repositories'"
|
||||
v-bind="repositoriesSectionData"
|
||||
:project="project"
|
||||
:status="status"
|
||||
embedded
|
||||
/>
|
||||
<NotificationsSection
|
||||
v-else-if="activeSection === 'notifications'"
|
||||
v-bind="notificationsSectionData"
|
||||
:project="project"
|
||||
:status="status"
|
||||
embedded
|
||||
/>
|
||||
<AccessSection
|
||||
v-else
|
||||
v-bind="accessSectionData"
|
||||
:project="project"
|
||||
:status="status"
|
||||
embedded
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -1,204 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
import { FolderGit2, LayoutGrid, ListTodo, Settings2 } from 'lucide-vue-next';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { access, index, show } from '@/routes/projects/index';
|
||||
import { index as boardIndex } from '@/routes/projects/board';
|
||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||
import {
|
||||
board as boardSettings,
|
||||
general as generalSettings,
|
||||
metadata as metadataSettings,
|
||||
notifications as notificationSettings,
|
||||
repositories as repositorySettings,
|
||||
} from '@/routes/projects/settings';
|
||||
import { create as projectTemplateCreate } from '@/routes/projects/templates';
|
||||
import type { BreadcrumbItem, ProjectShowPageProps } from '@/types';
|
||||
|
||||
const props = defineProps<ProjectShowPageProps>();
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Projects',
|
||||
href: index(),
|
||||
},
|
||||
{
|
||||
title: props.project.name,
|
||||
href: show(props.project.id),
|
||||
},
|
||||
];
|
||||
|
||||
const projectAreas = [
|
||||
{
|
||||
title: 'Backlog',
|
||||
description:
|
||||
'Create tickets, review incoming work, and move ready items into the board workflow.',
|
||||
icon: ListTodo,
|
||||
},
|
||||
{
|
||||
title: 'Board',
|
||||
description:
|
||||
'Track active work across the project-defined Kanban columns and update ticket status.',
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
{
|
||||
title: 'Project settings',
|
||||
description:
|
||||
'Manage board columns, ticket metadata, and the project-specific workflow configuration.',
|
||||
icon: Settings2,
|
||||
},
|
||||
{
|
||||
title: 'Repositories',
|
||||
description:
|
||||
'Link project repositories so tickets can later discover related branches and pull requests.',
|
||||
icon: FolderGit2,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="project.name" />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<Badge variant="outline" class="px-3 py-1">
|
||||
{{ project.key }}
|
||||
</Badge>
|
||||
<Heading
|
||||
:title="project.name"
|
||||
description="Project hub for the upcoming backlog, board, and settings work."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="boardIndex(project.id)">Open board</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="backlogIndex(project.id)">
|
||||
Open backlog
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="$page.props.auth.permissions.projectCreate"
|
||||
as-child
|
||||
variant="outline"
|
||||
>
|
||||
<Link :href="projectTemplateCreate(project.id)">
|
||||
Save as new template
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="index()">Back to projects</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card v-if="templateState?.inheritsFromTemplate">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Built from template {{ templateState.templateName }}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
This project keeps its template link. Inherited areas follow the template baseline; overridden areas were customized locally.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
v-for="area in templateState.inheritedAreas"
|
||||
:key="`inherited-${area.value}`"
|
||||
variant="outline"
|
||||
>
|
||||
{{ area.label }} · inherited
|
||||
</Badge>
|
||||
<Badge
|
||||
v-for="area in templateState.overriddenAreas"
|
||||
:key="`overridden-${area.value}`"
|
||||
>
|
||||
{{ area.label }} · overridden
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div v-if="canManageAccess" class="flex flex-wrap gap-3">
|
||||
<Button as-child>
|
||||
<Link :href="boardSettings(project.id)">
|
||||
Board settings
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="$page.props.auth.permissions.globalAdmin"
|
||||
as-child
|
||||
variant="outline"
|
||||
>
|
||||
<Link :href="generalSettings(project.id)">
|
||||
General settings
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="metadataSettings(project.id)">
|
||||
Ticket settings
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="repositorySettings(project.id)">
|
||||
Repository settings
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="notificationSettings(project.id)">
|
||||
Notification settings
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="access(project.id)">Manage access</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<Card
|
||||
v-for="area in projectAreas"
|
||||
:key="area.title"
|
||||
class="h-full"
|
||||
>
|
||||
<CardHeader>
|
||||
<div
|
||||
class="bg-muted text-foreground mb-3 flex size-10 items-center justify-center rounded-full"
|
||||
>
|
||||
<component :is="area.icon" class="size-5" />
|
||||
</div>
|
||||
<CardTitle>{{ area.title }}</CardTitle>
|
||||
<CardDescription>{{
|
||||
area.description
|
||||
}}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="text-muted-foreground text-sm">
|
||||
Use the project shortcuts above to open this area.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
+1488
-1166
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import AcceptanceCriteriaEditor from '@/components/projects/AcceptanceCriteriaEditor.vue';
|
||||
import ProjectTabs from '@/components/projects/ProjectTabs.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -16,13 +18,10 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import {
|
||||
buildAcceptanceCriteria,
|
||||
parseChecklistLines,
|
||||
} from '@/lib/acceptance-criteria';
|
||||
import { emptyAcceptanceCriteria } from '@/lib/acceptance-criteria';
|
||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||
import { index as boardIndex } from '@/routes/projects/board';
|
||||
import { index, show } from '@/routes/projects/index';
|
||||
import { index } from '@/routes/projects/index';
|
||||
import { metadata as metadataSettings } from '@/routes/projects/settings';
|
||||
import type { BreadcrumbItem, ProjectTicketCreatePageProps } from '@/types';
|
||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||
@@ -33,7 +32,8 @@ const returnHref =
|
||||
props.source === 'board'
|
||||
? boardIndex(props.project.id)
|
||||
: backlogIndex(props.project.id);
|
||||
const returnLabel = props.source === 'board' ? 'Back to board' : 'Back to backlog';
|
||||
const returnLabel =
|
||||
props.source === 'board' ? 'Back to board' : 'Back to backlog';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
@@ -42,7 +42,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
},
|
||||
{
|
||||
title: props.project.name,
|
||||
href: show(props.project.id),
|
||||
href: boardIndex(props.project.id),
|
||||
},
|
||||
{
|
||||
title: props.source === 'board' ? 'Board' : 'Backlog',
|
||||
@@ -61,10 +61,8 @@ const hasTicketMetadata =
|
||||
props.ticketPriorities.length > 0 &&
|
||||
props.ticketStatusOptions.length > 0;
|
||||
|
||||
const acceptanceCriteriaChecklist = ref('');
|
||||
|
||||
const form = useForm({
|
||||
acceptance_criteria: buildAcceptanceCriteria(acceptanceCriteriaChecklist.value),
|
||||
acceptance_criteria: emptyAcceptanceCriteria(),
|
||||
assignee_user_id: '',
|
||||
description: '',
|
||||
parent_ticket_id: '',
|
||||
@@ -77,10 +75,6 @@ const form = useForm({
|
||||
title: '',
|
||||
});
|
||||
|
||||
const acceptanceCriteriaPreviewItems = computed(() =>
|
||||
parseChecklistLines(acceptanceCriteriaChecklist.value),
|
||||
);
|
||||
|
||||
const acceptanceCriteriaError = computed(() => {
|
||||
return (
|
||||
form.errors.acceptance_criteria ??
|
||||
@@ -92,8 +86,6 @@ const acceptanceCriteriaError = computed(() => {
|
||||
});
|
||||
|
||||
const submit = (): void => {
|
||||
form.acceptance_criteria = buildAcceptanceCriteria(acceptanceCriteriaChecklist.value);
|
||||
|
||||
form.submit(ProjectTicketController.store({ project: props.project.id }), {
|
||||
preserveScroll: true,
|
||||
});
|
||||
@@ -116,10 +108,10 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
|
||||
<Head :title="`Create ${project.name} ticket`" />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div class="app-page-narrow max-w-5xl gap-5">
|
||||
<div
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
@@ -137,16 +129,18 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="returnHref">{{ returnLabel }}</Link>
|
||||
</Button>
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="show(project.id)">Back to project</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status"
|
||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<ProjectTabs
|
||||
:active="source"
|
||||
:can-manage-project="canManageProject"
|
||||
:project="project"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="status" class="status-success">
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
@@ -173,7 +167,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
<InputError :message="form.errors.title" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
|
||||
<div
|
||||
class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]"
|
||||
>
|
||||
<div class="grid gap-6">
|
||||
<div class="grid gap-2">
|
||||
<Label for="description">Description</Label>
|
||||
@@ -185,49 +181,19 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 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"
|
||||
placeholder="Describe the expected behavior and relevant context."
|
||||
/>
|
||||
<InputError :message="form.errors.description" />
|
||||
<InputError
|
||||
:message="form.errors.description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="acceptance_criteria_checklist">
|
||||
Acceptance criteria checklist
|
||||
</Label>
|
||||
<textarea
|
||||
id="acceptance_criteria_checklist"
|
||||
v-model="acceptanceCriteriaChecklist"
|
||||
rows="6"
|
||||
:disabled="form.processing"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 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"
|
||||
placeholder="[ ] User can sign in [x] Validation is shown for invalid credentials"
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Use <code>[ ]</code> / <code>[x]</code> for checklist items. Indent with two spaces for nested steps.
|
||||
</p>
|
||||
<InputError :message="acceptanceCriteriaError" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="acceptanceCriteriaPreviewItems.length > 0"
|
||||
class="border-border rounded-xl border p-4 text-sm"
|
||||
>
|
||||
<p class="mb-3 font-medium">
|
||||
Checklist preview
|
||||
</p>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="(item, index) in acceptanceCriteriaPreviewItems"
|
||||
:key="index"
|
||||
:style="{
|
||||
marginLeft: `${item.indent * 1.25}rem`,
|
||||
}"
|
||||
class="flex items-start gap-2"
|
||||
>
|
||||
<span class="mt-0.5">{{ item.checked ? '☑' : '☐' }}</span>
|
||||
<span>{{ item.text }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label>Acceptance criteria</Label>
|
||||
<AcceptanceCriteriaEditor
|
||||
v-model="form.acceptance_criteria"
|
||||
editable
|
||||
:disabled="form.processing"
|
||||
:error="acceptanceCriteriaError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -248,7 +214,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
{{ ticketType.name }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.ticket_type_id" />
|
||||
<InputError
|
||||
:message="form.errors.ticket_type_id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
@@ -269,7 +237,11 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
{{ ticketPriority.name }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.ticket_priority_id" />
|
||||
<InputError
|
||||
:message="
|
||||
form.errors.ticket_priority_id
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
@@ -285,10 +257,14 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
:key="ticketStatus.id"
|
||||
:value="String(ticketStatus.id)"
|
||||
>
|
||||
{{ ticketStatusLabel(ticketStatus) }}
|
||||
{{
|
||||
ticketStatusLabel(ticketStatus)
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.ticket_status_id" />
|
||||
<InputError
|
||||
:message="form.errors.ticket_status_id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
@@ -310,7 +286,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
{{ user.name }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.assignee_user_id" />
|
||||
<InputError
|
||||
:message="form.errors.assignee_user_id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
@@ -323,16 +301,24 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
:disabled="form.processing"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 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 value="">No parent ticket</option>
|
||||
<option value="">
|
||||
No parent ticket
|
||||
</option>
|
||||
<option
|
||||
v-for="parentTicketOption in parentTicketOptions"
|
||||
:key="parentTicketOption.id"
|
||||
:value="String(parentTicketOption.id)"
|
||||
:value="
|
||||
String(parentTicketOption.id)
|
||||
"
|
||||
>
|
||||
{{ `${parentTicketOption.ticket_key} - ${parentTicketOption.title}` }}
|
||||
{{
|
||||
`${parentTicketOption.ticket_key} - ${parentTicketOption.title}`
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="form.errors.parent_ticket_id" />
|
||||
<InputError
|
||||
:message="form.errors.parent_ticket_id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -349,7 +335,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
||||
step="1"
|
||||
:disabled="form.processing"
|
||||
/>
|
||||
<InputError :message="form.errors.story_points" />
|
||||
<InputError
|
||||
:message="form.errors.story_points"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { index, show } from '@/routes/projects/index';
|
||||
import { index } from '@/routes/projects/index';
|
||||
import { index as boardIndex } from '@/routes/projects/board';
|
||||
import {
|
||||
board as boardSettings,
|
||||
general as generalSettings,
|
||||
@@ -35,7 +36,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
},
|
||||
{
|
||||
title: props.project.name,
|
||||
href: show(props.project.id),
|
||||
href: boardIndex(props.project.id),
|
||||
},
|
||||
{
|
||||
title: 'Board settings',
|
||||
@@ -79,11 +80,15 @@ const statusAllowsTarget = (
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="`${project.name} board settings`" />
|
||||
<component
|
||||
:is="embedded ? 'div' : AppLayout"
|
||||
v-bind="embedded ? {} : { breadcrumbs, project }"
|
||||
>
|
||||
<Head v-if="!embedded" :title="`${project.name} board settings`" />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div :class="embedded ? 'space-y-6' : 'app-page'">
|
||||
<div
|
||||
v-if="!embedded"
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
@@ -126,15 +131,14 @@ const statusAllowsTarget = (
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="show(project.id)">Back to project</Link>
|
||||
<Link :href="boardIndex(project.id)"
|
||||
>Back to project</Link
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status"
|
||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||
>
|
||||
<div v-if="!embedded && status" class="status-success">
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
@@ -144,7 +148,9 @@ const statusAllowsTarget = (
|
||||
Template inheritance: {{ templateState.templateName }}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
This project stays linked to its template. Sections below show whether the current configuration is still inherited or has local overrides.
|
||||
This project stays linked to its template. Sections
|
||||
below show whether the current configuration is still
|
||||
inherited or has local overrides.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-wrap gap-2">
|
||||
@@ -685,5 +691,5 @@ const statusAllowsTarget = (
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { index, show } from '@/routes/projects/index';
|
||||
import { index } from '@/routes/projects/index';
|
||||
import { index as boardIndex } from '@/routes/projects/board';
|
||||
import {
|
||||
board as boardSettings,
|
||||
general as generalSettings,
|
||||
@@ -35,7 +36,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
},
|
||||
{
|
||||
title: props.project.name,
|
||||
href: show(props.project.id),
|
||||
href: boardIndex(props.project.id),
|
||||
},
|
||||
{
|
||||
title: 'General settings',
|
||||
@@ -89,9 +90,12 @@ const submitSwitchTemplate = (): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
switchTemplateForm.post(ProjectController.switchTemplate.url(props.project.id), {
|
||||
preserveScroll: true,
|
||||
});
|
||||
switchTemplateForm.post(
|
||||
ProjectController.switchTemplate.url(props.project.id),
|
||||
{
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const submitResetTemplate = (): void => {
|
||||
@@ -99,9 +103,12 @@ const submitResetTemplate = (): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
resetTemplateForm.post(ProjectController.resetTemplate.url(props.project.id), {
|
||||
preserveScroll: true,
|
||||
});
|
||||
resetTemplateForm.post(
|
||||
ProjectController.resetTemplate.url(props.project.id),
|
||||
{
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const submitDelete = (): void => {
|
||||
@@ -116,11 +123,15 @@ const submitDelete = (): void => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="`${project.name} general settings`" />
|
||||
<component
|
||||
:is="embedded ? 'div' : AppLayout"
|
||||
v-bind="embedded ? {} : { breadcrumbs, project }"
|
||||
>
|
||||
<Head v-if="!embedded" :title="`${project.name} general settings`" />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div :class="embedded ? 'space-y-6' : 'app-page'">
|
||||
<div
|
||||
v-if="!embedded"
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
@@ -159,15 +170,14 @@ const submitDelete = (): void => {
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="show(project.id)">Back to project</Link>
|
||||
<Link :href="boardIndex(project.id)"
|
||||
>Back to project</Link
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status"
|
||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||
>
|
||||
<div v-if="!embedded && status" class="status-success">
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
@@ -209,18 +219,27 @@ const submitDelete = (): void => {
|
||||
<CardHeader>
|
||||
<CardTitle>Template baseline</CardTitle>
|
||||
<CardDescription>
|
||||
Switch this project to another published template or reset it back to the currently linked template baseline. Both actions can overwrite local configuration.
|
||||
Switch this project to another published template or
|
||||
reset it back to the currently linked template baseline.
|
||||
Both actions can overwrite local configuration.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-6">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="border-border/70 rounded-xl border p-4">
|
||||
<p class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]">
|
||||
<p
|
||||
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
|
||||
>
|
||||
Current linked template
|
||||
</p>
|
||||
<p class="text-foreground mt-2 text-sm font-semibold">
|
||||
{{ currentTemplate?.name ?? 'No linked template' }}
|
||||
<p
|
||||
class="text-foreground mt-2 text-sm font-semibold"
|
||||
>
|
||||
{{
|
||||
currentTemplate?.name ??
|
||||
'No linked template'
|
||||
}}
|
||||
</p>
|
||||
<p class="text-muted-foreground mt-2 text-sm">
|
||||
{{
|
||||
@@ -232,33 +251,52 @@ const submitDelete = (): void => {
|
||||
</div>
|
||||
|
||||
<div class="border-border/70 rounded-xl border p-4">
|
||||
<p class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]">
|
||||
<p
|
||||
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
|
||||
>
|
||||
Warning
|
||||
</p>
|
||||
<p class="text-foreground mt-2 text-sm font-medium">
|
||||
Template changes can replace board workflow, ticket metadata, repositories, and notifications for copied areas.
|
||||
Template changes can replace board workflow,
|
||||
ticket metadata, repositories, and notifications
|
||||
for copied areas.
|
||||
</p>
|
||||
<p class="text-muted-foreground mt-2 text-sm">
|
||||
Confirm each action with the exact project key to avoid accidental overwrites.
|
||||
Confirm each action with the exact project key
|
||||
to avoid accidental overwrites.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-2">
|
||||
<form class="space-y-4 rounded-xl border p-4" @submit.prevent="submitSwitchTemplate">
|
||||
<form
|
||||
class="space-y-4 rounded-xl border p-4"
|
||||
@submit.prevent="submitSwitchTemplate"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-sm font-semibold">Switch to another template</h3>
|
||||
<h3 class="text-sm font-semibold">
|
||||
Switch to another template
|
||||
</h3>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Applying a different template replaces the copied areas with that template's current baseline.
|
||||
Applying a different template replaces the
|
||||
copied areas with that template's current
|
||||
baseline.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="source_template_id">Published template</Label>
|
||||
<Label for="source_template_id"
|
||||
>Published template</Label
|
||||
>
|
||||
<select
|
||||
id="source_template_id"
|
||||
v-model="switchTemplateForm.source_template_id"
|
||||
:disabled="switchTemplateForm.processing || availableSwitchTemplates.length === 0"
|
||||
v-model="
|
||||
switchTemplateForm.source_template_id
|
||||
"
|
||||
:disabled="
|
||||
switchTemplateForm.processing ||
|
||||
availableSwitchTemplates.length === 0
|
||||
"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||
>
|
||||
<option :value="undefined" disabled>
|
||||
@@ -272,63 +310,107 @@ const submitDelete = (): void => {
|
||||
{{ template.name }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError :message="switchTemplateForm.errors.source_template_id" />
|
||||
<InputError
|
||||
:message="
|
||||
switchTemplateForm.errors
|
||||
.source_template_id
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="switch_confirmation_key">Confirmation key</Label>
|
||||
<Label for="switch_confirmation_key"
|
||||
>Confirmation key</Label
|
||||
>
|
||||
<Input
|
||||
id="switch_confirmation_key"
|
||||
v-model="switchTemplateForm.confirmation_key"
|
||||
v-model="
|
||||
switchTemplateForm.confirmation_key
|
||||
"
|
||||
:disabled="switchTemplateForm.processing"
|
||||
placeholder="Type the project key exactly"
|
||||
/>
|
||||
<InputError :message="switchTemplateForm.errors.confirmation_key" />
|
||||
<InputError
|
||||
:message="
|
||||
switchTemplateForm.errors
|
||||
.confirmation_key
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div
|
||||
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Existing project-specific configuration in copied areas may be replaced or removed.
|
||||
Existing project-specific configuration in
|
||||
copied areas may be replaced or removed.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
:disabled="switchTemplateForm.processing || !canSwitchTemplate"
|
||||
:disabled="
|
||||
switchTemplateForm.processing ||
|
||||
!canSwitchTemplate
|
||||
"
|
||||
>
|
||||
Switch template
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form class="space-y-4 rounded-xl border p-4" @submit.prevent="submitResetTemplate">
|
||||
<form
|
||||
class="space-y-4 rounded-xl border p-4"
|
||||
@submit.prevent="submitResetTemplate"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-sm font-semibold">Reset to current template baseline</h3>
|
||||
<h3 class="text-sm font-semibold">
|
||||
Reset to current template baseline
|
||||
</h3>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Re-apply the currently linked template and clear local divergence inside copied areas.
|
||||
Re-apply the currently linked template and
|
||||
clear local divergence inside copied areas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="reset_confirmation_key">Confirmation key</Label>
|
||||
<Label for="reset_confirmation_key"
|
||||
>Confirmation key</Label
|
||||
>
|
||||
<Input
|
||||
id="reset_confirmation_key"
|
||||
v-model="resetTemplateForm.confirmation_key"
|
||||
:disabled="resetTemplateForm.processing || !currentTemplate"
|
||||
:disabled="
|
||||
resetTemplateForm.processing ||
|
||||
!currentTemplate
|
||||
"
|
||||
placeholder="Type the project key exactly"
|
||||
/>
|
||||
<InputError :message="resetTemplateForm.errors.confirmation_key" />
|
||||
<InputError
|
||||
:message="
|
||||
resetTemplateForm.errors
|
||||
.confirmation_key
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div
|
||||
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
This restores the current template baseline for copied areas and rewinds project-level overrides there.
|
||||
This restores the current template baseline
|
||||
for copied areas and rewinds project-level
|
||||
overrides there.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
:disabled="resetTemplateForm.processing || !isResetConfirmationValid || !currentTemplate"
|
||||
:disabled="
|
||||
resetTemplateForm.processing ||
|
||||
!isResetConfirmationValid ||
|
||||
!currentTemplate
|
||||
"
|
||||
>
|
||||
Reset to baseline
|
||||
</Button>
|
||||
@@ -402,5 +484,5 @@ const submitDelete = (): void => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { index, show } from '@/routes/projects/index';
|
||||
import { index } from '@/routes/projects/index';
|
||||
import { index as boardIndex } from '@/routes/projects/board';
|
||||
import {
|
||||
board as boardSettings,
|
||||
general as generalSettings,
|
||||
@@ -38,7 +39,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
},
|
||||
{
|
||||
title: props.project.name,
|
||||
href: show(props.project.id),
|
||||
href: boardIndex(props.project.id),
|
||||
},
|
||||
{
|
||||
title: 'Ticket settings',
|
||||
@@ -51,11 +52,15 @@ const replacementTicketTypes = (ticketTypeId: number) =>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="`${project.name} ticket settings`" />
|
||||
<component
|
||||
:is="embedded ? 'div' : AppLayout"
|
||||
v-bind="embedded ? {} : { breadcrumbs, project }"
|
||||
>
|
||||
<Head v-if="!embedded" :title="`${project.name} ticket settings`" />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div :class="embedded ? 'space-y-6' : 'app-page'">
|
||||
<div
|
||||
v-if="!embedded"
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
@@ -98,13 +103,15 @@ const replacementTicketTypes = (ticketTypeId: number) =>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="show(project.id)">Back to project</Link>
|
||||
<Link :href="boardIndex(project.id)"
|
||||
>Back to project</Link
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status"
|
||||
v-if="!embedded && status"
|
||||
:class="[
|
||||
'rounded-xl border px-4 py-3 text-sm',
|
||||
error
|
||||
@@ -121,7 +128,9 @@ const replacementTicketTypes = (ticketTypeId: number) =>
|
||||
Template inheritance: {{ templateState.templateName }}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Inherited sections stay synced with the template baseline. Local edits in these settings are treated as project-specific overrides.
|
||||
Inherited sections stay synced with the template
|
||||
baseline. Local edits in these settings are treated as
|
||||
project-specific overrides.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-wrap gap-2">
|
||||
@@ -699,7 +708,9 @@ const replacementTicketTypes = (ticketTypeId: number) =>
|
||||
required
|
||||
placeholder="Blocked by"
|
||||
/>
|
||||
<InputError :message="errors.counterpart_name" />
|
||||
<InputError
|
||||
:message="errors.counterpart_name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" :disabled="processing">
|
||||
@@ -745,7 +756,9 @@ const replacementTicketTypes = (ticketTypeId: number) =>
|
||||
<Input
|
||||
:id="`link-type-counterpart-name-${ticketLinkType.id}`"
|
||||
name="counterpart_name"
|
||||
:default-value="ticketLinkType.counterpart_label"
|
||||
:default-value="
|
||||
ticketLinkType.counterpart_label
|
||||
"
|
||||
required
|
||||
/>
|
||||
<InputError
|
||||
@@ -806,5 +819,5 @@ const replacementTicketTypes = (ticketTypeId: number) =>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { index, show } from '@/routes/projects/index';
|
||||
import { index } from '@/routes/projects/index';
|
||||
import { index as boardIndex } from '@/routes/projects/board';
|
||||
import {
|
||||
board as boardSettings,
|
||||
general as generalSettings,
|
||||
@@ -26,24 +27,13 @@ import {
|
||||
} from '@/routes/projects/settings';
|
||||
import type {
|
||||
BreadcrumbItem,
|
||||
NotificationFilterFormState,
|
||||
NotificationFilterPayload,
|
||||
ProjectNotificationSettingsPageProps,
|
||||
} from '@/types';
|
||||
import type { NotificationActionFilterModel } from '@/types/models/notification-action-filter-model';
|
||||
import type { NotificationActionModel } from '@/types/models/notification-action-model';
|
||||
|
||||
type NotificationFilterFormState = {
|
||||
attribute: string;
|
||||
id: string;
|
||||
operator: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type NotificationFilterPayload = {
|
||||
attribute: string;
|
||||
operator: string;
|
||||
value: string | string[] | null;
|
||||
};
|
||||
|
||||
const props = defineProps<ProjectNotificationSettingsPageProps>();
|
||||
|
||||
const defaultCreateChannel =
|
||||
@@ -79,7 +69,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
},
|
||||
{
|
||||
title: props.project.name,
|
||||
href: show(props.project.id),
|
||||
href: boardIndex(props.project.id),
|
||||
},
|
||||
{
|
||||
title: 'Notification settings',
|
||||
@@ -89,7 +79,8 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
|
||||
let filterStateCounter = 0;
|
||||
|
||||
const nextFilterStateId = (): string => `notification-filter-${filterStateCounter++}`;
|
||||
const nextFilterStateId = (): string =>
|
||||
`notification-filter-${filterStateCounter++}`;
|
||||
|
||||
const parseInFilterValues = (value: string): string[] =>
|
||||
value
|
||||
@@ -100,7 +91,8 @@ const parseInFilterValues = (value: string): string[] =>
|
||||
const supportsFilterValue = (operator: string): boolean =>
|
||||
!['exists', 'not_exists'].includes(operator);
|
||||
|
||||
const supportsArrayFilterValue = (operator: string): boolean => operator === 'in';
|
||||
const supportsArrayFilterValue = (operator: string): boolean =>
|
||||
operator === 'in';
|
||||
|
||||
const createFilter = (): NotificationFilterFormState => ({
|
||||
attribute: defaultFilterAttribute,
|
||||
@@ -159,8 +151,9 @@ const resetCreateFilters = (): void => {
|
||||
createFilterState.filters = [];
|
||||
};
|
||||
|
||||
const editFilters = (notificationActionId: number): NotificationFilterFormState[] =>
|
||||
editFilterState[notificationActionId] ?? [];
|
||||
const editFilters = (
|
||||
notificationActionId: number,
|
||||
): NotificationFilterFormState[] => editFilterState[notificationActionId] ?? [];
|
||||
|
||||
const addEditFilter = (notificationActionId: number): void => {
|
||||
editFilterState[notificationActionId] = [
|
||||
@@ -169,10 +162,13 @@ const addEditFilter = (notificationActionId: number): void => {
|
||||
];
|
||||
};
|
||||
|
||||
const removeEditFilter = (notificationActionId: number, index: number): void => {
|
||||
editFilterState[notificationActionId] = editFilters(notificationActionId).filter(
|
||||
(_, candidateIndex) => candidateIndex !== index,
|
||||
);
|
||||
const removeEditFilter = (
|
||||
notificationActionId: number,
|
||||
index: number,
|
||||
): void => {
|
||||
editFilterState[notificationActionId] = editFilters(
|
||||
notificationActionId,
|
||||
).filter((_, candidateIndex) => candidateIndex !== index);
|
||||
};
|
||||
|
||||
const createSerializedFilters = computed<string>(() =>
|
||||
@@ -246,11 +242,18 @@ const notificationActionTimestamp = (
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="`${project.name} notification settings`" />
|
||||
<component
|
||||
:is="embedded ? 'div' : AppLayout"
|
||||
v-bind="embedded ? {} : { breadcrumbs, project }"
|
||||
>
|
||||
<Head
|
||||
v-if="!embedded"
|
||||
:title="`${project.name} notification settings`"
|
||||
/>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div :class="embedded ? 'space-y-6' : 'app-page'">
|
||||
<div
|
||||
v-if="!embedded"
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
@@ -293,15 +296,14 @@ const notificationActionTimestamp = (
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="show(project.id)">Back to project</Link>
|
||||
<Link :href="boardIndex(project.id)"
|
||||
>Back to project</Link
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status"
|
||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||
>
|
||||
<div v-if="!embedded && status" class="status-success">
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
@@ -311,7 +313,8 @@ const notificationActionTimestamp = (
|
||||
Template inheritance: {{ templateState.templateName }}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Notification rules can remain inherited from the template or diverge here as project-level overrides.
|
||||
Notification rules can remain inherited from the
|
||||
template or diverge here as project-level overrides.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-wrap gap-2">
|
||||
@@ -543,7 +546,10 @@ const notificationActionTimestamp = (
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="createFilterState.filters.length === 0"
|
||||
v-if="
|
||||
createFilterState.filters.length ===
|
||||
0
|
||||
"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
No filters configured. This action runs
|
||||
@@ -552,8 +558,7 @@ const notificationActionTimestamp = (
|
||||
|
||||
<div
|
||||
v-for="(
|
||||
filter,
|
||||
filterIndex
|
||||
filter, filterIndex
|
||||
) in createFilterState.filters"
|
||||
:key="filter.id"
|
||||
class="grid gap-3 rounded-md border p-3"
|
||||
@@ -572,8 +577,12 @@ const notificationActionTimestamp = (
|
||||
>
|
||||
<option
|
||||
v-for="filterAttributeOption in filterAttributeOptions"
|
||||
:key="filterAttributeOption.value"
|
||||
:value="filterAttributeOption.value"
|
||||
:key="
|
||||
filterAttributeOption.value
|
||||
"
|
||||
:value="
|
||||
filterAttributeOption.value
|
||||
"
|
||||
>
|
||||
{{
|
||||
filterAttributeOption.label
|
||||
@@ -595,8 +604,12 @@ const notificationActionTimestamp = (
|
||||
>
|
||||
<option
|
||||
v-for="filterOperatorOption in filterOperatorOptions"
|
||||
:key="filterOperatorOption.value"
|
||||
:value="filterOperatorOption.value"
|
||||
:key="
|
||||
filterOperatorOption.value
|
||||
"
|
||||
:value="
|
||||
filterOperatorOption.value
|
||||
"
|
||||
>
|
||||
{{
|
||||
filterOperatorOption.label
|
||||
@@ -607,7 +620,11 @@ const notificationActionTimestamp = (
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="supportsFilterValue(filter.operator)"
|
||||
v-if="
|
||||
supportsFilterValue(
|
||||
filter.operator,
|
||||
)
|
||||
"
|
||||
class="grid gap-2"
|
||||
>
|
||||
<Label
|
||||
@@ -662,7 +679,9 @@ const notificationActionTimestamp = (
|
||||
</div>
|
||||
|
||||
<InputError
|
||||
:message="notificationFilterError(errors)"
|
||||
:message="
|
||||
notificationFilterError(errors)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1029,8 +1048,7 @@ const notificationActionTimestamp = (
|
||||
|
||||
<div
|
||||
v-for="(
|
||||
filter,
|
||||
filterIndex
|
||||
filter, filterIndex
|
||||
) in editFilters(notificationAction.id)"
|
||||
:key="filter.id"
|
||||
class="grid gap-3 rounded-md border p-3"
|
||||
@@ -1049,8 +1067,12 @@ const notificationActionTimestamp = (
|
||||
>
|
||||
<option
|
||||
v-for="filterAttributeOption in filterAttributeOptions"
|
||||
:key="filterAttributeOption.value"
|
||||
:value="filterAttributeOption.value"
|
||||
:key="
|
||||
filterAttributeOption.value
|
||||
"
|
||||
:value="
|
||||
filterAttributeOption.value
|
||||
"
|
||||
>
|
||||
{{
|
||||
filterAttributeOption.label
|
||||
@@ -1072,8 +1094,12 @@ const notificationActionTimestamp = (
|
||||
>
|
||||
<option
|
||||
v-for="filterOperatorOption in filterOperatorOptions"
|
||||
:key="filterOperatorOption.value"
|
||||
:value="filterOperatorOption.value"
|
||||
:key="
|
||||
filterOperatorOption.value
|
||||
"
|
||||
:value="
|
||||
filterOperatorOption.value
|
||||
"
|
||||
>
|
||||
{{
|
||||
filterOperatorOption.label
|
||||
@@ -1084,7 +1110,11 @@ const notificationActionTimestamp = (
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="supportsFilterValue(filter.operator)"
|
||||
v-if="
|
||||
supportsFilterValue(
|
||||
filter.operator,
|
||||
)
|
||||
"
|
||||
class="grid gap-2"
|
||||
>
|
||||
<Label
|
||||
@@ -1144,7 +1174,9 @@ const notificationActionTimestamp = (
|
||||
</div>
|
||||
|
||||
<InputError
|
||||
:message="notificationFilterError(errors)"
|
||||
:message="
|
||||
notificationFilterError(errors)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1207,5 +1239,5 @@ const notificationActionTimestamp = (
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { edit as editGitConnections } from '@/routes/git-connections/index';
|
||||
import { index, show } from '@/routes/projects/index';
|
||||
import { index } from '@/routes/projects/index';
|
||||
import { index as boardIndex } from '@/routes/projects/board';
|
||||
import {
|
||||
board as boardSettings,
|
||||
general as generalSettings,
|
||||
@@ -42,7 +43,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
},
|
||||
{
|
||||
title: props.project.name,
|
||||
href: show(props.project.id),
|
||||
href: boardIndex(props.project.id),
|
||||
},
|
||||
{
|
||||
title: 'Repository settings',
|
||||
@@ -218,11 +219,15 @@ const submitUpdate = (): void => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="`${project.name} repository settings`" />
|
||||
<component
|
||||
:is="embedded ? 'div' : AppLayout"
|
||||
v-bind="embedded ? {} : { breadcrumbs, project }"
|
||||
>
|
||||
<Head v-if="!embedded" :title="`${project.name} repository settings`" />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div :class="embedded ? 'space-y-6' : 'app-page'">
|
||||
<div
|
||||
v-if="!embedded"
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
@@ -265,15 +270,14 @@ const submitUpdate = (): void => {
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="show(project.id)">Back to project</Link>
|
||||
<Link :href="boardIndex(project.id)"
|
||||
>Back to project</Link
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status"
|
||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||
>
|
||||
<div v-if="!embedded && status" class="status-success">
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
@@ -283,7 +287,8 @@ const submitUpdate = (): void => {
|
||||
Template inheritance: {{ templateState.templateName }}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Repository links can stay inherited from the template or be replaced with project-specific overrides.
|
||||
Repository links can stay inherited from the template or
|
||||
be replaced with project-specific overrides.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-wrap gap-2">
|
||||
@@ -320,9 +325,9 @@ const submitUpdate = (): void => {
|
||||
</Link>
|
||||
</Button>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Without a matching personal connection, {{ appName }} only
|
||||
shows the repository name and links you to your user
|
||||
settings.
|
||||
Without a matching personal connection,
|
||||
{{ appName }} only shows the repository name and links
|
||||
you to your user settings.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -647,8 +652,8 @@ const submitUpdate = (): void => {
|
||||
>
|
||||
This project link is visible, but you need a
|
||||
matching Git connection in your user
|
||||
settings before {{ appName }} can open or query
|
||||
this repository for you.
|
||||
settings before {{ appName }} can open or
|
||||
query this repository for you.
|
||||
</p>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
@@ -683,9 +688,9 @@ const submitUpdate = (): void => {
|
||||
<CardHeader class="border-b">
|
||||
<CardTitle>Detected personal sources</CardTitle>
|
||||
<CardDescription>
|
||||
{{ appName }} loads repositories from your personal
|
||||
Git connections and offers them for project
|
||||
linking.
|
||||
{{ appName }} loads repositories from your
|
||||
personal Git connections and offers them for
|
||||
project linking.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -726,5 +731,5 @@ const submitUpdate = (): void => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
@@ -58,10 +58,7 @@ const formatDate = (value: string | null): string => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status"
|
||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||
>
|
||||
<div v-if="status" class="status-success">
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -46,15 +46,13 @@ const breadcrumbItems: BreadcrumbItem[] = [
|
||||
/>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Each user needs their own application key. Without a
|
||||
matching personal connection, {{ appName }} can only show
|
||||
repository names and will link you back to this page.
|
||||
matching personal connection, {{ appName }} can only
|
||||
show repository names and will link you back to this
|
||||
page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status"
|
||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||
>
|
||||
<div v-if="status" class="status-success">
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ const permissionsForTeam = (teamId: number): Record<string, string[]> =>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head title="Teams" />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div class="app-page">
|
||||
<div
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
@@ -57,10 +57,7 @@ const permissionsForTeam = (teamId: number): Record<string, string[]> =>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status"
|
||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||
>
|
||||
<div v-if="status" class="status-success">
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
|
||||
|
||||
export type AcceptanceCriteriaCommitSource =
|
||||
| 'check'
|
||||
| 'delete'
|
||||
| 'indent'
|
||||
| 'move'
|
||||
| 'text';
|
||||
|
||||
export type AcceptanceCriteriaEditorCommit = {
|
||||
items: AcceptanceCriteriaModel;
|
||||
source: AcceptanceCriteriaCommitSource;
|
||||
};
|
||||
|
||||
export type AcceptanceCriteriaSaveState = 'error' | 'idle' | 'saving';
|
||||
|
||||
export type AcceptanceCriteriaEditorProps = {
|
||||
disabled?: boolean;
|
||||
editable: boolean;
|
||||
error?: string;
|
||||
modelValue: AcceptanceCriteriaModel;
|
||||
saveState?: AcceptanceCriteriaSaveState;
|
||||
};
|
||||
|
||||
export type AcceptanceCriteriaEditorEmits = {
|
||||
(event: 'commit', value: AcceptanceCriteriaEditorCommit): void;
|
||||
(event: 'update:modelValue', value: AcceptanceCriteriaModel): void;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { TicketModel } from '@/types/models/ticket-model';
|
||||
|
||||
export type BoardDragTicket = {
|
||||
originColumnId: number;
|
||||
ticket: TicketModel;
|
||||
};
|
||||
|
||||
export type BoardDragPointer = {
|
||||
pointerId: number;
|
||||
pointerType: string;
|
||||
startX: number;
|
||||
startY: number;
|
||||
};
|
||||
|
||||
export type BoardDropTarget = {
|
||||
boardColumnId: number;
|
||||
ticketStatusId: number;
|
||||
};
|
||||
@@ -1,4 +1,11 @@
|
||||
export * from './auth';
|
||||
export * from './acceptance-criteria-editor';
|
||||
export * from './layout';
|
||||
export * from './navigation';
|
||||
export * from './notification-settings';
|
||||
export * from './page-props';
|
||||
export * from './property-select-dropdown';
|
||||
export * from './project-navigation';
|
||||
export * from './ticket-inline-edit';
|
||||
export * from './ui';
|
||||
export * from './user-select-dropdown';
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { BreadcrumbItem, NavItem } from '@/types/navigation';
|
||||
import type { ProjectModel } from '@/types/models/project-model';
|
||||
import type { HeadingVariant } from '@/types/ui';
|
||||
|
||||
export type AppLayoutProps = {
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
project?: ProjectModel;
|
||||
};
|
||||
|
||||
export type AppSidebarProps = {
|
||||
project?: ProjectModel;
|
||||
};
|
||||
|
||||
export type AppSidebarHeaderProps = Pick<AppLayoutProps, 'breadcrumbs'>;
|
||||
|
||||
export type AppHeaderProps = Pick<AppLayoutProps, 'breadcrumbs'>;
|
||||
|
||||
export type AppContentProps = {
|
||||
class?: string;
|
||||
variant?: import('@/types/ui').AppVariant;
|
||||
};
|
||||
|
||||
export type AppShellProps = {
|
||||
variant?: import('@/types/ui').AppVariant;
|
||||
};
|
||||
|
||||
export type AuthLayoutProps = {
|
||||
description?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export type HeadingProps = {
|
||||
description?: string;
|
||||
title: string;
|
||||
variant?: HeadingVariant;
|
||||
};
|
||||
|
||||
export type NavMainProps = {
|
||||
items: NavItem[];
|
||||
label?: string;
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
export type NotificationFilterFormState = {
|
||||
attribute: string;
|
||||
id: string;
|
||||
operator: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type NotificationFilterPayload = {
|
||||
attribute: string;
|
||||
operator: string;
|
||||
value: string | string[] | null;
|
||||
};
|
||||
@@ -21,6 +21,7 @@ import type { TicketTypeModel } from '@/types/models/ticket-type-model';
|
||||
import type { UserApplicationModel } from '@/types/models/user-application-model';
|
||||
import type { UserGitConnectionModel } from '@/types/models/user-git-connection-model';
|
||||
import type { UserModel } from '@/types/models/user-model';
|
||||
import type { ProjectSettingsSection } from '@/types/project-navigation';
|
||||
|
||||
export type AdminUsersPageProps = {
|
||||
directPermissions: Record<string, string[]>;
|
||||
@@ -84,12 +85,6 @@ export type ProjectTemplateState = {
|
||||
templateSlug: string;
|
||||
};
|
||||
|
||||
export type ProjectShowPageProps = {
|
||||
canManageAccess: boolean;
|
||||
project: ProjectModel;
|
||||
templateState?: ProjectTemplateState | null;
|
||||
};
|
||||
|
||||
export type PermissionOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
@@ -103,6 +98,7 @@ export type ProjectAccessPageProps = {
|
||||
teamPermissions: Record<string, string[]>;
|
||||
teams: TeamModel[];
|
||||
users: UserModel[];
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
export type TeamsIndexPageProps = {
|
||||
@@ -121,6 +117,7 @@ export type ProjectBoardSettingsPageProps = {
|
||||
status?: string | null;
|
||||
templateState?: ProjectTemplateState | null;
|
||||
ticketStatuses: TicketStatusModel[];
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
export type ProjectGeneralSettingsPageProps = {
|
||||
@@ -128,12 +125,14 @@ export type ProjectGeneralSettingsPageProps = {
|
||||
sourceTemplates: ProjectTemplateModel[];
|
||||
status?: string | null;
|
||||
templateState?: ProjectTemplateState | null;
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
export type ProjectBoardPageProps = {
|
||||
boardColumns: BoardColumnModel[];
|
||||
canCreateTickets: boolean;
|
||||
canEditTickets: boolean;
|
||||
canManageProject: boolean;
|
||||
project: ProjectModel;
|
||||
status?: string | null;
|
||||
};
|
||||
@@ -153,6 +152,7 @@ export type ProjectTicketMetadataSettingsPageProps = {
|
||||
ticketLinkTypes: TicketLinkTypeModel[];
|
||||
ticketPriorities: TicketPriorityModel[];
|
||||
ticketTypes: TicketTypeModel[];
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
export type ProjectRepositorySettingsPageProps = {
|
||||
@@ -161,6 +161,7 @@ export type ProjectRepositorySettingsPageProps = {
|
||||
repositoryConnections: RepositoryConnectionModel[];
|
||||
status?: string | null;
|
||||
templateState?: ProjectTemplateState | null;
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
export type ProjectNotificationSettingsPageProps = {
|
||||
@@ -174,12 +175,14 @@ export type ProjectNotificationSettingsPageProps = {
|
||||
status?: string | null;
|
||||
templateState?: ProjectTemplateState | null;
|
||||
templateVariables: NotificationActionTemplateVariableModel[];
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
export type ProjectBacklogPageProps = {
|
||||
backlogTicketStatuses: TicketStatusModel[];
|
||||
canCreateTickets: boolean;
|
||||
canEditTickets: boolean;
|
||||
canManageProject: boolean;
|
||||
project: ProjectModel;
|
||||
status?: string | null;
|
||||
ticketPriorities: TicketPriorityModel[];
|
||||
@@ -190,6 +193,7 @@ export type ProjectBacklogPageProps = {
|
||||
};
|
||||
|
||||
export type ProjectTicketCreatePageProps = {
|
||||
canManageProject: boolean;
|
||||
defaultTicketStatusId: number | null;
|
||||
parentTicketOptions: TicketReferenceModel[];
|
||||
project: ProjectModel;
|
||||
@@ -201,6 +205,26 @@ export type ProjectTicketCreatePageProps = {
|
||||
users: UserModel[];
|
||||
};
|
||||
|
||||
export type ProjectSettingsSectionData = Partial<
|
||||
Omit<
|
||||
ProjectAccessPageProps &
|
||||
ProjectBoardSettingsPageProps &
|
||||
ProjectGeneralSettingsPageProps &
|
||||
ProjectNotificationSettingsPageProps &
|
||||
ProjectRepositorySettingsPageProps &
|
||||
ProjectTicketMetadataSettingsPageProps,
|
||||
'embedded' | 'project' | 'status'
|
||||
>
|
||||
>;
|
||||
|
||||
export type ProjectSettingsPageProps = {
|
||||
activeSection: ProjectSettingsSection;
|
||||
error?: boolean;
|
||||
project: ProjectModel;
|
||||
sectionData: ProjectSettingsSectionData;
|
||||
status?: string | null;
|
||||
};
|
||||
|
||||
export type ProjectTicketPageProps = {
|
||||
canEditTicket: boolean;
|
||||
canManageProject: boolean;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ProjectModel } from '@/types/models/project-model';
|
||||
|
||||
export type ProjectPrimarySection = 'backlog' | 'board' | 'settings';
|
||||
|
||||
export type ProjectSettingsSection =
|
||||
| 'access'
|
||||
| 'board'
|
||||
| 'general'
|
||||
| 'metadata'
|
||||
| 'notifications'
|
||||
| 'repositories';
|
||||
|
||||
export type ProjectTabsProps = {
|
||||
active?: ProjectPrimarySection | null;
|
||||
canManageProject: boolean;
|
||||
project: ProjectModel;
|
||||
};
|
||||
|
||||
export type ProjectPageHeaderProps = {
|
||||
context: string;
|
||||
description: string;
|
||||
project: ProjectModel;
|
||||
};
|
||||
|
||||
export type EmbeddedProjectSettingsSectionProps<T> = Omit<
|
||||
T,
|
||||
'embedded' | 'project' | 'status'
|
||||
>;
|
||||
@@ -0,0 +1,21 @@
|
||||
export type PropertySelectDropdownValue = number | string | null;
|
||||
|
||||
export type PropertySelectOption = {
|
||||
description?: string | null;
|
||||
label: string;
|
||||
value: number | string;
|
||||
};
|
||||
|
||||
export type PropertySelectDropdownProps = {
|
||||
disabled?: boolean;
|
||||
editable: boolean;
|
||||
label: string;
|
||||
modelValue: PropertySelectDropdownValue;
|
||||
options: PropertySelectOption[];
|
||||
searchable?: boolean;
|
||||
};
|
||||
|
||||
export type PropertySelectDropdownEmits = {
|
||||
commit: [value: number | string];
|
||||
'update:modelValue': [value: number | string];
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
|
||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||
|
||||
export type InlineTextEditorValue = number | string | null;
|
||||
|
||||
export type InlineTextEditorProps = {
|
||||
disabled?: boolean;
|
||||
editable: boolean;
|
||||
error?: string;
|
||||
inputType?: 'number' | 'text';
|
||||
multiline?: boolean;
|
||||
placeholder: string;
|
||||
saveState?: 'error' | 'idle' | 'saving';
|
||||
value: InlineTextEditorValue;
|
||||
variant?: 'body' | 'property' | 'title';
|
||||
};
|
||||
|
||||
export type InlineTextEditorEmits = {
|
||||
(event: 'commit'): void;
|
||||
(event: 'update:value', value: InlineTextEditorValue): void;
|
||||
};
|
||||
|
||||
export type InlineSaveStatusProps = {
|
||||
error?: string;
|
||||
state: 'error' | 'idle' | 'saving';
|
||||
};
|
||||
|
||||
export type TicketEditableField =
|
||||
| 'acceptance_criteria'
|
||||
| 'assignee_user_id'
|
||||
| 'author_user_id'
|
||||
| 'blocked_comment'
|
||||
| 'description'
|
||||
| 'is_blocked'
|
||||
| 'parent_ticket_id'
|
||||
| 'story_points'
|
||||
| 'ticket_priority_id'
|
||||
| 'ticket_type_id'
|
||||
| 'title'
|
||||
| `field-${number}`;
|
||||
|
||||
export type ProjectTicketFieldValuePayload = {
|
||||
ticket_field_definition_id: number;
|
||||
value_number: number | string | null;
|
||||
value_user_id: number | string | null;
|
||||
};
|
||||
|
||||
export type ProjectTicketUpdatePayload = {
|
||||
acceptance_criteria: AcceptanceCriteriaModel;
|
||||
assignee_user_id: number | string | null;
|
||||
author_user_id: number | string | null;
|
||||
blocked_comment: string;
|
||||
description: string;
|
||||
field_values: ProjectTicketFieldValuePayload[];
|
||||
is_blocked: boolean;
|
||||
parent_ticket_id: number | string | null;
|
||||
story_points: number | string | null;
|
||||
ticket_priority_id: number | string;
|
||||
ticket_type_id: number | string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type TicketAutosaveResponse = {
|
||||
acceptance_criteria: AcceptanceCriteriaModel;
|
||||
message: string;
|
||||
updated: boolean;
|
||||
};
|
||||
|
||||
export type TicketAutosaveErrorResponse = {
|
||||
errors?: Record<string, string[]>;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type TicketStatusMoveResponse = {
|
||||
message: string;
|
||||
moved: boolean;
|
||||
ticket_status: TicketStatusModel | null;
|
||||
ticket_status_id: number | null;
|
||||
};
|
||||
|
||||
export type QueuedTicketAutosave = {
|
||||
field: TicketEditableField;
|
||||
payload: ProjectTicketUpdatePayload;
|
||||
};
|
||||
@@ -2,3 +2,15 @@ export type Appearance = 'light' | 'dark' | 'system';
|
||||
export type ResolvedAppearance = 'light' | 'dark';
|
||||
|
||||
export type AppVariant = 'header' | 'sidebar';
|
||||
|
||||
export type HeadingVariant = 'default' | 'small';
|
||||
|
||||
export type UserAvatarProps = {
|
||||
rounded?: boolean;
|
||||
user?: import('@/types/models/user-model').UserModel;
|
||||
};
|
||||
|
||||
export type UserInfoProps = {
|
||||
showEmail?: boolean;
|
||||
user: import('@/types/auth').User;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { UserModel } from '@/types/models/user-model';
|
||||
|
||||
export type UserSelectDropdownValue = number | string | null;
|
||||
|
||||
export type UserSelectDropdownProps = {
|
||||
editable: boolean;
|
||||
emptyLabel?: string;
|
||||
label: string;
|
||||
modelValue: UserSelectDropdownValue;
|
||||
users: UserModel[];
|
||||
};
|
||||
|
||||
export type UserSelectDropdownEmits = {
|
||||
commit: [value: number | string];
|
||||
'update:modelValue': [value: number | string];
|
||||
};
|
||||
@@ -22,11 +22,11 @@
|
||||
{{-- Inline style to set the HTML background color based on our theme in app.css --}}
|
||||
<style>
|
||||
html {
|
||||
background-color: oklch(1 0 0);
|
||||
background-color: #f7f9fb;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
background-color: oklch(0.145 0 0);
|
||||
background-color: #0b1326;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
|
||||
<link href="https://fonts.bunny.net/css?family=inter:400,500,600,700" rel="stylesheet" />
|
||||
|
||||
@vite(['resources/js/app.ts', "resources/js/pages/{$page['component']}.vue"])
|
||||
@inertiaHead
|
||||
|
||||
@@ -4,14 +4,17 @@ use App\Http\Controllers\ProjectBoardSettingsController;
|
||||
use App\Http\Controllers\ProjectController;
|
||||
use App\Http\Controllers\ProjectNotificationSettingsController;
|
||||
use App\Http\Controllers\ProjectRepositorySettingsController;
|
||||
use App\Http\Controllers\ProjectSettingsController;
|
||||
use App\Http\Controllers\ProjectTicketMetadataSettingsController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('general', [ProjectController::class, 'general'])->name('general');
|
||||
Route::get('/', [ProjectSettingsController::class, 'index'])->name('index');
|
||||
|
||||
Route::get('general', [ProjectSettingsController::class, 'general'])->name('general');
|
||||
Route::post('general/switch-template', [ProjectController::class, 'switchTemplate'])->name('general.switch-template');
|
||||
Route::post('general/reset-template', [ProjectController::class, 'resetTemplate'])->name('general.reset-template');
|
||||
|
||||
Route::get('board', [ProjectBoardSettingsController::class, 'show'])->name('board');
|
||||
Route::get('board', [ProjectSettingsController::class, 'board'])->name('board');
|
||||
|
||||
Route::prefix('board')
|
||||
->name('board.')
|
||||
@@ -20,7 +23,7 @@ Route::prefix('board')
|
||||
require __DIR__.'/settings/board.php';
|
||||
});
|
||||
|
||||
Route::get('metadata', [ProjectTicketMetadataSettingsController::class, 'show'])->name('metadata');
|
||||
Route::get('metadata', [ProjectSettingsController::class, 'metadata'])->name('metadata');
|
||||
Route::patch('metadata', [ProjectTicketMetadataSettingsController::class, 'update'])->name('metadata.update');
|
||||
|
||||
Route::prefix('metadata')
|
||||
@@ -30,7 +33,7 @@ Route::prefix('metadata')
|
||||
require __DIR__.'/settings/metadata.php';
|
||||
});
|
||||
|
||||
Route::get('repositories', [ProjectRepositorySettingsController::class, 'show'])->name('repositories');
|
||||
Route::get('repositories', [ProjectSettingsController::class, 'repositories'])->name('repositories');
|
||||
|
||||
Route::prefix('repositories')
|
||||
->name('repositories.')
|
||||
@@ -39,7 +42,7 @@ Route::prefix('repositories')
|
||||
require __DIR__.'/settings/repositories.php';
|
||||
});
|
||||
|
||||
Route::get('notifications', [ProjectNotificationSettingsController::class, 'show'])->name('notifications');
|
||||
Route::get('notifications', [ProjectSettingsController::class, 'notifications'])->name('notifications');
|
||||
|
||||
Route::prefix('notifications')
|
||||
->name('notifications.')
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Http\Controllers\ProjectBoardController;
|
||||
use App\Http\Controllers\ProjectController;
|
||||
use App\Http\Controllers\ProjectSettingsController;
|
||||
use App\Http\Controllers\ProjectTemplateController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
@@ -31,7 +32,7 @@ Route::prefix('projects')->name('projects.')->group(function (): void {
|
||||
->name('destroy');
|
||||
|
||||
Route::middleware('can:admin-project,project')->group(function (): void {
|
||||
Route::get('access', [ProjectController::class, 'access'])->name('access');
|
||||
Route::get('access', [ProjectSettingsController::class, 'access'])->name('access');
|
||||
|
||||
Route::prefix('permissions')->name('permissions.')->group(function (): void {
|
||||
Route::patch('users/{user}', [ProjectController::class, 'updateUserPermissions'])
|
||||
|
||||
@@ -57,6 +57,7 @@ class ProjectBacklogTest extends TestCase
|
||||
->component('projects/Backlog')
|
||||
->where('backlogTicketStatuses.0.name', 'Backlog')
|
||||
->where('canEditTickets', false)
|
||||
->where('canManageProject', false)
|
||||
->where('project.id', $project->id)
|
||||
->where('tickets.0.ticket_key', $ticket->ticket_key)
|
||||
->where('canCreateTickets', false),
|
||||
|
||||
@@ -37,7 +37,7 @@ class ProjectBoardControllerTest extends TestCase
|
||||
'name' => 'Doing',
|
||||
'position' => 2,
|
||||
]);
|
||||
$this->createColumnStatus($todoColumn);
|
||||
$todoStatus = $this->createColumnStatus($todoColumn);
|
||||
$doingStatus = $this->createColumnStatus($doingColumn);
|
||||
$backlogStatus = $this->backlogStatus($project);
|
||||
|
||||
@@ -45,6 +45,10 @@ class ProjectBoardControllerTest extends TestCase
|
||||
'name' => 'High',
|
||||
'position' => 2,
|
||||
]);
|
||||
$assignee = User::factory()->create([
|
||||
'name' => 'Assigned User',
|
||||
'avatar_path' => 'avatars/generated/assigned-user.svg',
|
||||
]);
|
||||
|
||||
Ticket::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
@@ -57,6 +61,7 @@ class ProjectBoardControllerTest extends TestCase
|
||||
]);
|
||||
|
||||
$boardTicket = Ticket::factory()->create([
|
||||
'assignee_user_id' => $assignee->id,
|
||||
'project_id' => $project->id,
|
||||
'sequence_number' => 2,
|
||||
'ticket_key' => 'CORE-2',
|
||||
@@ -67,6 +72,7 @@ class ProjectBoardControllerTest extends TestCase
|
||||
]);
|
||||
|
||||
$this->syncProjectPermissions($project, $user, ['view']);
|
||||
$this->allowMove($doingStatus, $todoStatus);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('projects.board.index', $project))
|
||||
@@ -80,8 +86,14 @@ class ProjectBoardControllerTest extends TestCase
|
||||
->has('boardColumns.1.tickets', 1)
|
||||
->where('boardColumns.1.tickets.0.ticket_key', $boardTicket->ticket_key)
|
||||
->where('boardColumns.1.tickets.0.title', $boardTicket->title)
|
||||
->where('boardColumns.1.tickets.0.assignee.id', $assignee->id)
|
||||
->where('boardColumns.1.tickets.0.assignee.name', $assignee->name)
|
||||
->where('boardColumns.1.tickets.0.assignee.avatar', $assignee->avatar)
|
||||
->where('boardColumns.1.tickets.0.ticket_status.allowed_target_statuses.0.id', $todoStatus->id)
|
||||
->where('boardColumns.1.tickets.0.ticket_status.allowed_target_statuses.0.board_column.id', $todoColumn->id)
|
||||
->where('canCreateTickets', false)
|
||||
->where('canEditTickets', false),
|
||||
->where('canEditTickets', false)
|
||||
->where('canManageProject', false),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,6 +155,75 @@ class ProjectBoardControllerTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure inline status moves return the destination and its allowed follow-up transitions.
|
||||
*/
|
||||
public function test_ticket_status_moves_return_json_with_follow_up_transitions(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'key' => 'CORE',
|
||||
]);
|
||||
|
||||
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||
|
||||
$todoColumn = BoardColumn::factory()->for($project)->create([
|
||||
'name' => 'Todo',
|
||||
'position' => 1,
|
||||
]);
|
||||
$reviewColumn = BoardColumn::factory()->for($project)->create([
|
||||
'name' => 'Review',
|
||||
'position' => 2,
|
||||
]);
|
||||
$todoStatus = $this->createColumnStatus($todoColumn);
|
||||
$reviewStatus = $this->createColumnStatus($reviewColumn);
|
||||
$backlogStatus = $this->backlogStatus($project);
|
||||
$this->allowMove($backlogStatus, $todoStatus);
|
||||
$this->allowMove($todoStatus, $reviewStatus);
|
||||
|
||||
$priority = TicketPriority::factory()->for($project)->create([
|
||||
'name' => 'High',
|
||||
'position' => 2,
|
||||
]);
|
||||
|
||||
$ticket = Ticket::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'sequence_number' => 1,
|
||||
'ticket_key' => 'CORE-1',
|
||||
'ticket_priority_id' => $priority->id,
|
||||
'ticket_status_id' => $backlogStatus->id,
|
||||
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
|
||||
'title' => 'Inline status ticket',
|
||||
]);
|
||||
|
||||
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->patchJson(route('projects.tickets.status.update', [$project, $ticket]), [
|
||||
'redirect_to' => 'backlog',
|
||||
'ticket_status_id' => $todoStatus->id,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('message', 'CORE-1 moved to Todo.')
|
||||
->assertJsonPath('moved', true)
|
||||
->assertJsonPath('ticket_status.id', $todoStatus->id)
|
||||
->assertJsonPath('ticket_status.board_column.id', $todoColumn->id)
|
||||
->assertJsonPath('ticket_status.allowed_target_statuses.0.id', $reviewStatus->id)
|
||||
->assertJsonPath('ticket_status.allowed_target_statuses.0.board_column.id', $reviewColumn->id)
|
||||
->assertJsonPath('ticket_status_id', $todoStatus->id);
|
||||
|
||||
$this->assertDatabaseHas('tickets', [
|
||||
'id' => $ticket->id,
|
||||
'ticket_status_id' => $todoStatus->id,
|
||||
]);
|
||||
$this->assertDatabaseHas('ticket_worklog_entries', [
|
||||
'actor_id' => $user->id,
|
||||
'entry_type' => 'status_changed',
|
||||
'summary' => 'Moved from Backlog to Todo.',
|
||||
'ticket_id' => $ticket->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure board tickets can move between columns.
|
||||
*/
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
namespace Tests\Feature\Projects;
|
||||
|
||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
@@ -45,16 +45,20 @@ class ProjectBoardSettingsControllerTest extends TestCase
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('projects.settings.board', $project))
|
||||
->get(route('projects.settings.index', [
|
||||
'project' => $project,
|
||||
'section' => 'board',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('projects/settings/Board')
|
||||
->component('projects/Settings')
|
||||
->where('activeSection', 'board')
|
||||
->where('project.id', $project->id)
|
||||
->where('backlogTicketStatuses.0.name', 'Backlog')
|
||||
->where('boardColumns.0.name', 'Todo')
|
||||
->where('ticketStatuses.0.name', 'Backlog')
|
||||
->where('templateState.templateName', 'Starter Workflow')
|
||||
->where('templateState.inheritedAreas.0.value', DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW),
|
||||
->where('sectionData.backlogTicketStatuses.0.name', 'Backlog')
|
||||
->where('sectionData.boardColumns.0.name', 'Todo')
|
||||
->where('sectionData.ticketStatuses.0.name', 'Backlog')
|
||||
->where('sectionData.templateState.templateName', 'Starter Workflow')
|
||||
->where('sectionData.templateState.inheritedAreas.0.value', DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,7 +91,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
|
||||
->post(route('projects.settings.board.columns.store', $project), [
|
||||
'name' => 'In Review',
|
||||
])
|
||||
->assertRedirect(route('projects.settings.board', $project))
|
||||
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'board']))
|
||||
->assertSessionHas('status', 'In Review was added to the board workflow.');
|
||||
|
||||
$this->assertDatabaseHas('board_columns', [
|
||||
@@ -131,7 +135,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
|
||||
'name' => 'Ready for QA',
|
||||
'position' => 1,
|
||||
])
|
||||
->assertRedirect(route('projects.settings.board', $project))
|
||||
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'board']))
|
||||
->assertSessionHas('status', 'Ready for QA was updated.');
|
||||
|
||||
$firstColumn->refresh();
|
||||
@@ -168,7 +172,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('projects.settings.board.columns.destroy', [$project, $secondColumn]))
|
||||
->assertRedirect(route('projects.settings.board', $project))
|
||||
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'board']))
|
||||
->assertSessionHas('status', 'In Progress was removed from the board workflow.');
|
||||
|
||||
$firstColumn->refresh();
|
||||
@@ -209,7 +213,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
|
||||
'board_column_id' => $boardColumn->id,
|
||||
'name' => 'Ready for QA',
|
||||
])
|
||||
->assertRedirect(route('projects.settings.board', $project))
|
||||
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'board']))
|
||||
->assertSessionHas('status', 'Ready for QA was added to Todo.');
|
||||
|
||||
$newStatus = TicketStatus::query()
|
||||
@@ -225,7 +229,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
|
||||
->patch(route('projects.settings.board.statuses.update', [$project, $newStatus]), [
|
||||
'name' => 'QA Ready',
|
||||
])
|
||||
->assertRedirect(route('projects.settings.board', $project))
|
||||
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'board']))
|
||||
->assertSessionHas('status', 'QA Ready was updated.');
|
||||
|
||||
$newStatus->refresh();
|
||||
@@ -240,7 +244,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('projects.settings.board.statuses.destroy', [$project, $newStatus]))
|
||||
->assertRedirect(route('projects.settings.board', $project))
|
||||
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'board']))
|
||||
->assertSessionHas('status', 'QA Ready was removed from Todo.');
|
||||
|
||||
$this->assertDatabaseMissing('ticket_statuses', [
|
||||
@@ -280,7 +284,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
|
||||
->patch(route('projects.settings.board.statuses.transitions.update', [$project, $sourceStatus]), [
|
||||
'allowed_ticket_status_ids' => [$reviewStatus->id, $doneStatus->id],
|
||||
])
|
||||
->assertRedirect(route('projects.settings.board', $project))
|
||||
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'board']))
|
||||
->assertSessionHas('status', 'Progress move targets were updated.');
|
||||
|
||||
$this->assertEqualsCanonicalizing(
|
||||
@@ -290,7 +294,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
|
||||
|
||||
$this->actingAs($user)
|
||||
->patch(route('projects.settings.board.statuses.transitions.update', [$project, $sourceStatus]), [])
|
||||
->assertRedirect(route('projects.settings.board', $project))
|
||||
->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'board']))
|
||||
->assertSessionHas('status', 'Progress move targets were updated.');
|
||||
|
||||
$this->assertCount(0, $sourceStatus->allowedTargetStatuses()->get());
|
||||
|
||||
@@ -174,7 +174,7 @@ class ProjectControllerTest extends TestCase
|
||||
|
||||
$project = Project::query()->firstOrFail();
|
||||
|
||||
$response->assertRedirect(route('projects.show', $project));
|
||||
$response->assertRedirect(route('projects.board.index', $project));
|
||||
|
||||
$this->assertDatabaseHas('projects', [
|
||||
'name' => 'Kanban Foundation',
|
||||
@@ -281,7 +281,7 @@ class ProjectControllerTest extends TestCase
|
||||
|
||||
$project = Project::query()->where('key', 'TMP1')->firstOrFail();
|
||||
|
||||
$response->assertRedirect(route('projects.show', $project));
|
||||
$response->assertRedirect(route('projects.board.index', $project));
|
||||
|
||||
$project->refresh();
|
||||
|
||||
@@ -412,7 +412,7 @@ class ProjectControllerTest extends TestCase
|
||||
|
||||
$project = Project::query()->where('key', 'DUPE')->firstOrFail();
|
||||
|
||||
$response->assertRedirect(route('projects.show', $project));
|
||||
$response->assertRedirect(route('projects.board.index', $project));
|
||||
|
||||
$this->assertSame('Duplicated Project', $project->name);
|
||||
$this->assertFalse($project->show_author);
|
||||
@@ -469,9 +469,9 @@ class ProjectControllerTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure authenticated users can view the project hub page.
|
||||
* Ensure authenticated users enter a project through its board.
|
||||
*/
|
||||
public function test_authenticated_users_can_view_a_project_hub_page(): void
|
||||
public function test_authenticated_users_are_redirected_to_the_project_board(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
@@ -482,13 +482,7 @@ class ProjectControllerTest extends TestCase
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('projects.show', $project))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('projects/Show')
|
||||
->where('canManageAccess', false)
|
||||
->where('project.id', $project->id)
|
||||
->where('project.key', 'CORE'),
|
||||
);
|
||||
->assertRedirect(route('projects.board.index', $project));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -507,11 +501,7 @@ class ProjectControllerTest extends TestCase
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('projects.show', $project))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('projects/Show')
|
||||
->where('project.id', $project->id),
|
||||
);
|
||||
->assertRedirect(route('projects.board.index', $project));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -544,14 +534,10 @@ class ProjectControllerTest extends TestCase
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('projects.access', $project))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('projects/Access')
|
||||
->where('project.id', $project->id)
|
||||
->has('users', 1)
|
||||
->has('teams', 1)
|
||||
->where('permissionOptions.0.value', 'project:read'),
|
||||
);
|
||||
->assertRedirect(route('projects.settings.index', [
|
||||
'project' => $project,
|
||||
'section' => 'access',
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -566,12 +552,10 @@ class ProjectControllerTest extends TestCase
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('projects.settings.general', $project))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('projects/settings/General')
|
||||
->where('project.id', $project->id)
|
||||
->where('project.key', 'CORE'),
|
||||
);
|
||||
->assertRedirect(route('projects.settings.index', [
|
||||
'project' => $project,
|
||||
'section' => 'general',
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -588,11 +572,10 @@ class ProjectControllerTest extends TestCase
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('projects.settings.general', $project))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('projects/settings/General')
|
||||
->where('project.id', $project->id),
|
||||
);
|
||||
->assertRedirect(route('projects.settings.index', [
|
||||
'project' => $project,
|
||||
'section' => 'general',
|
||||
]));
|
||||
}
|
||||
|
||||
public function test_project_admins_can_switch_projects_to_another_template(): void
|
||||
@@ -652,7 +635,10 @@ class ProjectControllerTest extends TestCase
|
||||
'confirmation_key' => 'CORE',
|
||||
'source_template_id' => $newTemplate->id,
|
||||
])
|
||||
->assertRedirect(route('projects.settings.general', $project))
|
||||
->assertRedirect(route('projects.settings.index', [
|
||||
'project' => $project,
|
||||
'section' => 'general',
|
||||
]))
|
||||
->assertSessionHas('status', 'Core Platform now uses the Operations Template template.');
|
||||
|
||||
$project->refresh();
|
||||
@@ -707,7 +693,10 @@ class ProjectControllerTest extends TestCase
|
||||
->post(route('projects.settings.general.reset-template', $project), [
|
||||
'confirmation_key' => 'CORE',
|
||||
])
|
||||
->assertRedirect(route('projects.settings.general', $project))
|
||||
->assertRedirect(route('projects.settings.index', [
|
||||
'project' => $project,
|
||||
'section' => 'general',
|
||||
]))
|
||||
->assertSessionHas('status', 'Core Platform was reset to the Workflow Template template baseline.');
|
||||
|
||||
$project->refresh();
|
||||
@@ -878,7 +867,10 @@ class ProjectControllerTest extends TestCase
|
||||
->patch(route('projects.permissions.users.update', [$project, $member]), [
|
||||
'permissions' => ['project:read', 'project:write'],
|
||||
])
|
||||
->assertRedirect(route('projects.access', $project))
|
||||
->assertRedirect(route('projects.settings.index', [
|
||||
'project' => $project,
|
||||
'section' => 'access',
|
||||
]))
|
||||
->assertSessionHas('status', 'Regular User permissions were updated.');
|
||||
|
||||
$this->assertDatabaseHas('permission_assignments', [
|
||||
@@ -917,7 +909,10 @@ class ProjectControllerTest extends TestCase
|
||||
->patch(route('projects.permissions.teams.update', [$project, $team]), [
|
||||
'permissions' => ['project:read', 'project:write'],
|
||||
])
|
||||
->assertRedirect(route('projects.access', $project))
|
||||
->assertRedirect(route('projects.settings.index', [
|
||||
'project' => $project,
|
||||
'section' => 'access',
|
||||
]))
|
||||
->assertSessionHas('status', 'Delivery Team permissions were updated.');
|
||||
|
||||
$this->assertDatabaseHas('permission_assignments', [
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user