mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-31 06:40:50 +01:00
feat: enhance Git operations and add changes management for databases
- Updated Git class to include options for fetching status and retrieving branches. - Introduced getRepoInfo function to fetch repository details from GitHub API. - Implemented changes page for database instances, allowing users to view and manage uncommitted changes. - Added actions for discarding and adding changes with appropriate logging. - Created UI components for displaying changes and managing actions. - Implemented server-side redirects based on database access token presence. - Enhanced delay profile management with improved delete functionality and logging.
This commit is contained in:
@@ -3,12 +3,25 @@
|
||||
import { X, User, GitBranch } from 'lucide-svelte';
|
||||
|
||||
export let open = false;
|
||||
export let mode: 'save' | 'delete' = 'save';
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
select: 'user' | 'base';
|
||||
cancel: void;
|
||||
}>();
|
||||
|
||||
$: title = mode === 'save' ? 'Where to save?' : 'Where to delete from?';
|
||||
$: userLabel = mode === 'save' ? 'Personal Override' : 'Remove Personal Override';
|
||||
$: userDescription =
|
||||
mode === 'save'
|
||||
? "Save locally only. Changes won't sync upstream and stay on this machine."
|
||||
: 'Remove your local override. The base database version will apply.';
|
||||
$: baseLabel = mode === 'save' ? 'Contribute to Database' : 'Delete from Database';
|
||||
$: baseDescription =
|
||||
mode === 'save'
|
||||
? "Add to base operations. You'll need to commit and push manually."
|
||||
: "Create a delete operation. You'll need to commit and push manually.";
|
||||
|
||||
function handleSelect(layer: 'user' | 'base') {
|
||||
dispatch('select', layer);
|
||||
}
|
||||
@@ -54,7 +67,7 @@
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-neutral-200 px-6 py-4 dark:border-neutral-800">
|
||||
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">Where to save?</h2>
|
||||
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
on:click={handleCancel}
|
||||
@@ -75,9 +88,9 @@
|
||||
<User size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-100">Personal Override</div>
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-100">{userLabel}</div>
|
||||
<div class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Save locally only. Changes won't sync upstream and stay on this machine.
|
||||
{userDescription}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -91,9 +104,9 @@
|
||||
<GitBranch size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-100">Contribute to Database</div>
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-100">{baseLabel}</div>
|
||||
<div class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Add to base operations. You'll need to commit and push manually.
|
||||
{baseDescription}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -147,6 +147,74 @@ function generateMetadataHeader(metadata: OperationMetadata): string {
|
||||
return lines.join('\n') + '\n\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse metadata from an operation file
|
||||
*/
|
||||
async function parseOperationMetadata(filepath: string): Promise<OperationMetadata | null> {
|
||||
try {
|
||||
const content = await Deno.readTextFile(filepath);
|
||||
const operationMatch = content.match(/^-- @operation:\s*(\w+)/m);
|
||||
const entityMatch = content.match(/^-- @entity:\s*(\w+)/m);
|
||||
const nameMatch = content.match(/^-- @name:\s*(.+)$/m);
|
||||
const previousNameMatch = content.match(/^-- @previous_name:\s*(.+)$/m);
|
||||
|
||||
if (!operationMatch || !entityMatch || !nameMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
operation: operationMatch[1] as OperationType,
|
||||
entity: entityMatch[1],
|
||||
name: nameMatch[1].trim(),
|
||||
previousName: previousNameMatch?.[1]?.trim()
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and remove a matching create operation for a delete
|
||||
* Returns true if a create was found and removed (no delete needed)
|
||||
*/
|
||||
async function cancelOutCreate(
|
||||
targetDir: string,
|
||||
metadata: OperationMetadata
|
||||
): Promise<boolean> {
|
||||
if (metadata.operation !== 'delete') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
for await (const entry of Deno.readDir(targetDir)) {
|
||||
if (!entry.isFile || !entry.name.endsWith('.sql')) continue;
|
||||
|
||||
const filepath = `${targetDir}/${entry.name}`;
|
||||
const fileMeta = await parseOperationMetadata(filepath);
|
||||
|
||||
// Check if this is a create operation for the same entity/name
|
||||
if (
|
||||
fileMeta &&
|
||||
fileMeta.operation === 'create' &&
|
||||
fileMeta.entity === metadata.entity &&
|
||||
fileMeta.name === metadata.name
|
||||
) {
|
||||
// Remove the create file - it cancels out with the delete
|
||||
await Deno.remove(filepath);
|
||||
await logger.info('Cancelled out create operation with delete', {
|
||||
source: 'PCDWriter',
|
||||
meta: { filepath, entity: metadata.entity, name: metadata.name }
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory doesn't exist or other error - proceed with normal write
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write operations to a PCD layer
|
||||
*
|
||||
@@ -176,6 +244,13 @@ export async function writeOperation(options: WriteOptions): Promise<WriteResult
|
||||
// Ensure directory exists
|
||||
await ensureDir(targetDir);
|
||||
|
||||
// Optimization: if this is a delete and there's an uncommitted create, just remove the create
|
||||
if (metadata && await cancelOutCreate(targetDir, metadata)) {
|
||||
// Recompile the cache after removing the create file
|
||||
await compile(instance.local_path, instance.id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Get next operation number
|
||||
const opNumber = await getNextOperationNumber(targetDir);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Git class - wraps git operations for a repository
|
||||
*/
|
||||
|
||||
import type { GitStatus, OperationFile, CommitResult, UpdateInfo } from './types.ts';
|
||||
import type { GitStatus, OperationFile, CommitResult, UpdateInfo, RepoInfo } from './types.ts';
|
||||
import * as repo from './repo.ts';
|
||||
import * as status from './status.ts';
|
||||
import * as ops from './ops.ts';
|
||||
@@ -19,7 +19,8 @@ export class Git {
|
||||
|
||||
// Status queries
|
||||
getBranch = () => status.getBranch(this.repoPath);
|
||||
status = (): Promise<GitStatus> => status.getStatus(this.repoPath);
|
||||
getBranches = () => status.getBranches(this.repoPath);
|
||||
status = (options?: status.GetStatusOptions): Promise<GitStatus> => status.getStatus(this.repoPath, options);
|
||||
checkForUpdates = (): Promise<UpdateInfo> => status.checkForUpdates(this.repoPath);
|
||||
getLastPushed = () => status.getLastPushed(this.repoPath);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
export { Git } from './Git.ts';
|
||||
export * from './types.ts';
|
||||
export type { GetStatusOptions } from './status.ts';
|
||||
|
||||
// Direct function exports
|
||||
export { clone } from './repo.ts';
|
||||
export { clone, getRepoInfo } from './repo.ts';
|
||||
|
||||
@@ -69,24 +69,23 @@ export async function getUncommittedOps(repoPath: string): Promise<OperationFile
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the highest operation number in ops/
|
||||
* Get the highest operation number from COMMITTED files in ops/
|
||||
*/
|
||||
export async function getMaxOpNumber(repoPath: string): Promise<number> {
|
||||
let maxNum = 0;
|
||||
const opsPath = `${repoPath}/ops`;
|
||||
|
||||
try {
|
||||
for await (const entry of Deno.readDir(opsPath)) {
|
||||
if (entry.isFile && entry.name.endsWith('.sql')) {
|
||||
const match = entry.name.match(/^(\d+)\./);
|
||||
if (match) {
|
||||
const num = parseInt(match[1], 10);
|
||||
if (num > maxNum) maxNum = num;
|
||||
}
|
||||
}
|
||||
// Use git ls-tree to only count committed files
|
||||
const output = await execGitSafe(['ls-tree', '--name-only', 'HEAD', 'ops/'], repoPath);
|
||||
if (!output) return maxNum;
|
||||
|
||||
for (const filename of output.split('\n')) {
|
||||
if (!filename.trim() || !filename.endsWith('.sql')) continue;
|
||||
const basename = filename.replace('ops/', '');
|
||||
const match = basename.match(/^(\d+)\./);
|
||||
if (match) {
|
||||
const num = parseInt(match[1], 10);
|
||||
if (num > maxNum) maxNum = num;
|
||||
}
|
||||
} catch {
|
||||
// Directory might not exist
|
||||
}
|
||||
|
||||
return maxNum;
|
||||
@@ -112,6 +111,11 @@ export async function discardOps(repoPath: string, filepaths: string[]): Promise
|
||||
|
||||
/**
|
||||
* Add operation files: pull, renumber if needed, commit, push
|
||||
*
|
||||
* TODO: This functionality needs to be redesigned. The current approach of
|
||||
* pull -> renumber -> commit -> push has race conditions and edge cases that
|
||||
* can leave files in inconsistent states if any step fails mid-way. We also want to better
|
||||
* choose when / how renumbering happens (e.g., only when there are conflicts).
|
||||
*/
|
||||
export async function addOps(
|
||||
repoPath: string,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { execGit, execGitSafe } from './exec.ts';
|
||||
import type { RepoInfo } from './types.ts';
|
||||
|
||||
/**
|
||||
* Validate that a repository URL is accessible and detect if it's private
|
||||
@@ -147,7 +148,11 @@ export async function resetToRemote(repoPath: string): Promise<void> {
|
||||
*/
|
||||
export async function stage(repoPath: string, filepaths: string[]): Promise<void> {
|
||||
for (const filepath of filepaths) {
|
||||
await execGit(['add', filepath], repoPath);
|
||||
// Convert to relative path if it starts with the repo path
|
||||
const relativePath = filepath.startsWith(repoPath + '/')
|
||||
? filepath.slice(repoPath.length + 1)
|
||||
: filepath;
|
||||
await execGit(['add', relativePath], repoPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,3 +162,56 @@ export async function stage(repoPath: string, filepaths: string[]): Promise<void
|
||||
export async function commit(repoPath: string, message: string): Promise<void> {
|
||||
await execGit(['commit', '-m', message], repoPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get repository info from GitHub API
|
||||
*/
|
||||
export async function getRepoInfo(
|
||||
repositoryUrl: string,
|
||||
personalAccessToken?: string | null
|
||||
): Promise<RepoInfo | null> {
|
||||
const githubPattern = /^https:\/\/github\.com\/([\w-]+)\/([\w-]+)\/?$/;
|
||||
const normalizedUrl = repositoryUrl.replace(/\.git$/, '');
|
||||
const match = normalizedUrl.match(githubPattern);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, owner, repo] = match;
|
||||
const apiUrl = `https://api.github.com/repos/${owner}/${repo}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'User-Agent': 'Profilarr'
|
||||
};
|
||||
|
||||
if (personalAccessToken) {
|
||||
headers['Authorization'] = `Bearer ${personalAccessToken}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await globalThis.fetch(apiUrl, { headers });
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
owner: data.owner.login,
|
||||
repo: data.name,
|
||||
description: data.description,
|
||||
stars: data.stargazers_count,
|
||||
forks: data.forks_count,
|
||||
openIssues: data.open_issues_count,
|
||||
ownerAvatarUrl: data.owner.avatar_url,
|
||||
ownerType: data.owner.type,
|
||||
htmlUrl: data.html_url
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,14 +13,21 @@ export async function getBranch(repoPath: string): Promise<string> {
|
||||
return await execGit(['branch', '--show-current'], repoPath);
|
||||
}
|
||||
|
||||
export interface GetStatusOptions {
|
||||
/** Whether to fetch from remote first (slower but accurate ahead/behind) */
|
||||
fetch?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full repository status
|
||||
*/
|
||||
export async function getStatus(repoPath: string): Promise<GitStatus> {
|
||||
export async function getStatus(repoPath: string, options: GetStatusOptions = {}): Promise<GitStatus> {
|
||||
const branch = await getBranch(repoPath);
|
||||
|
||||
// Fetch to get accurate ahead/behind
|
||||
await fetch(repoPath);
|
||||
// Optionally fetch to get accurate ahead/behind
|
||||
if (options.fetch) {
|
||||
await fetch(repoPath);
|
||||
}
|
||||
|
||||
// Get ahead/behind
|
||||
let ahead = 0;
|
||||
@@ -109,3 +116,19 @@ export async function getLastPushed(repoPath: string): Promise<string | null> {
|
||||
const output = await execGitSafe(['log', '-1', '--format=%cI', `origin/${branch}`], repoPath);
|
||||
return output || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all local and remote branches
|
||||
*/
|
||||
export async function getBranches(repoPath: string): Promise<string[]> {
|
||||
// Get all branches (local and remote tracking)
|
||||
const output = await execGit(['branch', '-a', '--format=%(refname:short)'], repoPath);
|
||||
const branches = output
|
||||
.split('\n')
|
||||
.map((b) => b.trim())
|
||||
.filter((b) => b && !b.includes('HEAD'))
|
||||
.map((b) => b.replace(/^origin\//, ''))
|
||||
.filter((b, i, arr) => arr.indexOf(b) === i); // dedupe
|
||||
|
||||
return branches;
|
||||
}
|
||||
|
||||
@@ -33,3 +33,15 @@ export interface UpdateInfo {
|
||||
latestRemoteCommit: string;
|
||||
currentLocalCommit: string;
|
||||
}
|
||||
|
||||
export interface RepoInfo {
|
||||
owner: string;
|
||||
repo: string;
|
||||
description: string | null;
|
||||
stars: number;
|
||||
forks: number;
|
||||
openIssues: number;
|
||||
ownerAvatarUrl: string;
|
||||
ownerType: 'User' | 'Organization';
|
||||
htmlUrl: string;
|
||||
}
|
||||
|
||||
21
src/routes/databases/[id]/+layout.server.ts
Normal file
21
src/routes/databases/[id]/+layout.server.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts';
|
||||
|
||||
export const load: LayoutServerLoad = ({ params }) => {
|
||||
const id = parseInt(params.id || '', 10);
|
||||
|
||||
if (isNaN(id)) {
|
||||
error(400, 'Invalid database ID');
|
||||
}
|
||||
|
||||
const database = databaseInstancesQueries.getById(id);
|
||||
|
||||
if (!database) {
|
||||
error(404, 'Database not found');
|
||||
}
|
||||
|
||||
return {
|
||||
database
|
||||
};
|
||||
};
|
||||
38
src/routes/databases/[id]/+layout.svelte
Normal file
38
src/routes/databases/[id]/+layout.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import Tabs from '$ui/navigation/tabs/Tabs.svelte';
|
||||
import { RefreshCw, GitBranch } from 'lucide-svelte';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
$: instanceId = $page.params.id;
|
||||
$: currentPath = $page.url.pathname;
|
||||
$: hasToken = !!$page.data.database?.personal_access_token;
|
||||
|
||||
$: tabs = [
|
||||
{
|
||||
label: 'Sync',
|
||||
href: `/databases/${instanceId}/sync`,
|
||||
icon: RefreshCw,
|
||||
active: currentPath.includes('/sync')
|
||||
},
|
||||
...(hasToken
|
||||
? [
|
||||
{
|
||||
label: 'Changes',
|
||||
href: `/databases/${instanceId}/changes`,
|
||||
icon: GitBranch,
|
||||
active: currentPath.includes('/changes')
|
||||
}
|
||||
]
|
||||
: [])
|
||||
];
|
||||
|
||||
$: backButton = {
|
||||
label: 'Back',
|
||||
href: '/databases'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="p-8">
|
||||
<Tabs {tabs} {backButton} />
|
||||
<slot />
|
||||
</div>
|
||||
@@ -1,21 +1,13 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { ServerLoad } from '@sveltejs/kit';
|
||||
import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: ServerLoad = ({ params }) => {
|
||||
const id = parseInt(params.id || '', 10);
|
||||
export const load: PageServerLoad = async ({ params, parent }) => {
|
||||
const { database } = await parent();
|
||||
|
||||
if (isNaN(id)) {
|
||||
error(400, 'Invalid database ID');
|
||||
// Dev databases (with PAT) go to changes, others go to sync
|
||||
if (database.personal_access_token) {
|
||||
redirect(302, `/databases/${params.id}/changes`);
|
||||
} else {
|
||||
redirect(302, `/databases/${params.id}/sync`);
|
||||
}
|
||||
|
||||
const database = databaseInstancesQueries.getById(id);
|
||||
|
||||
if (!database) {
|
||||
error(404, 'Database not found');
|
||||
}
|
||||
|
||||
return {
|
||||
database
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,19 +1 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import { Pencil } from 'lucide-svelte';
|
||||
|
||||
export let data: PageData;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.database.name} - Profilarr</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Floating Edit Button -->
|
||||
<a
|
||||
href="/databases/{data.database.id}/edit"
|
||||
class="group fixed right-8 bottom-8 z-50 flex h-12 w-12 items-center justify-center rounded-full border border-neutral-200 bg-white text-neutral-700 shadow-md transition-all hover:scale-110 hover:border-neutral-300 hover:shadow-lg dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:border-neutral-600"
|
||||
aria-label="Edit database"
|
||||
>
|
||||
<Pencil size={18} class="transition-transform duration-300 group-hover:rotate-12" />
|
||||
</a>
|
||||
<!-- Server-side redirect to /sync -->
|
||||
|
||||
108
src/routes/databases/[id]/changes/+page.server.ts
Normal file
108
src/routes/databases/[id]/changes/+page.server.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts';
|
||||
import { Git, getRepoInfo } from '$utils/git/index.ts';
|
||||
import { logger } from '$logger/logger.ts';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent }) => {
|
||||
const { database } = await parent();
|
||||
|
||||
if (!database.personal_access_token) {
|
||||
error(403, 'Changes page requires a personal access token');
|
||||
}
|
||||
|
||||
const git = new Git(database.local_path);
|
||||
|
||||
const [status, uncommittedOps, lastPushed, branches, repoInfo] = await Promise.all([
|
||||
git.status(),
|
||||
git.getUncommittedOps(),
|
||||
git.getLastPushed(),
|
||||
git.getBranches(),
|
||||
getRepoInfo(database.repository_url, database.personal_access_token)
|
||||
]);
|
||||
|
||||
return {
|
||||
status,
|
||||
uncommittedOps,
|
||||
lastPushed,
|
||||
branches,
|
||||
repoInfo
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
discard: async ({ request, params }) => {
|
||||
const id = parseInt(params.id || '', 10);
|
||||
const database = databaseInstancesQueries.getById(id);
|
||||
|
||||
if (!database) {
|
||||
return { success: false, error: 'Database not found' };
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const files = formData.getAll('files') as string[];
|
||||
|
||||
if (files.length === 0) {
|
||||
return { success: false, error: 'No files selected' };
|
||||
}
|
||||
|
||||
const git = new Git(database.local_path);
|
||||
await git.discardOps(files);
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
add: async ({ request, params }) => {
|
||||
const id = parseInt(params.id || '', 10);
|
||||
const database = databaseInstancesQueries.getById(id);
|
||||
|
||||
if (!database) {
|
||||
return { success: false, error: 'Database not found' };
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const files = formData.getAll('files') as string[];
|
||||
const message = formData.get('message') as string;
|
||||
|
||||
const git = new Git(database.local_path);
|
||||
const result = await git.addOps(files, message);
|
||||
|
||||
if (result.success) {
|
||||
await logger.info('Changes committed and pushed', {
|
||||
source: 'changes',
|
||||
meta: { databaseId: id, files: files.length, message }
|
||||
});
|
||||
} else {
|
||||
await logger.error('Failed to add changes', {
|
||||
source: 'changes',
|
||||
meta: { databaseId: id, error: result.error, files }
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
checkout: async ({ request, params }) => {
|
||||
const id = parseInt(params.id || '', 10);
|
||||
const database = databaseInstancesQueries.getById(id);
|
||||
|
||||
if (!database) {
|
||||
return { success: false, error: 'Database not found' };
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const branch = formData.get('branch') as string;
|
||||
|
||||
if (!branch) {
|
||||
return { success: false, error: 'No branch specified' };
|
||||
}
|
||||
|
||||
try {
|
||||
const git = new Git(database.local_path);
|
||||
await git.checkout(branch);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Failed to switch branch' };
|
||||
}
|
||||
}
|
||||
};
|
||||
265
src/routes/databases/[id]/changes/+page.svelte
Normal file
265
src/routes/databases/[id]/changes/+page.svelte
Normal file
@@ -0,0 +1,265 @@
|
||||
<script lang="ts">
|
||||
import Table from '$ui/table/Table.svelte';
|
||||
import type { Column } from '$ui/table/types';
|
||||
import ChangesActionsBar from './components/ChangesActionsBar.svelte';
|
||||
import StatusCard from './components/StatusCard.svelte';
|
||||
import { Check } from 'lucide-svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { alertStore } from '$alerts/store';
|
||||
import type { PageData } from './$types';
|
||||
import type { OperationFile } from '$utils/git/types';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let selected = new Set<string>();
|
||||
let commitMessage = '';
|
||||
|
||||
$: allSelected = data.uncommittedOps.length > 0 && selected.size === data.uncommittedOps.length;
|
||||
|
||||
function toggleAll() {
|
||||
if (allSelected) {
|
||||
selected = new Set();
|
||||
} else {
|
||||
selected = new Set(data.uncommittedOps.map((op) => op.filepath));
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRow(filepath: string) {
|
||||
const newSelected = new Set(selected);
|
||||
if (newSelected.has(filepath)) {
|
||||
newSelected.delete(filepath);
|
||||
} else {
|
||||
newSelected.add(filepath);
|
||||
}
|
||||
selected = newSelected;
|
||||
}
|
||||
|
||||
async function handleDiscard() {
|
||||
const formData = new FormData();
|
||||
for (const filepath of selected) {
|
||||
formData.append('files', filepath);
|
||||
}
|
||||
|
||||
const response = await fetch('?/discard', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.type === 'success' && result.data?.success) {
|
||||
selected = new Set();
|
||||
alertStore.add('success', 'Changes discarded');
|
||||
await invalidateAll();
|
||||
} else {
|
||||
alertStore.add('error', result.data?.error || 'Failed to discard changes');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
const formData = new FormData();
|
||||
for (const filepath of selected) {
|
||||
formData.append('files', filepath);
|
||||
}
|
||||
formData.append('message', commitMessage);
|
||||
|
||||
const response = await fetch('?/add', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.type === 'success' && result.data?.success) {
|
||||
commitMessage = '';
|
||||
alertStore.add('success', 'Changes committed and pushed');
|
||||
} else {
|
||||
alertStore.add('error', result.data?.error || 'Failed to add changes');
|
||||
}
|
||||
|
||||
// Always refresh to keep UI in sync with file system
|
||||
selected = new Set();
|
||||
await invalidateAll();
|
||||
}
|
||||
|
||||
function formatOperation(op: string | null): string {
|
||||
if (!op) return '-';
|
||||
return op.charAt(0).toUpperCase() + op.slice(1);
|
||||
}
|
||||
|
||||
function getOperationClass(op: string | null): string {
|
||||
switch (op) {
|
||||
case 'create':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||
case 'update':
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
|
||||
case 'delete':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||
default:
|
||||
return 'bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200';
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<OperationFile>[] = [
|
||||
{
|
||||
key: 'operation',
|
||||
header: 'Operation',
|
||||
width: 'w-28',
|
||||
sortable: true,
|
||||
cell: (row: OperationFile) => {
|
||||
const op = formatOperation(row.operation);
|
||||
return {
|
||||
html: `<span class="inline-flex px-2 py-0.5 rounded text-xs font-mono ${getOperationClass(row.operation)}">${op}</span>`
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'entity',
|
||||
header: 'Entity',
|
||||
width: 'w-36',
|
||||
sortable: true,
|
||||
cell: (row: OperationFile) => row.entity || '-'
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Name',
|
||||
sortable: true,
|
||||
cell: (row: OperationFile) => ({
|
||||
html:
|
||||
row.previousName && row.previousName !== row.name
|
||||
? `<div><span class="line-through text-neutral-400">${row.previousName}</span> → ${row.name || '-'}</div>`
|
||||
: row.name || '-'
|
||||
})
|
||||
},
|
||||
{
|
||||
key: 'filename',
|
||||
header: 'File',
|
||||
width: 'w-48',
|
||||
cell: (row: OperationFile) => ({
|
||||
html: `<span class="font-mono text-xs text-neutral-500 dark:text-neutral-400">${row.filename}</span>`
|
||||
})
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Changes - {data.database.name} - Profilarr</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<StatusCard
|
||||
status={data.status}
|
||||
repoInfo={data.repoInfo}
|
||||
branches={data.branches}
|
||||
databaseId={data.database.id}
|
||||
/>
|
||||
|
||||
<!-- Actions Bar -->
|
||||
<ChangesActionsBar
|
||||
selectedCount={selected.size}
|
||||
bind:commitMessage
|
||||
onDiscard={handleDiscard}
|
||||
onAdd={handleAdd}
|
||||
/>
|
||||
|
||||
<!-- Table -->
|
||||
{#if data.uncommittedOps.length === 0}
|
||||
<div
|
||||
class="rounded-lg border border-neutral-200 bg-white p-8 text-center dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
<p class="text-neutral-600 dark:text-neutral-400">No uncommitted changes</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-800">
|
||||
<table class="w-full">
|
||||
<thead
|
||||
class="border-b border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-800"
|
||||
>
|
||||
<tr>
|
||||
<th class="w-12 px-4 py-3 text-center">
|
||||
<button type="button" on:click={toggleAll} class="inline-flex">
|
||||
<div
|
||||
class="flex h-5 w-5 items-center justify-center rounded border-2 transition-all {allSelected
|
||||
? 'border-blue-600 bg-blue-600 dark:border-blue-500 dark:bg-blue-500'
|
||||
: 'border-neutral-300 bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800'}"
|
||||
>
|
||||
{#if allSelected}
|
||||
<Check size={14} class="text-white" />
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
Operation
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
Entity
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
Name
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
File
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
class="divide-y divide-neutral-200 bg-white dark:divide-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
{#each data.uncommittedOps as op}
|
||||
<tr
|
||||
class="cursor-pointer transition-colors hover:bg-neutral-50 dark:hover:bg-neutral-800/50"
|
||||
on:click={() => toggleRow(op.filepath)}
|
||||
>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<div
|
||||
class="mx-auto flex h-5 w-5 items-center justify-center rounded border-2 transition-all {selected.has(op.filepath)
|
||||
? 'border-blue-600 bg-blue-600 dark:border-blue-500 dark:bg-blue-500'
|
||||
: 'border-neutral-300 bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800'}"
|
||||
>
|
||||
{#if selected.has(op.filepath)}
|
||||
<Check size={14} class="text-white" />
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="inline-flex rounded px-2 py-0.5 font-mono text-xs {getOperationClass(
|
||||
op.operation
|
||||
)}"
|
||||
>
|
||||
{formatOperation(op.operation)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{op.entity || '-'}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{#if op.previousName && op.previousName !== op.name}
|
||||
<span class="text-neutral-400 line-through">{op.previousName}</span>
|
||||
→
|
||||
{op.name || '-'}
|
||||
{:else}
|
||||
{op.name || '-'}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-mono text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{op.filename}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { Trash2, Upload } from 'lucide-svelte';
|
||||
import ActionsBar from '$ui/actions/ActionsBar.svelte';
|
||||
import ActionButton from '$ui/actions/ActionButton.svelte';
|
||||
import Dropdown from '$ui/dropdown/Dropdown.svelte';
|
||||
|
||||
export let selectedCount: number;
|
||||
export let commitMessage: string;
|
||||
|
||||
export let onDiscard: () => void;
|
||||
export let onAdd: () => void;
|
||||
|
||||
$: canDiscard = selectedCount > 0;
|
||||
$: canAdd = selectedCount > 0 && commitMessage.trim().length > 0;
|
||||
</script>
|
||||
|
||||
<ActionsBar className="w-full">
|
||||
<div class="relative flex flex-1">
|
||||
<div
|
||||
class="flex h-10 w-full items-center border border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-800"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={commitMessage}
|
||||
placeholder="Commit message..."
|
||||
class="h-full w-full bg-transparent px-3 font-mono text-sm text-neutral-700 placeholder-neutral-400 outline-none dark:text-neutral-300 dark:placeholder-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ActionButton
|
||||
icon={Upload}
|
||||
hasDropdown={true}
|
||||
dropdownPosition="right"
|
||||
on:click={canAdd ? onAdd : undefined}
|
||||
>
|
||||
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
|
||||
<Dropdown position={dropdownPosition} {open} minWidth="12rem">
|
||||
<div class="px-3 py-2 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{#if !selectedCount}
|
||||
Select changes to add
|
||||
{:else if !commitMessage.trim()}
|
||||
Enter a commit message
|
||||
{:else}
|
||||
Add {selectedCount} change{selectedCount === 1 ? '' : 's'}
|
||||
{/if}
|
||||
</div>
|
||||
</Dropdown>
|
||||
</svelte:fragment>
|
||||
</ActionButton>
|
||||
|
||||
<ActionButton
|
||||
icon={Trash2}
|
||||
hasDropdown={true}
|
||||
dropdownPosition="right"
|
||||
on:click={canDiscard ? onDiscard : undefined}
|
||||
>
|
||||
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
|
||||
<Dropdown position={dropdownPosition} {open} minWidth="10rem">
|
||||
<div class="px-3 py-2 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{#if canDiscard}
|
||||
Discard {selectedCount} change{selectedCount === 1 ? '' : 's'}
|
||||
{:else}
|
||||
Select changes to discard
|
||||
{/if}
|
||||
</div>
|
||||
</Dropdown>
|
||||
</svelte:fragment>
|
||||
</ActionButton>
|
||||
</ActionsBar>
|
||||
169
src/routes/databases/[id]/changes/components/StatusCard.svelte
Normal file
169
src/routes/databases/[id]/changes/components/StatusCard.svelte
Normal file
@@ -0,0 +1,169 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
GitBranch,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
ExternalLink,
|
||||
Star,
|
||||
GitFork,
|
||||
CircleDot,
|
||||
History,
|
||||
ChevronDown,
|
||||
Check
|
||||
} from 'lucide-svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { GitStatus, RepoInfo } from '$utils/git/types';
|
||||
|
||||
export let status: GitStatus;
|
||||
export let repoInfo: RepoInfo | null;
|
||||
export let branches: string[];
|
||||
export let databaseId: number;
|
||||
|
||||
let branchDropdownOpen = false;
|
||||
let switching = false;
|
||||
|
||||
async function handleBranchSwitch(branch: string) {
|
||||
if (branch === status.branch || switching) return;
|
||||
|
||||
switching = true;
|
||||
branchDropdownOpen = false;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('branch', branch);
|
||||
|
||||
try {
|
||||
const response = await fetch('?/checkout', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await invalidateAll();
|
||||
}
|
||||
} finally {
|
||||
switching = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest('.branch-dropdown')) {
|
||||
branchDropdownOpen = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={handleClickOutside} />
|
||||
|
||||
<div
|
||||
class="mt-6 rounded-lg border border-neutral-200 bg-white p-4 dark:border-neutral-700 dark:bg-neutral-800"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Left: Repo info -->
|
||||
<div class="flex items-center gap-4">
|
||||
{#if repoInfo}
|
||||
<img
|
||||
src={repoInfo.ownerAvatarUrl}
|
||||
alt={repoInfo.owner}
|
||||
class="h-8 w-8 rounded-lg"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="font-mono text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{repoInfo.owner}/{repoInfo.repo}
|
||||
</code>
|
||||
<a
|
||||
href={repoInfo.htmlUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-neutral-400 transition-colors hover:text-neutral-600 dark:hover:text-neutral-300"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<span class="flex items-center gap-1">
|
||||
<Star size={12} />
|
||||
<code class="font-mono">{repoInfo.stars.toLocaleString()}</code>
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<GitFork size={12} />
|
||||
<code class="font-mono">{repoInfo.forks.toLocaleString()}</code>
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<CircleDot size={12} />
|
||||
<code class="font-mono">{repoInfo.openIssues.toLocaleString()}</code>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right: Branch, status, commits -->
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Ahead/Behind indicators -->
|
||||
{#if status.ahead > 0}
|
||||
<div class="flex items-center gap-1 text-sm text-yellow-600 dark:text-yellow-400">
|
||||
<ArrowUp size={14} />
|
||||
<code class="font-mono">{status.ahead}</code>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if status.behind > 0}
|
||||
<div class="flex items-center gap-1 text-sm text-orange-600 dark:text-orange-400">
|
||||
<ArrowDown size={14} />
|
||||
<code class="font-mono">{status.behind}</code>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Branch dropdown -->
|
||||
<div class="branch-dropdown relative">
|
||||
<button
|
||||
type="button"
|
||||
on:click|stopPropagation={() => (branchDropdownOpen = !branchDropdownOpen)}
|
||||
disabled={switching}
|
||||
class="flex items-center gap-2 rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm transition-colors hover:bg-neutral-100 disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<GitBranch size={14} class="text-neutral-500 dark:text-neutral-400" />
|
||||
<code class="font-mono text-neutral-700 dark:text-neutral-300">{status.branch}</code>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
class="text-neutral-400 transition-transform {branchDropdownOpen
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{#if branchDropdownOpen}
|
||||
<div
|
||||
class="absolute right-0 top-full z-50 mt-1 max-h-60 w-48 overflow-auto rounded-lg border border-neutral-200 bg-white py-1 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
|
||||
>
|
||||
{#each branches as branch}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => handleBranchSwitch(branch)}
|
||||
class="flex w-full items-center justify-between px-3 py-1.5 text-left text-sm transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<code class="font-mono {branch === status.branch
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-neutral-700 dark:text-neutral-300'}">{branch}</code>
|
||||
{#if branch === status.branch}
|
||||
<Check size={14} class="text-blue-600 dark:text-blue-400" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Commits button -->
|
||||
<a
|
||||
href="/databases/{databaseId}/changes/commits"
|
||||
class="flex items-center gap-2 rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm transition-colors hover:bg-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<History size={14} class="text-neutral-500 dark:text-neutral-400" />
|
||||
<code class="font-mono text-neutral-700 dark:text-neutral-300">Commits</code>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
5
src/routes/databases/[id]/edit/+layout@.svelte
Normal file
5
src/routes/databases/[id]/edit/+layout@.svelte
Normal file
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
// Reset to root layout - edit page doesn't need the tabs
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
7
src/routes/databases/[id]/sync/+page.svelte
Normal file
7
src/routes/databases/[id]/sync/+page.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
// Sync page - configure how/when to sync to arr instances
|
||||
</script>
|
||||
|
||||
<div class="text-neutral-500 dark:text-neutral-400">
|
||||
Sync configuration coming soon
|
||||
</div>
|
||||
@@ -5,6 +5,7 @@ import { canWriteToBase } from '$pcd/writer.ts';
|
||||
import * as delayProfileQueries from '$pcd/queries/delayProfiles/index.ts';
|
||||
import type { OperationLayer } from '$pcd/writer.ts';
|
||||
import type { PreferredProtocol } from '$pcd/queries/delayProfiles/index.ts';
|
||||
import { logger } from '$logger/logger.ts';
|
||||
|
||||
export const load: ServerLoad = async ({ params }) => {
|
||||
const { databaseId, id } = params;
|
||||
@@ -127,7 +128,7 @@ export const actions: Actions = {
|
||||
throw redirect(303, `/delay-profiles/${databaseId}`);
|
||||
},
|
||||
|
||||
delete: async ({ params }) => {
|
||||
delete: async ({ request, params }) => {
|
||||
const { databaseId, id } = params;
|
||||
|
||||
if (!databaseId || !id) {
|
||||
@@ -152,11 +153,29 @@ export const actions: Actions = {
|
||||
return fail(404, { error: 'Delay profile not found' });
|
||||
}
|
||||
|
||||
// Delete always goes to user layer (can't remove base ops)
|
||||
const formData = await request.formData();
|
||||
const layerFromForm = formData.get('layer');
|
||||
const layer = (layerFromForm as OperationLayer) || 'user';
|
||||
|
||||
await logger.debug('Delete action received', {
|
||||
source: 'DelayProfileDelete',
|
||||
meta: {
|
||||
profileId,
|
||||
profileName: current.name,
|
||||
layerFromForm,
|
||||
layerUsed: layer
|
||||
}
|
||||
});
|
||||
|
||||
// Check layer permission
|
||||
if (layer === 'base' && !canWriteToBase(currentDatabaseId)) {
|
||||
return fail(403, { error: 'Cannot write to base layer without personal access token' });
|
||||
}
|
||||
|
||||
const result = await delayProfileQueries.remove({
|
||||
databaseId: currentDatabaseId,
|
||||
cache,
|
||||
layer: 'user',
|
||||
layer,
|
||||
current
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { tick } from 'svelte';
|
||||
import NumberInput from '$ui/form/NumberInput.svelte';
|
||||
import TagInput from '$ui/form/TagInput.svelte';
|
||||
import Modal from '$ui/modal/Modal.svelte';
|
||||
import SaveTargetModal from '$ui/modal/SaveTargetModal.svelte';
|
||||
import { alertStore } from '$alerts/store';
|
||||
import { Check, Save, Trash2, Loader2 } from 'lucide-svelte';
|
||||
@@ -36,10 +36,13 @@
|
||||
|
||||
// Modal states
|
||||
let showSaveTargetModal = false;
|
||||
let showDeleteModal = false;
|
||||
let showDeleteTargetModal = false;
|
||||
let mainFormElement: HTMLFormElement;
|
||||
let deleteFormElement: HTMLFormElement;
|
||||
|
||||
// Delete layer selection
|
||||
let deleteLayer: 'user' | 'base' = 'user';
|
||||
|
||||
// Display text based on mode
|
||||
$: title = mode === 'create' ? 'New Delay Profile' : 'Edit Delay Profile';
|
||||
$: description =
|
||||
@@ -61,22 +64,41 @@
|
||||
|
||||
$: isValid = name.trim() !== '' && tags.length > 0;
|
||||
|
||||
function handleSaveClick() {
|
||||
async function handleSaveClick() {
|
||||
if (canWriteToBase) {
|
||||
// Show modal to ask where to save
|
||||
showSaveTargetModal = true;
|
||||
} else {
|
||||
// No choice, just submit with 'user' layer
|
||||
selectedLayer = 'user';
|
||||
await tick();
|
||||
mainFormElement?.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
function handleLayerSelect(event: CustomEvent<'user' | 'base'>) {
|
||||
async function handleLayerSelect(event: CustomEvent<'user' | 'base'>) {
|
||||
selectedLayer = event.detail;
|
||||
showSaveTargetModal = false;
|
||||
await tick();
|
||||
mainFormElement?.requestSubmit();
|
||||
}
|
||||
|
||||
async function handleDeleteClick() {
|
||||
if (canWriteToBase) {
|
||||
showDeleteTargetModal = true;
|
||||
} else {
|
||||
deleteLayer = 'user';
|
||||
await tick();
|
||||
deleteFormElement?.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteLayerSelect(event: CustomEvent<'user' | 'base'>) {
|
||||
deleteLayer = event.detail;
|
||||
showDeleteTargetModal = false;
|
||||
await tick();
|
||||
deleteFormElement?.requestSubmit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-8 p-8">
|
||||
@@ -292,7 +314,7 @@
|
||||
{#if mode === 'edit'}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (showDeleteModal = true)}
|
||||
on:click={handleDeleteClick}
|
||||
class="flex items-center gap-2 rounded-lg border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 transition-colors hover:bg-red-50 dark:border-red-700 dark:bg-neutral-900 dark:text-red-300 dark:hover:bg-red-900"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
@@ -348,32 +370,25 @@
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="layer" value={deleteLayer} />
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
{#if mode === 'edit'}
|
||||
<Modal
|
||||
open={showDeleteModal}
|
||||
header="Delete Delay Profile"
|
||||
bodyMessage={`Are you sure you want to delete "${name}"? This action cannot be undone.`}
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
confirmDanger={true}
|
||||
on:confirm={() => {
|
||||
showDeleteModal = false;
|
||||
deleteFormElement?.requestSubmit();
|
||||
}}
|
||||
on:cancel={() => (showDeleteModal = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Save Target Modal -->
|
||||
{#if canWriteToBase}
|
||||
<SaveTargetModal
|
||||
open={showSaveTargetModal}
|
||||
mode="save"
|
||||
on:select={handleLayerSelect}
|
||||
on:cancel={() => (showSaveTargetModal = false)}
|
||||
/>
|
||||
|
||||
<!-- Delete Target Modal -->
|
||||
<SaveTargetModal
|
||||
open={showDeleteTargetModal}
|
||||
mode="delete"
|
||||
on:select={handleDeleteLayerSelect}
|
||||
on:cancel={() => (showDeleteTargetModal = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { canWriteToBase } from '$pcd/writer.ts';
|
||||
import * as delayProfileQueries from '$pcd/queries/delayProfiles/index.ts';
|
||||
import type { OperationLayer } from '$pcd/writer.ts';
|
||||
import type { PreferredProtocol } from '$pcd/queries/delayProfiles/index.ts';
|
||||
import { logger } from '$logger/logger.ts';
|
||||
|
||||
export const load: ServerLoad = ({ params }) => {
|
||||
const { databaseId } = params;
|
||||
@@ -58,7 +59,17 @@ export const actions: Actions = {
|
||||
const bypassIfHighestQuality = formData.get('bypassIfHighestQuality') === 'true';
|
||||
const bypassIfAboveCfScore = formData.get('bypassIfAboveCfScore') === 'true';
|
||||
const minimumCfScore = parseInt(formData.get('minimumCfScore') as string, 10) || 0;
|
||||
const layer = (formData.get('layer') as OperationLayer) || 'user';
|
||||
const layerFromForm = formData.get('layer');
|
||||
const layer = (layerFromForm as OperationLayer) || 'user';
|
||||
|
||||
await logger.debug('Create action received', {
|
||||
source: 'DelayProfileCreate',
|
||||
meta: {
|
||||
profileName: name,
|
||||
layerFromForm,
|
||||
layerUsed: layer
|
||||
}
|
||||
});
|
||||
|
||||
// Validate
|
||||
if (!name?.trim()) {
|
||||
|
||||
Reference in New Issue
Block a user