mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-04-17 12:27:06 -04:00
feat: enable onboarding flag, add cutscene stages for databases and arr instances (#407)
This commit is contained in:
@@ -37,6 +37,7 @@
|
||||
"dev:parser": "cd src/services/parser && dotnet watch run --urls http://localhost:5000",
|
||||
"build": "APP_BASE_PATH=./dist/build deno run -A npm:vite build && deno compile --no-check --allow-net --allow-read --allow-write --allow-env --allow-ffi --allow-run --allow-sys --target x86_64-unknown-linux-gnu --output dist/build/profilarr dist/build/mod.ts",
|
||||
"preview": "PORT=6869 HOST=0.0.0.0 APP_BASE_PATH=./dist/dev PARSER_HOST=localhost PARSER_PORT=5000 ./dist/build/profilarr",
|
||||
"reset:onboarding": "sqlite3 dist/dev/data/profilarr.db \"UPDATE general_settings SET onboarding_shown = 0\"",
|
||||
"clean:dev": "rm -rf dist/dev",
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check . && deno lint",
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
- [Overview](#overview)
|
||||
- [Steps](#steps)
|
||||
- [Completion Types](#completion-types)
|
||||
- [Dynamic Routes](#dynamic-routes)
|
||||
- [Stages](#stages)
|
||||
- [Pipelines](#pipelines)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Groups](#groups)
|
||||
- [Adding Content](#adding-content)
|
||||
- [Overlay Engine](#overlay-engine)
|
||||
- [Store & Persistence](#store--persistence)
|
||||
@@ -31,10 +33,11 @@ an instruction card. The user completes the step by satisfying a condition, and
|
||||
the overlay advances. The spotlight animates smoothly between targets, and
|
||||
navigation between pages is handled automatically.
|
||||
|
||||
New users see a prompt on first load asking if they want a guided tour. This is
|
||||
driven by a database flag (`onboarding_shown` on `general_settings`) that's set
|
||||
whether the user accepts or dismisses. Users can replay any stage or pipeline
|
||||
later from the `/onboarding` page, accessible via the help button.
|
||||
New users see a prompt on first load asking if they want a guided tour. This
|
||||
starts the Welcome stage, which introduces Profilarr and ends at the onboarding
|
||||
page where users can run any stage at their own pace. The prompt is driven by a
|
||||
database flag (`onboarding_shown` on `general_settings`) that's set whether the
|
||||
user accepts or dismisses.
|
||||
|
||||
## Steps
|
||||
|
||||
@@ -44,7 +47,7 @@ the user to do something. Steps are the building blocks of everything else.
|
||||
```ts
|
||||
interface Step {
|
||||
id: string;
|
||||
route?: string;
|
||||
route?: string | { resolve: string };
|
||||
target?: string;
|
||||
title: string;
|
||||
body: string;
|
||||
@@ -57,8 +60,8 @@ interface Step {
|
||||
`target` matches a `data-onboarding` attribute in the DOM. If omitted, the
|
||||
instruction card appears centered with no spotlight.
|
||||
|
||||
`route` auto-navigates when the step becomes active. If the user is already on
|
||||
that route, nothing happens.
|
||||
`route` auto-navigates when the step becomes active. Can be a static string or
|
||||
a dynamic resolver (see [Dynamic Routes](#dynamic-routes)).
|
||||
|
||||
`position` controls where the instruction card sits relative to the target:
|
||||
`above`, `below`, `left`, `right`, `above-left`, `above-right`, `below-left`,
|
||||
@@ -79,6 +82,34 @@ interacting beyond the spotlight target.
|
||||
| `state` | Advances when a named async check function returns true. Functions are registered in `stateChecks.ts`. |
|
||||
| `manual` | Shows a Continue button on the instruction card. |
|
||||
|
||||
When the completion type is not `manual`, the forward button is hidden from the
|
||||
progress bar. The user must satisfy the completion condition to advance.
|
||||
|
||||
### Dynamic Routes
|
||||
|
||||
Steps can use dynamic route resolvers for pages that require runtime data (e.g.
|
||||
navigating to a specific database or Arr instance). Instead of a static route
|
||||
string, use an object referencing a named resolver:
|
||||
|
||||
```ts
|
||||
route: {
|
||||
resolve: 'firstDatabaseChanges';
|
||||
}
|
||||
```
|
||||
|
||||
Resolvers are registered in `src/lib/client/cutscene/routeResolvers.ts`. Each
|
||||
resolver is an async function that returns a path string:
|
||||
|
||||
```ts
|
||||
export const routeResolvers: Record<string, () => Promise<string>> = {
|
||||
firstDatabaseChanges: async () => {
|
||||
const res = await fetch('/api/v1/databases');
|
||||
const dbs = await res.json();
|
||||
return `/databases/${dbs[0].id}/changes`;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Stages
|
||||
|
||||
A stage groups steps into a self-contained lesson that teaches one thing. Stages
|
||||
@@ -91,42 +122,117 @@ interface Stage {
|
||||
description: string;
|
||||
steps: Step[];
|
||||
silent?: boolean;
|
||||
prerequisites?: Prerequisite[];
|
||||
}
|
||||
```
|
||||
|
||||
`silent` skips the completion modal when this stage finishes as the last stage in
|
||||
a run. Used for stages like Help where a "you're done" modal would be redundant.
|
||||
`silent` skips the completion modal when this stage finishes. Used for stages
|
||||
like Help where a "you're done" modal would be redundant.
|
||||
|
||||
`prerequisites` gates the stage behind runtime conditions. See
|
||||
[Prerequisites](#prerequisites) below.
|
||||
|
||||
### Current Stages
|
||||
|
||||
| ID | Name | Steps | Description |
|
||||
| ------------- | ----------- | ----- | ---------------------------------------------------------------------- |
|
||||
| `welcome` | Welcome | 10 | Sidebar walkthrough: what Profilarr is and what each section does |
|
||||
| `personalize` | Personalize | 2 | Theme toggle and accent color picker |
|
||||
| `databases` | Databases | 6 | Linking a database: form fields, PAT, conflict strategy, sync settings |
|
||||
| `help` | Help | 1 | Introduces the help button (silent) |
|
||||
| ID | Name | Steps | Prerequisites | Description |
|
||||
| ----------------- | ----------- | ----- | ---------------- | ----------------------------------------------- |
|
||||
| `welcome` | Welcome | 3 | | What Profilarr is and how it works |
|
||||
| `navigation` | Navigation | 5 | | The main sections of the app |
|
||||
| `personalize` | Personalize | 2 | | Theme toggle and accent color picker |
|
||||
| `help` | Help | 1 | | Introduces the help button (silent) |
|
||||
| `database-link` | Link | 7 | | Connect a configuration database |
|
||||
| `database-manage` | Overview | 6 | `hasDatabase` | Tabs and features of a connected database |
|
||||
| `arr-link` | Link | 5 | | Connect a Radarr or Sonarr instance |
|
||||
| `arr-manage` | Overview | 7 | `hasArrInstance` | Tabs and features of a connected Arr instance |
|
||||
| `arr-sync` | Sync | 7 | `hasArrInstance` | Configure what gets synced and when |
|
||||
| `arr-upgrades` | Upgrades | 11 | `hasArrInstance` | Automated searching for better quality releases |
|
||||
| `arr-renames` | Rename | 7 | `hasArrInstance` | Automated file and folder renaming |
|
||||
|
||||
## Pipelines
|
||||
## Prerequisites
|
||||
|
||||
A pipeline chains stages together in order. When one stage completes, the next
|
||||
begins automatically.
|
||||
A prerequisite gates a stage behind a runtime condition. Before a stage starts,
|
||||
its prerequisites are checked. If any check fails, the cutscene does not start
|
||||
and the user sees an error alert with the prerequisite's message.
|
||||
|
||||
```ts
|
||||
interface Pipeline {
|
||||
id: string;
|
||||
interface Prerequisite {
|
||||
check: string;
|
||||
message: string;
|
||||
}
|
||||
```
|
||||
|
||||
`check` references a named function in the `stateChecks` registry
|
||||
(`src/lib/client/cutscene/stateChecks.ts`). Each function is async and returns a
|
||||
boolean.
|
||||
|
||||
`message` is shown via `alertStore` when the check returns false.
|
||||
|
||||
### How checks run
|
||||
|
||||
The prerequisite runner (`src/lib/client/cutscene/prerequisites.ts`) collects
|
||||
prerequisites from the requested stage, runs each check in order, and returns on
|
||||
the first failure.
|
||||
|
||||
### Adding a prerequisite
|
||||
|
||||
1. Add a check function to `stateChecks.ts`:
|
||||
|
||||
```ts
|
||||
export const stateChecks: Record<string, () => Promise<boolean>> = {
|
||||
hasDatabase: async () => {
|
||||
const res = await fetch('/api/v1/databases');
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
return data.length > 0;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
2. Add the prerequisite to the stage definition:
|
||||
|
||||
```ts
|
||||
prerequisites: [{ check: 'hasDatabase', message: 'You need at least one connected database.' }];
|
||||
```
|
||||
|
||||
The same check function can be referenced by multiple stages.
|
||||
|
||||
## Groups
|
||||
|
||||
Groups organize stages visually on the onboarding page. They have no runtime
|
||||
behavior; stages within a group run independently.
|
||||
|
||||
```ts
|
||||
interface StageGroup {
|
||||
name: string;
|
||||
description: string;
|
||||
stages: string[];
|
||||
}
|
||||
```
|
||||
|
||||
The `stages` array contains stage IDs referencing entries in the stage registry.
|
||||
Groups are defined in `definitions/index.ts`:
|
||||
|
||||
### Current Pipelines
|
||||
```ts
|
||||
export const GROUPS: StageGroup[] = [
|
||||
{
|
||||
name: 'Getting Started',
|
||||
description: 'Learn the basics of Profilarr',
|
||||
stages: ['welcome', 'navigation', 'personalize', 'help']
|
||||
},
|
||||
{
|
||||
name: 'Databases',
|
||||
description: 'Connect and manage configuration databases',
|
||||
stages: ['database-link', 'database-manage']
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
| ID | Name | Stages | Description |
|
||||
| ----------------- | --------------- | ---------------------------------------- | -------------------------------------------- |
|
||||
| `getting-started` | Getting Started | Welcome → Personalize → Databases → Help | First-run onboarding covering the essentials |
|
||||
### Current Groups
|
||||
|
||||
| Name | Stages |
|
||||
| --------------- | ------------------------------------------------- |
|
||||
| Getting Started | Welcome, Navigation, Personalize, Help |
|
||||
| Databases | Connect a Database, Managing a Database |
|
||||
| Arr Instances | Connect an Arr Instance, Managing an Arr Instance |
|
||||
|
||||
## Adding Content
|
||||
|
||||
@@ -138,16 +244,10 @@ array and tag the target element with `data-onboarding`:
|
||||
```
|
||||
|
||||
If the step uses `state` completion, register the check function in
|
||||
`src/lib/client/cutscene/stateChecks.ts`:
|
||||
`src/lib/client/cutscene/stateChecks.ts`.
|
||||
|
||||
```ts
|
||||
export const stateChecks: Record<string, () => Promise<boolean>> = {
|
||||
myCheck: async () => {
|
||||
const res = await fetch('/api/v1/...');
|
||||
return res.ok;
|
||||
}
|
||||
};
|
||||
```
|
||||
If the step needs a dynamic route, register a resolver in
|
||||
`src/lib/client/cutscene/routeResolvers.ts`.
|
||||
|
||||
**Adding a stage**: create a file in `src/lib/client/cutscene/definitions/stages/`
|
||||
and register it in `definitions/index.ts`:
|
||||
@@ -183,22 +283,7 @@ export const STAGES: Record<string, Stage> = {
|
||||
};
|
||||
```
|
||||
|
||||
The stage automatically appears on the `/onboarding` page.
|
||||
|
||||
**Adding a pipeline**: create a file in `definitions/pipelines/` and register it
|
||||
in `definitions/index.ts`:
|
||||
|
||||
```ts
|
||||
// definitions/pipelines/my-pipeline.ts
|
||||
import type { Pipeline } from '../../types.ts';
|
||||
|
||||
export const myPipeline: Pipeline = {
|
||||
id: 'my-pipeline',
|
||||
name: 'My Pipeline',
|
||||
description: 'A guided experience',
|
||||
stages: ['stage-one', 'stage-two', 'stage-three']
|
||||
};
|
||||
```
|
||||
Add the stage to a group in `GROUPS` so it appears on the onboarding page.
|
||||
|
||||
## Overlay Engine
|
||||
|
||||
@@ -218,9 +303,9 @@ recalculates on scroll, resize, and navigation.
|
||||
|
||||
## Store & Persistence
|
||||
|
||||
Runtime state is managed by a Svelte writable store. Progress (active pipeline,
|
||||
current stage/step) is saved to localStorage on every state change, so
|
||||
refreshing mid-walkthrough picks up where the user left off.
|
||||
Runtime state is managed by a Svelte writable store. Progress (active stage and
|
||||
current step) is saved to localStorage on every state change, so refreshing
|
||||
mid-walkthrough picks up where the user left off.
|
||||
|
||||
The only server-side persistence is the `onboarding_shown` flag on
|
||||
`general_settings` (migration 058). This controls whether the first-run prompt
|
||||
@@ -233,9 +318,8 @@ this.
|
||||
|
||||
## Onboarding Page
|
||||
|
||||
`/onboarding` lists all registered pipelines and stages. Users can start a full
|
||||
pipeline or replay an individual stage. The page supports table/card view toggle
|
||||
and search by name or description.
|
||||
`/onboarding` shows stages organized by group. Each group displays its name,
|
||||
description, and the stages within it. Users can start any individual stage.
|
||||
|
||||
The help button (parrot) includes an "Onboarding" link to this page on desktop.
|
||||
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="pointer-events-none fixed inset-0 z-50 {isAuthPage
|
||||
class="pointer-events-none fixed inset-0 z-[100] {isAuthPage
|
||||
? ''
|
||||
: 'hidden pt-16 pb-16 md:block md:pt-0 md:pb-0 md:pl-80'}"
|
||||
: 'pt-16 pb-16 md:pt-0 md:pb-0 md:pl-80'}"
|
||||
>
|
||||
<div class="relative h-full w-full">
|
||||
<div
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { ArrowRight, X } from 'lucide-svelte';
|
||||
import { X } from 'lucide-svelte';
|
||||
import Button from '$ui/button/Button.svelte';
|
||||
import CutsceneProgress from './CutsceneProgress.svelte';
|
||||
|
||||
export let title: string;
|
||||
export let body: string;
|
||||
export let showContinue: boolean = false;
|
||||
export let onContinue: (() => void) | undefined = undefined;
|
||||
export let onBack: (() => void) | undefined = undefined;
|
||||
export let onForward: (() => void) | undefined = undefined;
|
||||
export let onCancel: (() => void) | undefined = undefined;
|
||||
export let showBack: boolean = true;
|
||||
export let currentStep: number = 0;
|
||||
export let totalSteps: number = 0;
|
||||
|
||||
@@ -19,8 +21,6 @@
|
||||
];
|
||||
|
||||
$: cancelTooltip = cancelQuips[Math.floor(Math.random() * cancelQuips.length)];
|
||||
$: showProgress = totalSteps > 1;
|
||||
$: progressPercent = totalSteps > 0 ? ((currentStep + 1) / totalSteps) * 100 : 0;
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -28,16 +28,9 @@
|
||||
>
|
||||
<div>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{title}
|
||||
</h3>
|
||||
{#if showProgress}
|
||||
<span class="text-xs text-neutral-400 dark:text-neutral-500">
|
||||
{currentStep + 1}/{totalSteps}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{title}
|
||||
</h3>
|
||||
{#if onCancel}
|
||||
<Button icon={X} variant="ghost" size="xs" title={cancelTooltip} on:click={onCancel} />
|
||||
{/if}
|
||||
@@ -46,24 +39,5 @@
|
||||
{body}
|
||||
</p>
|
||||
</div>
|
||||
{#if showProgress}
|
||||
<div class="h-1 overflow-hidden rounded-full bg-neutral-300 dark:bg-neutral-700">
|
||||
<div
|
||||
class="h-full rounded-full bg-accent-500 transition-all duration-300"
|
||||
style="width: {progressPercent}%"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if showContinue && onContinue}
|
||||
<div>
|
||||
<Button
|
||||
text="Continue"
|
||||
icon={ArrowRight}
|
||||
iconPosition="right"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
on:click={onContinue}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<CutsceneProgress {currentStep} {totalSteps} {onBack} {onForward} {showBack} />
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
import { cutscene } from './store';
|
||||
import { setupCompletion, teardownCompletion } from './completions.ts';
|
||||
import { STAGES, PIPELINES } from './definitions/index.ts';
|
||||
import { STAGES } from './definitions/index.ts';
|
||||
import { routeResolvers } from './routeResolvers.ts';
|
||||
import CutsceneCard from './CutsceneCard.svelte';
|
||||
|
||||
let targetRect: DOMRect | null = null;
|
||||
@@ -17,27 +18,10 @@
|
||||
$: step = $currentStep;
|
||||
$: active = state.active;
|
||||
|
||||
// Compute overall progress across pipeline or single stage
|
||||
$: isFirstStep = state.stepIndex === 0;
|
||||
|
||||
$: progressInfo = (() => {
|
||||
if (!state.active || !state.stageId) return { current: 0, total: 0 };
|
||||
|
||||
if (state.pipelineId) {
|
||||
const pipeline = PIPELINES[state.pipelineId];
|
||||
if (!pipeline) return { current: 0, total: 0 };
|
||||
|
||||
let total = 0;
|
||||
let current = 0;
|
||||
for (const sid of pipeline.stages) {
|
||||
const s = STAGES[sid];
|
||||
if (!s) continue;
|
||||
if (sid === state.stageId) {
|
||||
current = total + state.stepIndex;
|
||||
}
|
||||
total += s.steps.length;
|
||||
}
|
||||
return { current, total };
|
||||
}
|
||||
|
||||
const stage = STAGES[state.stageId];
|
||||
if (!stage) return { current: 0, total: 0 };
|
||||
return { current: state.stepIndex, total: stage.steps.length };
|
||||
@@ -77,6 +61,11 @@
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
function isInViewport(el: Element): boolean {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.top >= 0 && rect.bottom <= window.innerHeight;
|
||||
}
|
||||
|
||||
function findTarget(): void {
|
||||
if (!step?.target) {
|
||||
targetRect = null;
|
||||
@@ -84,6 +73,12 @@
|
||||
}
|
||||
const el = document.querySelector(`[data-onboarding="${step.target}"]`);
|
||||
if (el) {
|
||||
if (!isInViewport(el)) {
|
||||
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
// Re-measure after scroll settles
|
||||
setTimeout(() => findTarget(), 400);
|
||||
return;
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
const newRect = {
|
||||
x: rect.left - PAD,
|
||||
@@ -115,14 +110,19 @@
|
||||
let lastStepId: string | null = null;
|
||||
let cardReady = false;
|
||||
|
||||
async function resolveRoute(route: string | { resolve: string }): Promise<string> {
|
||||
if (typeof route === 'string') return route;
|
||||
const resolver = routeResolvers[route.resolve];
|
||||
if (!resolver) return '/';
|
||||
return resolver();
|
||||
}
|
||||
|
||||
$: if (step && step.id !== lastStepId && typeof window !== 'undefined') {
|
||||
lastStepId = step.id;
|
||||
cardReady = false;
|
||||
teardownCompletion();
|
||||
setupCompletion(step, () => cutscene.advance());
|
||||
|
||||
// Navigate if step requires a specific route
|
||||
const needsNav = step.route && window.location.pathname !== step.route;
|
||||
const afterNav = () => {
|
||||
tick().then(() => {
|
||||
requestAnimationFrame(() => {
|
||||
@@ -137,8 +137,15 @@
|
||||
});
|
||||
};
|
||||
|
||||
if (needsNav) {
|
||||
goto(step.route!).then(afterNav);
|
||||
// Navigate if step requires a specific route
|
||||
if (step.route) {
|
||||
resolveRoute(step.route).then((resolved) => {
|
||||
if (window.location.pathname !== resolved) {
|
||||
goto(resolved).then(afterNav);
|
||||
} else {
|
||||
afterNav();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
afterNav();
|
||||
}
|
||||
@@ -224,10 +231,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleContinue(): void {
|
||||
function handleForward(): void {
|
||||
cutscene.advance();
|
||||
}
|
||||
|
||||
function handleBack(): void {
|
||||
cutscene.goBack();
|
||||
}
|
||||
|
||||
function handleCancel(): void {
|
||||
teardownCompletion();
|
||||
cutscene.cancel();
|
||||
@@ -239,10 +250,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent user scroll while cutscene is active (but allow programmatic scrollIntoView)
|
||||
function preventScroll(e: Event): void {
|
||||
e.preventDefault();
|
||||
}
|
||||
$: if (typeof window !== 'undefined') {
|
||||
if (active) {
|
||||
window.addEventListener('wheel', preventScroll, { passive: false });
|
||||
window.addEventListener('touchmove', preventScroll, { passive: false });
|
||||
} else {
|
||||
window.removeEventListener('wheel', preventScroll);
|
||||
window.removeEventListener('touchmove', preventScroll);
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
teardownCompletion();
|
||||
observer?.disconnect();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('wheel', preventScroll);
|
||||
window.removeEventListener('touchmove', preventScroll);
|
||||
window.removeEventListener('scroll', onScroll, true);
|
||||
window.removeEventListener('resize', updateDimensions);
|
||||
}
|
||||
@@ -304,9 +331,10 @@
|
||||
<CutsceneCard
|
||||
title={step.title}
|
||||
body={step.body}
|
||||
showContinue={step.completion.type === 'manual'}
|
||||
onContinue={handleContinue}
|
||||
onBack={handleBack}
|
||||
onForward={step.completion.type === 'manual' ? handleForward : undefined}
|
||||
onCancel={handleCancel}
|
||||
showBack={!isFirstStep}
|
||||
currentStep={progressInfo.current}
|
||||
totalSteps={progressInfo.total}
|
||||
/>
|
||||
|
||||
52
src/lib/client/cutscene/CutsceneProgress.svelte
Normal file
52
src/lib/client/cutscene/CutsceneProgress.svelte
Normal file
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { ChevronLeft, ChevronRight, Check } from 'lucide-svelte';
|
||||
|
||||
export let currentStep: number = 0;
|
||||
export let totalSteps: number = 0;
|
||||
export let onBack: (() => void) | undefined = undefined;
|
||||
export let onForward: (() => void) | undefined = undefined;
|
||||
export let showBack: boolean = true;
|
||||
|
||||
$: progressPercent = totalSteps > 0 ? ((currentStep + 1) / totalSteps) * 100 : 0;
|
||||
$: isLastStep = currentStep + 1 === totalSteps;
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="text-right text-xs text-neutral-400 dark:text-neutral-500">
|
||||
{currentStep + 1}/{totalSteps}
|
||||
</div>
|
||||
<div class="flex h-8 items-stretch">
|
||||
{#if showBack}
|
||||
<button
|
||||
class="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-l-lg border border-neutral-300 bg-white text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-700 dark:border-neutral-700/60 dark:bg-neutral-800/50 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-200"
|
||||
on:click={onBack}
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
<div
|
||||
class="relative flex min-w-[120px] flex-1 items-center overflow-hidden border-y border-neutral-300 bg-neutral-200/50 dark:border-neutral-700/60 dark:bg-neutral-800/30"
|
||||
class:border-l={!showBack}
|
||||
class:rounded-l-lg={!showBack}
|
||||
class:border-r={!onForward}
|
||||
class:rounded-r-lg={!onForward}
|
||||
>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 bg-accent-500 transition-all duration-300"
|
||||
style="width: {progressPercent}%"
|
||||
></div>
|
||||
</div>
|
||||
{#if onForward}
|
||||
<button
|
||||
class="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-r-lg border border-l-0 border-neutral-300 bg-white text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-700 dark:border-neutral-700/60 dark:bg-neutral-800/50 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-200"
|
||||
on:click={onForward}
|
||||
>
|
||||
{#if isLastStep}
|
||||
<Check size={14} class="text-green-500" />
|
||||
{:else}
|
||||
<ChevronRight size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
function start(): void {
|
||||
cutscene.dismiss();
|
||||
cutscene.startPipeline('getting-started', false);
|
||||
cutscene.startStage('welcome', false);
|
||||
}
|
||||
|
||||
function skip(): void {
|
||||
|
||||
@@ -1,17 +1,44 @@
|
||||
import type { Stage, Pipeline } from '../types.ts';
|
||||
import { welcomeStage } from './stages/welcome.ts';
|
||||
import { personalizeStage } from './stages/personalize.ts';
|
||||
import { databasesStage } from './stages/databases.ts';
|
||||
import { helpStage } from './stages/help.ts';
|
||||
import { gettingStartedPipeline } from './pipelines/getting-started.ts';
|
||||
import type { Stage, StageGroup } from '../types.ts';
|
||||
import { welcomeStage } from './stages/getting-started/welcome.ts';
|
||||
import { navigationStage } from './stages/getting-started/navigation.ts';
|
||||
import { personalizeStage } from './stages/getting-started/personalize.ts';
|
||||
import { helpStage } from './stages/getting-started/help.ts';
|
||||
import { databaseLinkStage } from './stages/databases/link.ts';
|
||||
import { databaseOverviewStage } from './stages/databases/overview.ts';
|
||||
import { arrLinkStage } from './stages/arrs/link.ts';
|
||||
import { arrOverviewStage } from './stages/arrs/overview.ts';
|
||||
import { arrSyncStage } from './stages/arrs/sync.ts';
|
||||
import { arrUpgradesStage } from './stages/arrs/upgrades.ts';
|
||||
import { arrRenameStage } from './stages/arrs/renames.ts';
|
||||
|
||||
export const STAGES: Record<string, Stage> = {
|
||||
welcome: welcomeStage,
|
||||
navigation: navigationStage,
|
||||
personalize: personalizeStage,
|
||||
databases: databasesStage,
|
||||
'database-link': databaseLinkStage,
|
||||
'database-manage': databaseOverviewStage,
|
||||
'arr-link': arrLinkStage,
|
||||
'arr-manage': arrOverviewStage,
|
||||
'arr-sync': arrSyncStage,
|
||||
'arr-upgrades': arrUpgradesStage,
|
||||
'arr-renames': arrRenameStage,
|
||||
help: helpStage
|
||||
};
|
||||
|
||||
export const PIPELINES: Record<string, Pipeline> = {
|
||||
'getting-started': gettingStartedPipeline
|
||||
};
|
||||
export const GROUPS: StageGroup[] = [
|
||||
{
|
||||
name: 'Getting Started',
|
||||
description: 'Learn the basics of Profilarr',
|
||||
stages: ['welcome', 'navigation', 'personalize', 'help']
|
||||
},
|
||||
{
|
||||
name: 'Databases',
|
||||
description: 'Connect and manage configuration databases',
|
||||
stages: ['database-link', 'database-manage']
|
||||
},
|
||||
{
|
||||
name: 'Arr Instances',
|
||||
description: 'Connect and manage Radarr/Sonarr instances',
|
||||
stages: ['arr-link', 'arr-manage', 'arr-sync', 'arr-upgrades', 'arr-renames']
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { Pipeline } from '../../types.ts';
|
||||
|
||||
export const gettingStartedPipeline: Pipeline = {
|
||||
id: 'getting-started',
|
||||
name: 'Getting Started',
|
||||
description: 'Learn the basics of Profilarr',
|
||||
stages: ['welcome', 'personalize', 'databases', 'help']
|
||||
};
|
||||
51
src/lib/client/cutscene/definitions/stages/arrs/link.ts
Normal file
51
src/lib/client/cutscene/definitions/stages/arrs/link.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Stage } from '$cutscene/types.ts';
|
||||
|
||||
export const arrLinkStage: Stage = {
|
||||
id: 'arr-link',
|
||||
name: 'Link',
|
||||
description: 'Connect a Radarr or Sonarr instance',
|
||||
steps: [
|
||||
{
|
||||
id: 'arr-explain',
|
||||
target: 'nav-arrs',
|
||||
title: 'Arrs',
|
||||
body: "Now let's connect an Arr instance. Arrs are your Radarr and Sonarr instances that Profilarr manages. Once connected, you can push configurations directly to them. Click Arrs to continue.",
|
||||
position: 'right',
|
||||
completion: { type: 'click' }
|
||||
},
|
||||
{
|
||||
id: 'arr-add',
|
||||
route: '/arr',
|
||||
target: 'arr-add',
|
||||
title: 'Add an Instance',
|
||||
body: 'Click here to start connecting a new Arr instance.',
|
||||
position: 'below-left',
|
||||
completion: { type: 'click' }
|
||||
},
|
||||
{
|
||||
id: 'arr-type',
|
||||
route: '/arr/new',
|
||||
target: 'arr-type',
|
||||
title: 'Instance Type',
|
||||
body: 'First, select whether this is a Radarr (movies) or Sonarr (TV shows) instance.',
|
||||
position: 'below',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'arr-connection',
|
||||
target: 'arr-connection',
|
||||
title: 'Connection Details',
|
||||
body: "Give your instance a name, enter its URL, and paste the API key. You can find the API key in your Arr's Settings > General page. If you're running both Profilarr and your Arr in Docker, use the container name as the host (e.g. http://radarr:7878).",
|
||||
position: 'below',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'arr-save',
|
||||
target: 'arr-save',
|
||||
title: 'Save',
|
||||
body: "Once you've filled in the connection details, hit Save to connect the instance. Profilarr will verify the connection and add it to your setup.",
|
||||
position: 'below-left',
|
||||
completion: { type: 'manual' }
|
||||
}
|
||||
]
|
||||
};
|
||||
76
src/lib/client/cutscene/definitions/stages/arrs/overview.ts
Normal file
76
src/lib/client/cutscene/definitions/stages/arrs/overview.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { Stage } from '$cutscene/types.ts';
|
||||
|
||||
export const arrOverviewStage: Stage = {
|
||||
id: 'arr-manage',
|
||||
name: 'Overview',
|
||||
description: 'Tabs and features of a connected Arr instance',
|
||||
prerequisites: [
|
||||
{
|
||||
check: 'hasArrInstance',
|
||||
message:
|
||||
'You need at least one connected Arr instance to start this stage. Follow the "Link" stage under Arr Instances from the onboarding page to connect one.'
|
||||
}
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
id: 'arr-nav-sync',
|
||||
route: { resolve: 'firstArrSync' },
|
||||
target: 'arr-tab-sync',
|
||||
title: 'Sync',
|
||||
body: "The Sync tab is where you configure what gets pushed to this instance. You'll set up media management, delay profiles, and quality profiles here. We'll walk through this in a dedicated stage later.",
|
||||
position: 'below',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'arr-nav-library',
|
||||
route: { resolve: 'firstArrLibrary' },
|
||||
target: 'arr-tab-library',
|
||||
title: 'Library',
|
||||
body: "The Library tab shows everything on your Arr instance: movies for Radarr, series for Sonarr. You can browse items, see their quality profiles, custom format scores, and whether they've hit their quality cutoff.",
|
||||
position: 'below',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'arr-nav-upgrades',
|
||||
route: { resolve: 'firstArrUpgrades' },
|
||||
target: 'arr-tab-upgrades',
|
||||
title: 'Upgrades',
|
||||
body: "The Upgrades tab lets you configure automated searches for better quality releases. Set up filters to target specific items and schedules to run searches automatically. We'll cover this in a dedicated stage later.",
|
||||
position: 'below',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'arr-nav-renames',
|
||||
route: { resolve: 'firstArrRename' },
|
||||
target: 'arr-tab-renames',
|
||||
title: 'Renames',
|
||||
body: "The Renames tab handles bulk file and folder renaming to match your naming conventions. You can preview changes with a dry run before applying them. We'll cover this in a dedicated stage later.",
|
||||
position: 'below',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'arr-nav-logs',
|
||||
route: { resolve: 'firstArrLogs' },
|
||||
target: 'arr-tab-logs',
|
||||
title: 'Logs',
|
||||
body: 'The Logs tab shows activity from this Arr instance. You can filter by log level and search for specific entries.',
|
||||
position: 'below',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'arr-nav-settings',
|
||||
route: { resolve: 'firstArrSettings' },
|
||||
target: 'arr-tab-settings',
|
||||
title: 'Settings',
|
||||
body: 'The Settings tab lets you update connection details, configure library refresh intervals, set up automatic cleanup of stale configurations, and remove the instance if needed.',
|
||||
position: 'below',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'arr-summary',
|
||||
title: "You're connected",
|
||||
body: "You've learned how to connect an Arr instance and explored the tabs where you'll manage your setup. The next steps are configuring sync to push configurations to your instance, setting up automated upgrades to search for better releases, and configuring renames to keep your files organized. Each of these has a dedicated walkthrough you can run from the onboarding page.",
|
||||
completion: { type: 'manual' }
|
||||
}
|
||||
]
|
||||
};
|
||||
67
src/lib/client/cutscene/definitions/stages/arrs/renames.ts
Normal file
67
src/lib/client/cutscene/definitions/stages/arrs/renames.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { Stage } from '$cutscene/types.ts';
|
||||
|
||||
export const arrRenameStage: Stage = {
|
||||
id: 'arr-renames',
|
||||
name: 'Rename',
|
||||
description: 'Automated file and folder renaming',
|
||||
prerequisites: [
|
||||
{
|
||||
check: 'hasArrInstance',
|
||||
message:
|
||||
'You need at least one connected Arr instance to start this stage. Follow the "Link" stage under Arr Instances from the onboarding page to connect one.'
|
||||
}
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
id: 'rename-why',
|
||||
route: { resolve: 'firstArrRename' },
|
||||
title: 'Why Rename?',
|
||||
body: "Arrs name files when they're first grabbed, but file names can fall out of sync over time. TBA episodes get placeholder names that need updating once the real title is known. Switching naming conventions or syncing new naming settings from a database leaves existing files with the old format. Rename catches all of this by comparing every file against your current naming format and fixing anything that doesn't match.",
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'rename-what',
|
||||
title: 'What is Rename?',
|
||||
body: "Rename scans your library, compares each file name against your naming format, and renames anything that doesn't match. You can preview changes with a dry run before applying them.",
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'rename-folders',
|
||||
target: 'rename-folders',
|
||||
title: 'Rename Folders',
|
||||
body: 'When enabled, folders are renamed alongside files. When off, only file names are updated.',
|
||||
position: 'below',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'rename-summary',
|
||||
target: 'rename-summary',
|
||||
title: 'Summary Notifications',
|
||||
body: 'Controls how rename notifications are sent. Summary mode sends a compact notification with a total count. Detailed mode lists every renamed file individually.',
|
||||
position: 'below',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'rename-schedule',
|
||||
target: 'rename-schedule',
|
||||
title: 'Schedule',
|
||||
body: 'How often renames run automatically. The default is daily at midnight. Like upgrades, this uses a cron expression.',
|
||||
position: 'below',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'rename-ignore-tag',
|
||||
target: 'rename-ignore-tag',
|
||||
title: 'Ignore Tag',
|
||||
body: "Items tagged with this value in your Arr instance will be skipped during renames. Useful for excluding items you've manually named or don't want touched.",
|
||||
position: 'below',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'rename-summary-step',
|
||||
title: 'Ready to Rename',
|
||||
body: 'Start with a dry run from the button above to preview what would be renamed before running it live.',
|
||||
completion: { type: 'manual' }
|
||||
}
|
||||
]
|
||||
};
|
||||
70
src/lib/client/cutscene/definitions/stages/arrs/sync.ts
Normal file
70
src/lib/client/cutscene/definitions/stages/arrs/sync.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { Stage } from '$cutscene/types.ts';
|
||||
|
||||
export const arrSyncStage: Stage = {
|
||||
id: 'arr-sync',
|
||||
name: 'Sync',
|
||||
description: 'Configure what gets synced and when',
|
||||
prerequisites: [
|
||||
{
|
||||
check: 'hasArrInstance',
|
||||
message:
|
||||
'You need at least one connected Arr instance to start this stage. Follow the "Link" stage under Arr Instances from the onboarding page to connect one.'
|
||||
}
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
id: 'sync-overview',
|
||||
route: { resolve: 'firstArrSync' },
|
||||
title: 'What is Sync?',
|
||||
body: "Sync pushes configurations from your databases into your Arr instances. Profilarr stores everything in its own format, then compiles it into what Radarr or Sonarr expects. This lets Profilarr do things the Arrs can't natively, like reusing regular expressions across custom formats.",
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'sync-media-management',
|
||||
target: 'sync-media-management',
|
||||
title: 'Media Management',
|
||||
body: 'Media management covers naming conventions, quality definitions, and media settings. This needs to be configured and saved before quality profiles can be synced.',
|
||||
position: 'below',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'sync-delay-profiles',
|
||||
target: 'sync-delay-profiles',
|
||||
title: 'Delay Profiles',
|
||||
body: 'Delay profiles control protocol preferences, delays, and score gates. Like media management, these must be configured and saved before quality profiles.',
|
||||
position: 'below',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'sync-quality-profiles',
|
||||
target: 'sync-quality-profiles',
|
||||
title: 'Quality Profiles',
|
||||
body: "Quality profiles are the core of your configuration. Select which profiles to sync from your connected databases. Custom formats don't need to be selected individually; if a custom format is part of a quality profile, it syncs automatically.",
|
||||
position: 'above',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'sync-trigger',
|
||||
target: 'sync-trigger',
|
||||
title: 'Triggers',
|
||||
body: 'Each section has its own trigger that controls when sync happens. Manual means you click sync yourself. On Pull syncs automatically when new changes arrive from a database. Schedule runs on a cron expression you define.',
|
||||
position: 'below-right',
|
||||
freeInteract: true,
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'sync-how-it-works',
|
||||
target: 'sync-how-it-works',
|
||||
title: 'How it Works',
|
||||
body: 'For a deeper breakdown of sync mechanics, triggers, cron expressions, and setup order, click this button anytime. It covers everything in detail.',
|
||||
position: 'below-left',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'sync-summary',
|
||||
title: 'Ready to Sync',
|
||||
body: 'Configure from top to bottom: media management and delay profiles first, then quality profiles. Custom formats come along with their quality profiles automatically. Each section can have its own trigger, so you can mix manual and automatic syncing.',
|
||||
completion: { type: 'manual' }
|
||||
}
|
||||
]
|
||||
};
|
||||
102
src/lib/client/cutscene/definitions/stages/arrs/upgrades.ts
Normal file
102
src/lib/client/cutscene/definitions/stages/arrs/upgrades.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { Stage } from '$cutscene/types.ts';
|
||||
|
||||
export const arrUpgradesStage: Stage = {
|
||||
id: 'arr-upgrades',
|
||||
name: 'Upgrades',
|
||||
description: 'Automated searching for better quality releases',
|
||||
prerequisites: [
|
||||
{
|
||||
check: 'hasArrInstance',
|
||||
message:
|
||||
'You need at least one connected Arr instance to start this stage. Follow the "Link" stage under Arr Instances from the onboarding page to connect one.'
|
||||
}
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
id: 'upgrades-why',
|
||||
route: { resolve: 'firstArrUpgrades' },
|
||||
title: 'Why Upgrades?',
|
||||
body: 'Arrs only search for releases when you first add something, then rely on RSS feeds to find upgrades. This works most of the time, but misses edge cases: your best indexers might have been down when a better release appeared, you might switch quality profiles, or a profile update might change what counts as an upgrade. Upgrades fills that gap by actively searching your library on a schedule.',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'upgrades-filters',
|
||||
target: 'upgrades-filters',
|
||||
title: 'Filters',
|
||||
body: 'Filters define which items in your library are eligible for upgrade searching. You can create multiple filters for different purposes: one for old movies below cutoff, another for recent additions, etc.',
|
||||
position: 'below',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'upgrades-add-filter',
|
||||
target: 'upgrades-add-filter',
|
||||
title: 'Add a Filter',
|
||||
body: 'Click to create your first filter. This opens the filter editor where you can configure rules and settings.',
|
||||
position: 'below-left',
|
||||
completion: { type: 'click' }
|
||||
},
|
||||
{
|
||||
id: 'upgrades-filter-rules',
|
||||
target: 'upgrades-filter-rules',
|
||||
title: 'Filter Rules',
|
||||
body: 'Rules match items by field: title, year, rating, status, tags, and more. Combine rules with "All" (AND) or "Any" (OR) logic. Groups can be nested for complex conditions. For example: status is Released AND (rating > 7 OR year > 2020).',
|
||||
position: 'above',
|
||||
freeInteract: true,
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'upgrades-cutoff',
|
||||
target: 'upgrades-cutoff',
|
||||
title: 'Cutoff %',
|
||||
body: 'This sets the score threshold for the "cutoff met" field. If your quality profile\'s cutoff score is 90 and you set this to 80%, items scoring below 72 are considered below cutoff. Pair it with a "cutoff met is false" rule to target items that haven\'t reached your quality goals.',
|
||||
position: 'above',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'upgrades-method',
|
||||
target: 'upgrades-method',
|
||||
title: 'Method',
|
||||
body: 'The selector determines how items are prioritized from the matched set. Oldest targets items that have been waiting the longest. Lowest Score finds items furthest from your quality goals. Random spreads searches evenly.',
|
||||
position: 'above',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'upgrades-count',
|
||||
target: 'upgrades-count',
|
||||
title: 'Count',
|
||||
body: 'How many items to search per run. This is capped based on your schedule: more frequent runs means a lower max. The cap exists because indexers rate limit search requests. Profilarr calculates the safe maximum automatically.',
|
||||
position: 'above',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'upgrades-cooldown',
|
||||
target: 'upgrades-cooldown',
|
||||
title: 'Cooldown Tag',
|
||||
body: "After an item is searched, it gets tagged in your Arr instance so it won't be picked again next run. This spreads searches across your entire library over time. When every matched item has been tagged, the tags reset and the cycle starts over.",
|
||||
position: 'above',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'upgrades-schedule',
|
||||
target: 'upgrades-schedule',
|
||||
title: 'The Schedule',
|
||||
body: 'This controls how often a filter runs. Each time the schedule fires, only one filter executes. This is intentional; indexers rate limit search requests, and hitting them too aggressively can get you throttled or banned.',
|
||||
position: 'below',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'upgrades-filter-mode',
|
||||
target: 'upgrades-filter-mode',
|
||||
title: 'Filter Mode',
|
||||
body: 'The mode controls which filter runs on each tick. Round robin cycles through your enabled filters in order. Random picks one at random. Either way, only one filter fires per scheduled run.',
|
||||
position: 'below',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'upgrades-summary',
|
||||
title: 'Ready to Upgrade',
|
||||
body: 'Filters target items, selectors prioritize them, the schedule spaces everything out safely. Start with a dry run to test your setup, then enable when you are confident.',
|
||||
completion: { type: 'manual' }
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -1,32 +1,33 @@
|
||||
import type { Stage } from '../../types.ts';
|
||||
import type { Stage } from '$cutscene/types.ts';
|
||||
|
||||
export const databasesStage: Stage = {
|
||||
id: 'databases',
|
||||
name: 'Databases',
|
||||
description: 'Learn how to link a configuration database',
|
||||
export const databaseLinkStage: Stage = {
|
||||
id: 'database-link',
|
||||
name: 'Link',
|
||||
description: 'Connect a configuration database',
|
||||
steps: [
|
||||
{
|
||||
id: 'databases-explain',
|
||||
target: 'nav-databases',
|
||||
title: 'Databases',
|
||||
body: "Databases are where your configurations come from. They're git repositories containing pre-built custom formats, quality profiles, and more. Anyone can create a Profilarr-compliant database, and you can connect as many as you like. A default database has already been linked for you, but you're free to remove it and use your own. Press Continue and we'll show you how to link one.",
|
||||
position: 'right',
|
||||
completion: { type: 'manual' }
|
||||
body: "Now let's set up a database. Remember, databases are git repositories containing pre-built custom formats, quality profiles, and more. Anyone can create a Profilarr-compliant database, and you can connect as many as you like. A default database has already been connected for you, but you're free to remove it and use your own. Click Databases to continue.",
|
||||
position: 'below-right',
|
||||
completion: { type: 'click' }
|
||||
},
|
||||
{
|
||||
id: 'databases-new',
|
||||
route: '/databases/new',
|
||||
target: 'db-header',
|
||||
title: 'Link a Database',
|
||||
body: 'This is where you link a new database to Profilarr.',
|
||||
position: 'below',
|
||||
completion: { type: 'manual' }
|
||||
id: 'databases-add',
|
||||
route: '/databases',
|
||||
target: 'db-add',
|
||||
title: 'Connect a New Database',
|
||||
body: 'Click here to start connecting a new database.',
|
||||
position: 'below-left',
|
||||
completion: { type: 'click' }
|
||||
},
|
||||
{
|
||||
id: 'databases-form',
|
||||
route: '/databases/new',
|
||||
target: 'db-name-repo-branch',
|
||||
title: 'Name, Repository & Branch',
|
||||
body: 'Start by giving your database a friendly name, then paste in a GitHub repository URL. You can also specify a branch if you need one other than the default. Only GitHub is supported at the moment. If you ever need the same database on a different branch, just link it again with a different branch selected.',
|
||||
body: 'Start by giving your database a friendly name, then paste in a GitHub repository URL. You can also specify a branch if you need one other than the default. Only GitHub is supported at the moment. If you ever need the same database on a different branch, just connect it again with a different branch selected.',
|
||||
position: 'below',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
@@ -53,6 +54,14 @@ export const databasesStage: Stage = {
|
||||
body: 'This controls how often Profilarr checks the remote repository for new updates, and whether those updates should be pulled in automatically or just trigger a notification so you can review them first.',
|
||||
position: 'above',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'databases-save',
|
||||
target: 'db-save',
|
||||
title: 'Save',
|
||||
body: "Once you're happy with your settings, hit Save to connect the database. Profilarr will clone the repository and import all its configurations.",
|
||||
position: 'below-left',
|
||||
completion: { type: 'manual' }
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Stage } from '$cutscene/types.ts';
|
||||
|
||||
export const databaseOverviewStage: Stage = {
|
||||
id: 'database-manage',
|
||||
name: 'Overview',
|
||||
description: 'Tabs and features of a connected database',
|
||||
prerequisites: [
|
||||
{
|
||||
check: 'hasDatabase',
|
||||
message:
|
||||
'You need at least one connected database to start this stage. Follow the "Link" stage under Databases from the onboarding page to connect one.'
|
||||
}
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
id: 'db-nav-changes',
|
||||
route: { resolve: 'firstDatabaseChanges' },
|
||||
target: 'db-tab-changes',
|
||||
title: 'Changes',
|
||||
body: "The Changes tab is your sync dashboard. It shows the current git status of the database and any incoming upstream updates you haven't pulled yet. From here you can pull updates to stay in sync. If you're a database developer, you'll also see your outgoing draft changes with options to export and commit them back to the repository.",
|
||||
position: 'below',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'db-nav-commits',
|
||||
route: { resolve: 'firstDatabaseCommits' },
|
||||
target: 'db-tab-commits',
|
||||
title: 'Commits',
|
||||
body: 'The Commits tab shows the commit history of the database repository. You can see what changed, when, and by whom.',
|
||||
position: 'below',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'db-nav-conflicts',
|
||||
route: { resolve: 'firstDatabaseConflicts' },
|
||||
target: 'db-tab-conflicts',
|
||||
title: 'Conflicts',
|
||||
body: 'When your local tweaks clash with an upstream update, they surface here. Each conflict shows what changed on both sides. You can resolve them by aligning (dropping your change in favor of upstream) or overriding (keeping your version). The conflict strategy you set on the database controls whether this happens automatically or waits for your input.',
|
||||
position: 'below',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'db-nav-tweaks',
|
||||
route: { resolve: 'firstDatabaseTweaks' },
|
||||
target: 'db-tab-tweaks',
|
||||
title: 'Tweaks',
|
||||
body: "The Tweaks tab is where you'll be able to view and manage your local overrides. This section is still being built.",
|
||||
position: 'below',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'db-nav-settings',
|
||||
route: { resolve: 'firstDatabaseSettings' },
|
||||
target: 'db-tab-settings',
|
||||
title: 'Settings',
|
||||
body: "The Settings tab lets you configure this database's name, sync schedule, auto-pull behavior, and conflict resolution strategy. You can also disconnect the database from here.",
|
||||
position: 'below',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'db-summary',
|
||||
title: "You're set up with databases",
|
||||
body: "You've learned how to connect a database and explored the tabs where you'll manage changes, review commits, resolve conflicts, and configure settings. Your databases will stay in sync automatically if auto-pull is enabled, and any conflicts between your local tweaks and upstream updates will surface in the Conflicts tab when they happen.",
|
||||
completion: { type: 'manual' }
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Stage } from '../../types.ts';
|
||||
import type { Stage } from '$cutscene/types.ts';
|
||||
|
||||
export const helpStage: Stage = {
|
||||
id: 'help',
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Stage } from '$cutscene/types.ts';
|
||||
|
||||
export const navigationStage: Stage = {
|
||||
id: 'navigation',
|
||||
name: 'Navigation',
|
||||
description: 'The main sections of the app',
|
||||
steps: [
|
||||
{
|
||||
id: 'sidebar-overview',
|
||||
target: 'sidebar',
|
||||
title: 'Navigation',
|
||||
body: "Let's take a quick look at the main sections of the app.",
|
||||
position: 'right',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'nav-databases',
|
||||
route: '/databases',
|
||||
target: 'nav-databases',
|
||||
title: 'Databases',
|
||||
body: "Databases are where your configurations come from. Connect one and everything inside is ready to browse, customize, and deploy to your Arr instances. We'll walk through connecting one later.",
|
||||
position: 'right',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'nav-arrs',
|
||||
route: '/arr',
|
||||
target: 'nav-arrs',
|
||||
title: 'Arrs',
|
||||
body: 'Your Arrs are the Radarr and Sonarr instances that Profilarr manages. Add them here, and any configurations you set up will sync directly to each one. You can connect as many as you need.',
|
||||
position: 'right',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'nav-config-entities',
|
||||
target: 'nav-config-entities',
|
||||
title: 'Configurations',
|
||||
body: "Databases provide several types of configuration: quality profiles, custom formats, regular expressions, delay profiles, and media management settings like naming and quality definitions. Each has its own section in the sidebar. We'll cover them in more detail later.",
|
||||
position: 'right',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'nav-settings',
|
||||
route: '/settings',
|
||||
target: 'nav-settings',
|
||||
title: 'Settings',
|
||||
body: 'This is where you configure Profilarr itself. Scheduled jobs, notification alerts, security, backups, and general preferences all live here. Most defaults work out of the box, so you can come back to this later.',
|
||||
position: 'right',
|
||||
completion: { type: 'manual' }
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Stage } from '../../types.ts';
|
||||
import type { Stage } from '$cutscene/types.ts';
|
||||
|
||||
export const personalizeStage: Stage = {
|
||||
id: 'personalize',
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Stage } from '$cutscene/types.ts';
|
||||
|
||||
export const welcomeStage: Stage = {
|
||||
id: 'welcome',
|
||||
name: 'Welcome',
|
||||
description: 'What Profilarr is and how it works',
|
||||
steps: [
|
||||
{
|
||||
id: 'what-is-profilarr',
|
||||
title: 'Welcome to Profilarr',
|
||||
body: 'Profilarr helps you build, test, and deploy media server configurations. Instead of manually configuring Radarr and Sonarr, you connect to curated databases and sync everything across your instances, while keeping any local tweaks you make.',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'nav-onboarding',
|
||||
target: 'nav-onboarding',
|
||||
title: 'Learning Profilarr',
|
||||
body: 'This is the onboarding page. It lives under Settings in the sidebar. Click it to get started.',
|
||||
position: 'right',
|
||||
completion: { type: 'click' }
|
||||
},
|
||||
{
|
||||
id: 'welcome-onboarding',
|
||||
title: 'The Onboarding Page',
|
||||
body: 'Each walkthrough here teaches one part of Profilarr with guided, hands-on steps. You can come back here anytime from Settings in the sidebar.',
|
||||
completion: { type: 'manual' }
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -1,95 +0,0 @@
|
||||
import type { Stage } from '../../types.ts';
|
||||
|
||||
export const welcomeStage: Stage = {
|
||||
id: 'welcome',
|
||||
name: 'Welcome',
|
||||
description: 'Learn what Profilarr is and how it works',
|
||||
steps: [
|
||||
{
|
||||
id: 'what-is-profilarr',
|
||||
title: 'Welcome to Profilarr',
|
||||
body: 'Profilarr helps you build, test, and deploy media server configurations. Instead of manually configuring Radarr and Sonarr, you connect to curated databases and sync everything across your instances, while keeping any local tweaks you make.',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'sidebar-overview',
|
||||
target: 'sidebar',
|
||||
title: 'Navigation',
|
||||
body: "This is your sidebar. Let's walk through what each section does.",
|
||||
position: 'right',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'nav-databases',
|
||||
route: '/databases',
|
||||
target: 'nav-databases',
|
||||
title: 'Databases',
|
||||
body: "Databases provide your configurations. They're curated git repositories full of pre-built profiles, formats, and settings. We'll cover linking one in detail later.",
|
||||
position: 'right',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'nav-quality-profiles',
|
||||
route: '/quality-profiles',
|
||||
target: 'nav-quality-profiles',
|
||||
title: 'Quality Profiles',
|
||||
body: 'Quality profiles control how your Arr instances prioritize and score releases. They determine which qualities are acceptable, what order to prefer them in, and how custom formats influence the final ranking.',
|
||||
position: 'right',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'nav-custom-formats',
|
||||
route: '/custom-formats',
|
||||
target: 'nav-custom-formats',
|
||||
title: 'Custom Formats',
|
||||
body: 'Custom formats define rules for matching releases by things like resolution, source, codec, and release group. They work together with quality profiles to fine-tune what gets downloaded.',
|
||||
position: 'right',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'nav-regex',
|
||||
route: '/regular-expressions',
|
||||
target: 'nav-regex',
|
||||
title: 'Regular Expressions',
|
||||
body: "Unlike Radarr and Sonarr where regex patterns are embedded inside custom formats, Profilarr treats them as their own entity. This means they're reusable across multiple formats. If you use the same pattern in ten formats, you only need to update it in one place.",
|
||||
position: 'right',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'nav-media-management',
|
||||
route: '/media-management',
|
||||
target: 'nav-media-management',
|
||||
title: 'Media Management',
|
||||
body: 'Media management covers naming conventions, quality definitions, and media settings. This is where you control how files and folders are named, what quality thresholds are used, and other media-related preferences.',
|
||||
position: 'right',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'nav-delay-profiles',
|
||||
route: '/delay-profiles',
|
||||
target: 'nav-delay-profiles',
|
||||
title: 'Delay Profiles',
|
||||
body: 'Delay profiles let you set protocol preferences and release delays. You can prioritize torrents over usenet or vice versa, and set minimum wait times before grabbing a release.',
|
||||
position: 'right',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'nav-arrs',
|
||||
route: '/arr',
|
||||
target: 'nav-arrs',
|
||||
title: 'Arrs',
|
||||
body: 'This is where you connect your Radarr and Sonarr instances. Once connected, you can deploy your configurations to them and keep everything in sync.',
|
||||
position: 'right',
|
||||
completion: { type: 'manual' }
|
||||
},
|
||||
{
|
||||
id: 'nav-settings',
|
||||
route: '/settings',
|
||||
target: 'nav-settings',
|
||||
title: 'Settings',
|
||||
body: 'Jobs, notifications, security, backups, and general app settings all live here.',
|
||||
position: 'right',
|
||||
completion: { type: 'manual' }
|
||||
}
|
||||
]
|
||||
};
|
||||
21
src/lib/client/cutscene/prerequisites.ts
Normal file
21
src/lib/client/cutscene/prerequisites.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { STAGES } from './definitions/index.ts';
|
||||
import { stateChecks } from './stateChecks.ts';
|
||||
|
||||
export async function checkPrerequisites(
|
||||
stageId: string
|
||||
): Promise<{ ok: true } | { ok: false; message: string }> {
|
||||
const stage = STAGES[stageId];
|
||||
if (!stage?.prerequisites) return { ok: true };
|
||||
|
||||
for (const prereq of stage.prerequisites) {
|
||||
const check = stateChecks[prereq.check];
|
||||
if (!check) continue;
|
||||
|
||||
const passed = await check();
|
||||
if (!passed) {
|
||||
return { ok: false, message: prereq.message };
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
62
src/lib/client/cutscene/routeResolvers.ts
Normal file
62
src/lib/client/cutscene/routeResolvers.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Named route resolver functions for dynamic step routing.
|
||||
* Each function returns the resolved path string.
|
||||
* Add new resolvers here as stages require them.
|
||||
*/
|
||||
export const routeResolvers: Record<string, () => Promise<string>> = {
|
||||
firstDatabaseChanges: async () => {
|
||||
const res = await fetch('/api/v1/databases');
|
||||
const dbs = await res.json();
|
||||
return `/databases/${dbs[0].id}/changes`;
|
||||
},
|
||||
firstDatabaseCommits: async () => {
|
||||
const res = await fetch('/api/v1/databases');
|
||||
const dbs = await res.json();
|
||||
return `/databases/${dbs[0].id}/commits`;
|
||||
},
|
||||
firstDatabaseConflicts: async () => {
|
||||
const res = await fetch('/api/v1/databases');
|
||||
const dbs = await res.json();
|
||||
return `/databases/${dbs[0].id}/conflicts`;
|
||||
},
|
||||
firstDatabaseTweaks: async () => {
|
||||
const res = await fetch('/api/v1/databases');
|
||||
const dbs = await res.json();
|
||||
return `/databases/${dbs[0].id}/tweaks`;
|
||||
},
|
||||
firstDatabaseSettings: async () => {
|
||||
const res = await fetch('/api/v1/databases');
|
||||
const dbs = await res.json();
|
||||
return `/databases/${dbs[0].id}/settings`;
|
||||
},
|
||||
firstArrLibrary: async () => {
|
||||
const res = await fetch('/api/v1/arr');
|
||||
const arr = await res.json();
|
||||
return `/arr/${arr[0].id}/library`;
|
||||
},
|
||||
firstArrSync: async () => {
|
||||
const res = await fetch('/api/v1/arr');
|
||||
const arr = await res.json();
|
||||
return `/arr/${arr[0].id}/sync`;
|
||||
},
|
||||
firstArrUpgrades: async () => {
|
||||
const res = await fetch('/api/v1/arr');
|
||||
const arr = await res.json();
|
||||
return `/arr/${arr[0].id}/upgrades`;
|
||||
},
|
||||
firstArrRename: async () => {
|
||||
const res = await fetch('/api/v1/arr');
|
||||
const arr = await res.json();
|
||||
return `/arr/${arr[0].id}/rename`;
|
||||
},
|
||||
firstArrLogs: async () => {
|
||||
const res = await fetch('/api/v1/arr');
|
||||
const arr = await res.json();
|
||||
return `/arr/${arr[0].id}/logs`;
|
||||
},
|
||||
firstArrSettings: async () => {
|
||||
const res = await fetch('/api/v1/arr');
|
||||
const arr = await res.json();
|
||||
return `/arr/${arr[0].id}/settings`;
|
||||
}
|
||||
};
|
||||
@@ -4,8 +4,16 @@
|
||||
* Add new checks here as stages require them.
|
||||
*/
|
||||
export const stateChecks: Record<string, () => Promise<boolean>> = {
|
||||
// Future checks will be added here as stages are built, e.g.:
|
||||
// hasDatabase: async () => { ... },
|
||||
// hasArrInstance: async () => { ... },
|
||||
// hasSyncedProfile: async () => { ... },
|
||||
hasDatabase: async () => {
|
||||
const res = await fetch('/api/v1/databases');
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
return data.length > 0;
|
||||
},
|
||||
hasArrInstance: async () => {
|
||||
const res = await fetch('/api/v1/arr');
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
return data.length > 0;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
import type { CutsceneState } from './types.ts';
|
||||
import { STAGES, PIPELINES } from './definitions/index.ts';
|
||||
import { STAGES } from './definitions/index.ts';
|
||||
import { checkPrerequisites } from './prerequisites.ts';
|
||||
import { alertStore } from '$lib/client/alerts/store';
|
||||
|
||||
const STORAGE_KEY = 'cutscene-progress';
|
||||
|
||||
const DEFAULT_STATE: CutsceneState = {
|
||||
active: false,
|
||||
pipelineId: null,
|
||||
stageId: null,
|
||||
stepIndex: 0,
|
||||
completedStages: [],
|
||||
manualStart: false
|
||||
};
|
||||
|
||||
@@ -52,11 +52,6 @@ function createCutsceneStore() {
|
||||
return STAGES[$state.stageId] ?? null;
|
||||
});
|
||||
|
||||
const currentPipeline = derived(state, ($state) => {
|
||||
if (!$state.pipelineId) return null;
|
||||
return PIPELINES[$state.pipelineId] ?? null;
|
||||
});
|
||||
|
||||
function init(shown: boolean): void {
|
||||
onboardingShown.set(shown);
|
||||
if (!shown) {
|
||||
@@ -72,39 +67,39 @@ function createCutsceneStore() {
|
||||
}
|
||||
}
|
||||
|
||||
function startStage(stageId: string): void {
|
||||
async function startStage(stageId: string, manual = true): Promise<void> {
|
||||
const stage = STAGES[stageId];
|
||||
if (!stage || stage.steps.length === 0) return;
|
||||
|
||||
const result = await checkPrerequisites(stageId);
|
||||
if (!result.ok) {
|
||||
alertStore.add('error', result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
justCompleted.set(false);
|
||||
const newState: CutsceneState = {
|
||||
active: true,
|
||||
pipelineId: null,
|
||||
stageId,
|
||||
stepIndex: 0,
|
||||
completedStages: [],
|
||||
manualStart: true
|
||||
manualStart: manual
|
||||
};
|
||||
state.set(newState);
|
||||
saveState(newState);
|
||||
}
|
||||
|
||||
function startPipeline(pipelineId: string, manual = true): void {
|
||||
const pipeline = PIPELINES[pipelineId];
|
||||
if (!pipeline || pipeline.stages.length === 0) return;
|
||||
function goBack(): void {
|
||||
state.update((current) => {
|
||||
if (!current.active || !current.stageId) return current;
|
||||
|
||||
justCompleted.set(false);
|
||||
const firstStageId = pipeline.stages[0];
|
||||
const newState: CutsceneState = {
|
||||
active: true,
|
||||
pipelineId,
|
||||
stageId: firstStageId,
|
||||
stepIndex: 0,
|
||||
completedStages: [],
|
||||
manualStart: manual
|
||||
};
|
||||
state.set(newState);
|
||||
saveState(newState);
|
||||
if (current.stepIndex > 0) {
|
||||
const updated = { ...current, stepIndex: current.stepIndex - 1 };
|
||||
saveState(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
return current;
|
||||
});
|
||||
}
|
||||
|
||||
function advance(): void {
|
||||
@@ -124,42 +119,12 @@ function createCutsceneStore() {
|
||||
}
|
||||
|
||||
// Stage complete
|
||||
const completedStages = [...current.completedStages, current.stageId];
|
||||
|
||||
// If running a pipeline, try next stage
|
||||
if (current.pipelineId) {
|
||||
const pipeline = PIPELINES[current.pipelineId];
|
||||
if (pipeline) {
|
||||
const currentStageIndex = pipeline.stages.indexOf(current.stageId);
|
||||
const nextStageId = pipeline.stages[currentStageIndex + 1];
|
||||
|
||||
if (nextStageId) {
|
||||
const updated: CutsceneState = {
|
||||
...current,
|
||||
stageId: nextStageId,
|
||||
stepIndex: 0,
|
||||
completedStages
|
||||
};
|
||||
saveState(updated);
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Done (single stage or pipeline complete)
|
||||
clearState();
|
||||
const lastStage = current.stageId ? STAGES[current.stageId] : null;
|
||||
const lastStage = STAGES[current.stageId];
|
||||
if (current.manualStart && !lastStage?.silent) {
|
||||
justCompleted.set(true);
|
||||
}
|
||||
return {
|
||||
active: false,
|
||||
pipelineId: null,
|
||||
stageId: null,
|
||||
stepIndex: 0,
|
||||
completedStages: [],
|
||||
manualStart: false
|
||||
};
|
||||
return DEFAULT_STATE;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -194,13 +159,12 @@ function createCutsceneStore() {
|
||||
subscribe: state.subscribe,
|
||||
currentStep,
|
||||
currentStage,
|
||||
currentPipeline,
|
||||
onboardingShown: { subscribe: onboardingShown.subscribe },
|
||||
justCompleted: { subscribe: justCompleted.subscribe },
|
||||
init,
|
||||
startPipeline,
|
||||
startStage,
|
||||
advance,
|
||||
goBack,
|
||||
cancel,
|
||||
dismiss,
|
||||
dismissCompleted,
|
||||
|
||||
@@ -6,7 +6,7 @@ export type Completion =
|
||||
|
||||
export interface Step {
|
||||
id: string;
|
||||
route?: string;
|
||||
route?: string | { resolve: string };
|
||||
target?: string;
|
||||
title: string;
|
||||
body: string;
|
||||
@@ -23,16 +23,21 @@ export interface Step {
|
||||
completion: Completion;
|
||||
}
|
||||
|
||||
export interface Prerequisite {
|
||||
check: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface Stage {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
steps: Step[];
|
||||
silent?: boolean;
|
||||
prerequisites?: Prerequisite[];
|
||||
}
|
||||
|
||||
export interface Pipeline {
|
||||
id: string;
|
||||
export interface StageGroup {
|
||||
name: string;
|
||||
description: string;
|
||||
stages: string[];
|
||||
@@ -40,9 +45,7 @@ export interface Pipeline {
|
||||
|
||||
export interface CutsceneState {
|
||||
active: boolean;
|
||||
pipelineId: string | null;
|
||||
stageId: string | null;
|
||||
stepIndex: number;
|
||||
completedStages: string[];
|
||||
manualStart: boolean;
|
||||
}
|
||||
|
||||
@@ -65,11 +65,11 @@ export function initEdit<T extends FormData>(serverData: T) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize for create mode - always dirty
|
||||
* Initialize for create mode - dirty when user changes something
|
||||
*/
|
||||
export function initCreate<T extends FormData>(defaults: T) {
|
||||
isNewMode.set(true);
|
||||
originalSnapshot.set(null);
|
||||
isNewMode.set(false);
|
||||
originalSnapshot.set(structuredClone(defaults));
|
||||
currentData.set(structuredClone(defaults));
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
export let title: string = '';
|
||||
export let type: 'button' | 'submit' = 'button';
|
||||
export let variant: 'neutral' | 'danger' = 'neutral';
|
||||
export let onboarding: string | undefined = undefined;
|
||||
|
||||
let isHovered = false;
|
||||
let leaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -46,6 +47,7 @@
|
||||
on:mouseenter={handleMouseEnter}
|
||||
on:mouseleave={handleMouseLeave}
|
||||
role="group"
|
||||
data-onboarding={onboarding}
|
||||
>
|
||||
<button
|
||||
{type}
|
||||
|
||||
43
src/lib/client/ui/card/ExpandableCard.svelte
Normal file
43
src/lib/client/ui/card/ExpandableCard.svelte
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown } from 'lucide-svelte';
|
||||
|
||||
export let title: string;
|
||||
export let description: string = '';
|
||||
export let open: boolean = true;
|
||||
|
||||
function toggle() {
|
||||
open = !open;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="overflow-hidden rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="flex cursor-pointer items-center justify-between bg-neutral-50 px-6 py-4 dark:bg-neutral-800/50"
|
||||
on:click={toggle}
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">{title}</h2>
|
||||
<slot name="header-right" />
|
||||
</div>
|
||||
{#if description}
|
||||
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">{description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={18}
|
||||
class="ml-4 shrink-0 text-neutral-400 transition-transform duration-200 {open
|
||||
? ''
|
||||
: '-rotate-90'}"
|
||||
/>
|
||||
</div>
|
||||
{#if open}
|
||||
<div class="border-t border-neutral-200 dark:border-neutral-800">
|
||||
<slot />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,13 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { Bug, Bird, Lightbulb, GraduationCap } from 'lucide-svelte';
|
||||
import { Bug, Bird, Lightbulb } from 'lucide-svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { FEATURES } from '$lib/shared/features';
|
||||
import { dev } from '$app/environment';
|
||||
|
||||
export let variant: 'fab' | 'navbar' = 'fab';
|
||||
|
||||
$: cutsceneEnabled = FEATURES.cutscene || dev;
|
||||
|
||||
const quips = [
|
||||
'What do you want THIS time?',
|
||||
'Oh great, you again.',
|
||||
@@ -92,24 +87,12 @@
|
||||
href="https://github.com/Dictionarry-Hub/profilarr/issues/new?template=feature.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex w-full items-center gap-3 px-3 py-2 text-left text-neutral-700 transition-colors hover:bg-neutral-50 dark:text-neutral-200 dark:hover:bg-neutral-700 {cutsceneEnabled
|
||||
? 'border-b border-neutral-200/50 dark:border-neutral-700/40'
|
||||
: ''}"
|
||||
class="flex w-full items-center gap-3 px-3 py-2 text-left text-neutral-700 transition-colors hover:bg-neutral-50 dark:text-neutral-200 dark:hover:bg-neutral-700"
|
||||
on:click={close}
|
||||
>
|
||||
<Lightbulb size={16} />
|
||||
<span>Request a Feature</span>
|
||||
</a>
|
||||
{#if cutsceneEnabled && isFab}
|
||||
<a
|
||||
href="/onboarding"
|
||||
class="flex w-full items-center gap-3 px-3 py-2 text-left text-neutral-700 transition-colors hover:bg-neutral-50 dark:text-neutral-200 dark:hover:bg-neutral-700"
|
||||
on:click={close}
|
||||
>
|
||||
<GraduationCap size={16} />
|
||||
<span>Onboarding</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#if isFab}
|
||||
<div class="border-t border-neutral-200/50 px-3 py-2 dark:border-neutral-700/40">
|
||||
<p class="text-xs text-neutral-500 italic dark:text-neutral-400">{quip}</p>
|
||||
|
||||
@@ -5,16 +5,12 @@
|
||||
import { Menu } from 'lucide-svelte';
|
||||
import { mobileNavOpen } from '$stores/mobileNav';
|
||||
import logo from '$assets/logo-512.png';
|
||||
import { alertStore } from '$alerts/store';
|
||||
import MobileNavAlert from '$alerts/MobileNavAlert.svelte';
|
||||
|
||||
$: latestAlert = $alertStore.length > 0 ? $alertStore[$alertStore.length - 1] : null;
|
||||
</script>
|
||||
|
||||
<nav
|
||||
class="fixed top-0 left-0 z-50 w-full border-r-0 border-b border-neutral-200 bg-neutral-50 md:z-[80] md:w-80 md:border-r dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
<div class="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 px-4 py-4">
|
||||
<div class="flex items-center justify-between gap-3 px-4 py-4">
|
||||
<!-- Left: Hamburger (mobile) + Brand name with logo (desktop) -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@@ -35,13 +31,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center: Mobile alerts -->
|
||||
<div class="flex min-w-0 justify-center md:hidden">
|
||||
{#if latestAlert}
|
||||
<MobileNavAlert id={latestAlert.id} type={latestAlert.type} message={latestAlert.message} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right: Accent picker and Theme toggle -->
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<AccentPicker />
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
export let initialOpen: boolean = true;
|
||||
export let hasItems: boolean = false;
|
||||
export let onboardingId: string | undefined = undefined;
|
||||
|
||||
let isOpen = initialOpen;
|
||||
|
||||
function toggleOpen() {
|
||||
@@ -18,8 +17,10 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mb-4" data-onboarding={onboardingId}>
|
||||
<GroupHeader {label} {href} {icon} {emoji} {isOpen} {hasItems} onToggle={toggleOpen} />
|
||||
<div class="mb-4">
|
||||
<div data-onboarding={onboardingId}>
|
||||
<GroupHeader {label} {href} {icon} {emoji} {isOpen} {hasItems} onToggle={toggleOpen} />
|
||||
</div>
|
||||
|
||||
{#if isOpen && hasItems}
|
||||
<div class="mt-2 grid grid-cols-[auto_1fr]" transition:slide={{ duration: 200 }}>
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
iconSrc?: string;
|
||||
/** Optional click handler (use e.preventDefault() to override navigation) */
|
||||
onclick?: (e: MouseEvent) => void;
|
||||
/** Optional data-onboarding attribute for cutscene targeting */
|
||||
onboardingId?: string;
|
||||
}
|
||||
|
||||
let { label, href, activePattern, icon, iconSrc, onclick }: Props = $props();
|
||||
let { label, href, activePattern, icon, iconSrc, onclick, onboardingId }: Props = $props();
|
||||
|
||||
const isActive = $derived.by(() => {
|
||||
const pathname = $page.url.pathname;
|
||||
@@ -36,6 +38,7 @@
|
||||
<a
|
||||
{href}
|
||||
{onclick}
|
||||
data-onboarding={onboardingId}
|
||||
class="flex items-center gap-2 rounded-lg py-1.5 pr-2 pl-3 font-sans text-sm font-semibold text-neutral-600 transition-colors hover:bg-neutral-200 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100 {isActive
|
||||
? 'bg-neutral-200 text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100'
|
||||
: ''}"
|
||||
|
||||
@@ -114,72 +114,74 @@
|
||||
{/each}
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
label="Quality Profiles"
|
||||
emoji="⚡"
|
||||
href="/quality-profiles"
|
||||
icon={Sliders}
|
||||
initialOpen={true}
|
||||
hasItems={parserAvailable}
|
||||
onboardingId="nav-quality-profiles"
|
||||
>
|
||||
{#if parserAvailable}
|
||||
<GroupItem label="Testing" href="/quality-profiles/entity-testing" />
|
||||
{/if}
|
||||
</Group>
|
||||
<div data-onboarding="nav-config-entities">
|
||||
<Group
|
||||
label="Quality Profiles"
|
||||
emoji="⚡"
|
||||
href="/quality-profiles"
|
||||
icon={Sliders}
|
||||
initialOpen={true}
|
||||
hasItems={parserAvailable}
|
||||
onboardingId="nav-quality-profiles"
|
||||
>
|
||||
{#if parserAvailable}
|
||||
<GroupItem label="Testing" href="/quality-profiles/entity-testing" />
|
||||
{/if}
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
label="Custom Formats"
|
||||
emoji="🎨"
|
||||
href="/custom-formats"
|
||||
icon={Palette}
|
||||
initialOpen={false}
|
||||
onboardingId="nav-custom-formats"
|
||||
/>
|
||||
|
||||
<Group
|
||||
label="Regular Expressions"
|
||||
emoji="🔬"
|
||||
href="/regular-expressions"
|
||||
icon={Microscope}
|
||||
initialOpen={false}
|
||||
onboardingId="nav-regex"
|
||||
/>
|
||||
|
||||
<Group
|
||||
label="Media Management"
|
||||
emoji="🏷️"
|
||||
href="/media-management"
|
||||
icon={Tag}
|
||||
initialOpen={true}
|
||||
hasItems={true}
|
||||
onboardingId="nav-media-management"
|
||||
>
|
||||
<GroupItem
|
||||
label="Naming Settings"
|
||||
href="/media-management?section=naming"
|
||||
activePattern="/naming"
|
||||
<Group
|
||||
label="Custom Formats"
|
||||
emoji="🎨"
|
||||
href="/custom-formats"
|
||||
icon={Palette}
|
||||
initialOpen={false}
|
||||
onboardingId="nav-custom-formats"
|
||||
/>
|
||||
<GroupItem
|
||||
label="Quality Definitions"
|
||||
href="/media-management?section=quality-definitions"
|
||||
activePattern="/quality-definitions"
|
||||
/>
|
||||
<GroupItem
|
||||
label="Media Settings"
|
||||
href="/media-management?section=media-settings"
|
||||
activePattern="/media-settings"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
label="Delay Profiles"
|
||||
emoji="⏳"
|
||||
href="/delay-profiles"
|
||||
icon={Clock}
|
||||
initialOpen={false}
|
||||
onboardingId="nav-delay-profiles"
|
||||
/>
|
||||
<Group
|
||||
label="Regular Expressions"
|
||||
emoji="🔬"
|
||||
href="/regular-expressions"
|
||||
icon={Microscope}
|
||||
initialOpen={false}
|
||||
onboardingId="nav-regex"
|
||||
/>
|
||||
|
||||
<Group
|
||||
label="Media Management"
|
||||
emoji="🏷️"
|
||||
href="/media-management"
|
||||
icon={Tag}
|
||||
initialOpen={true}
|
||||
hasItems={true}
|
||||
onboardingId="nav-media-management"
|
||||
>
|
||||
<GroupItem
|
||||
label="Naming Settings"
|
||||
href="/media-management?section=naming"
|
||||
activePattern="/naming"
|
||||
/>
|
||||
<GroupItem
|
||||
label="Quality Definitions"
|
||||
href="/media-management?section=quality-definitions"
|
||||
activePattern="/quality-definitions"
|
||||
/>
|
||||
<GroupItem
|
||||
label="Media Settings"
|
||||
href="/media-management?section=media-settings"
|
||||
activePattern="/media-settings"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
label="Delay Profiles"
|
||||
emoji="⏳"
|
||||
href="/delay-profiles"
|
||||
icon={Clock}
|
||||
initialOpen={false}
|
||||
onboardingId="nav-delay-profiles"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Group
|
||||
label="Settings"
|
||||
@@ -196,6 +198,7 @@
|
||||
<GroupItem label="Backups" href="/settings/backups" />
|
||||
<GroupItem label="Notifications" href="/settings/notifications" />
|
||||
<GroupItem label="Security" href="/settings/security" />
|
||||
<GroupItem label="Onboarding" href="/onboarding" onboardingId="nav-onboarding" />
|
||||
<GroupItem label="About" href="/settings/about" />
|
||||
<GroupItem
|
||||
label="Log Out"
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
href: string;
|
||||
active?: boolean;
|
||||
icon?: ComponentType;
|
||||
onboarding?: string;
|
||||
}
|
||||
|
||||
interface BackButton {
|
||||
@@ -139,6 +140,7 @@
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => handleTabSelect(tab.href)}
|
||||
data-onboarding={tab.onboarding || null}
|
||||
class="flex cursor-pointer items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors {tab.active
|
||||
? 'border-accent-600 text-accent-600 dark:border-accent-500 dark:text-accent-500'
|
||||
: 'border-transparent text-neutral-600 hover:border-neutral-300 hover:text-neutral-900 dark:text-neutral-400 dark:hover:border-neutral-700 dark:hover:text-neutral-50'}"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Plus } from 'lucide-svelte';
|
||||
import Button from '$ui/button/Button.svelte';
|
||||
|
||||
export let icon: any; // Lucide icon component
|
||||
export let title: string;
|
||||
@@ -7,6 +8,7 @@
|
||||
export let buttonText: string;
|
||||
export let buttonHref: string;
|
||||
export let buttonIcon: any = Plus; // Default to Plus icon
|
||||
export let onboarding: string | undefined = undefined;
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-[calc(100vh-4rem)] items-center justify-center p-8">
|
||||
@@ -29,12 +31,13 @@
|
||||
</p>
|
||||
|
||||
<!-- Action Button -->
|
||||
<a
|
||||
href={buttonHref}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-accent-600 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-700 dark:bg-accent-500 dark:hover:bg-accent-600"
|
||||
>
|
||||
<svelte:component this={buttonIcon} size={18} />
|
||||
{buttonText}
|
||||
</a>
|
||||
<span data-onboarding={onboarding} class="inline-block">
|
||||
<Button
|
||||
text={buttonText}
|
||||
href={buttonHref}
|
||||
icon={buttonIcon}
|
||||
iconColor="text-blue-600 dark:text-blue-400"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,5 +9,5 @@ export const FEATURES = {
|
||||
/** AI-powered commit message generation */
|
||||
ai: false,
|
||||
/** Cutscene onboarding system */
|
||||
cutscene: false
|
||||
cutscene: true
|
||||
} as const;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import AlertContainer from '$alerts/AlertContainer.svelte';
|
||||
import HelpButton from '$ui/help/HelpButton.svelte';
|
||||
import CutsceneOverlay from '$lib/client/cutscene/CutsceneOverlay.svelte';
|
||||
import CutscenePrompt from '$lib/client/cutscene/CutscenePrompt.svelte';
|
||||
|
||||
import CutsceneComplete from '$lib/client/cutscene/CutsceneComplete.svelte';
|
||||
import { cutscene } from '$lib/client/cutscene/store';
|
||||
import { FEATURES } from '$lib/shared/features';
|
||||
@@ -55,7 +55,6 @@
|
||||
<HelpButton />
|
||||
{#if cutsceneEnabled && isDesktop}
|
||||
<CutsceneOverlay />
|
||||
<CutscenePrompt />
|
||||
<CutsceneComplete />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -52,13 +52,19 @@
|
||||
buttonText="Add Instance"
|
||||
buttonHref="/arr/new"
|
||||
buttonIcon={Plus}
|
||||
onboarding="arr-add"
|
||||
/>
|
||||
{:else}
|
||||
<div class="space-y-6 p-4 sm:p-8">
|
||||
<!-- Actions Bar -->
|
||||
<ActionsBar>
|
||||
<SearchAction searchStore={search} placeholder="Search instances..." />
|
||||
<ActionButton icon={Plus} title="Add Instance" on:click={() => goto('/arr/new')} />
|
||||
<ActionButton
|
||||
icon={Plus}
|
||||
title="Add Instance"
|
||||
on:click={() => goto('/arr/new')}
|
||||
onboarding="arr-add"
|
||||
/>
|
||||
<ActionButton icon={Info} title="Info" on:click={() => (showInfoModal = true)} />
|
||||
<ViewToggle bind:value={$view} />
|
||||
</ActionsBar>
|
||||
|
||||
@@ -13,14 +13,16 @@
|
||||
label: 'Library',
|
||||
href: `/arr/${instanceId}/library`,
|
||||
active: currentPath.includes('/library'),
|
||||
icon: Library
|
||||
icon: Library,
|
||||
onboarding: 'arr-tab-library'
|
||||
};
|
||||
|
||||
$: syncTab = {
|
||||
label: 'Sync',
|
||||
href: `/arr/${instanceId}/sync`,
|
||||
active: currentPath.includes('/sync'),
|
||||
icon: RefreshCw
|
||||
icon: RefreshCw,
|
||||
onboarding: 'arr-tab-sync'
|
||||
};
|
||||
|
||||
$: otherTabs = [
|
||||
@@ -28,25 +30,29 @@
|
||||
label: 'Upgrades',
|
||||
href: `/arr/${instanceId}/upgrades`,
|
||||
active: currentPath.includes('/upgrades'),
|
||||
icon: ArrowUpCircle
|
||||
icon: ArrowUpCircle,
|
||||
onboarding: 'arr-tab-upgrades'
|
||||
},
|
||||
{
|
||||
label: 'Renames',
|
||||
href: `/arr/${instanceId}/rename`,
|
||||
active: currentPath.includes('/rename'),
|
||||
icon: FileEdit
|
||||
icon: FileEdit,
|
||||
onboarding: 'arr-tab-renames'
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
href: `/arr/${instanceId}/logs`,
|
||||
active: currentPath.includes('/logs'),
|
||||
icon: ScrollText
|
||||
icon: ScrollText,
|
||||
onboarding: 'arr-tab-logs'
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
href: `/arr/${instanceId}/settings`,
|
||||
active: currentPath.includes('/settings'),
|
||||
icon: Settings
|
||||
icon: Settings,
|
||||
onboarding: 'arr-tab-settings'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Rename Folders -->
|
||||
<div>
|
||||
<div data-onboarding="rename-folders">
|
||||
<span class="mb-1 block text-sm text-neutral-500 md:hidden dark:text-neutral-400"
|
||||
>Folders</span
|
||||
>
|
||||
@@ -126,7 +126,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Summary Notifications -->
|
||||
<div>
|
||||
<div data-onboarding="rename-summary">
|
||||
<span class="mb-1 block text-sm text-neutral-500 md:hidden dark:text-neutral-400"
|
||||
>Summary</span
|
||||
>
|
||||
@@ -147,7 +147,7 @@
|
||||
<div class="hidden h-6 w-px bg-neutral-200 md:block dark:bg-neutral-700"></div>
|
||||
|
||||
<!-- Schedule -->
|
||||
<div>
|
||||
<div data-onboarding="rename-schedule">
|
||||
<span class="mb-1 block text-sm text-neutral-500 md:hidden dark:text-neutral-400"
|
||||
>Schedule</span
|
||||
>
|
||||
@@ -160,7 +160,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Ignore Tag -->
|
||||
<div>
|
||||
<div data-onboarding="rename-ignore-tag">
|
||||
<span class="mb-1 block text-sm text-neutral-500 md:hidden dark:text-neutral-400"
|
||||
>Ignore Tag</span
|
||||
>
|
||||
|
||||
@@ -130,7 +130,9 @@
|
||||
</p>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="right">
|
||||
<Button text="How it works" icon={Info} on:click={() => (showInfoModal = true)} />
|
||||
<span data-onboarding="sync-how-it-works">
|
||||
<Button text="How it works" icon={Info} on:click={() => (showInfoModal = true)} />
|
||||
</span>
|
||||
</svelte:fragment>
|
||||
</StickyCard>
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-onboarding="sync-delay-profiles"
|
||||
class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
<!-- Header -->
|
||||
|
||||
@@ -209,6 +209,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-onboarding="sync-media-management"
|
||||
class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
<!-- Header -->
|
||||
@@ -278,6 +279,7 @@
|
||||
hasConfig={state.namingDatabaseId !== null ||
|
||||
state.qualityDefinitionsDatabaseId !== null ||
|
||||
state.mediaSettingsDatabaseId !== null}
|
||||
onboardingId="sync-trigger"
|
||||
onWarning={(msg) => alertStore.add('warning', msg)}
|
||||
on:save={handleSave}
|
||||
on:sync={handleSync}
|
||||
|
||||
@@ -143,6 +143,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-onboarding="sync-quality-profiles"
|
||||
class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
<!-- Header -->
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
export let hasConfig: boolean = true;
|
||||
export let warning: string | null = null;
|
||||
export let onWarning: ((message: string) => void) | undefined = undefined;
|
||||
export let onboardingId: string | undefined = undefined;
|
||||
|
||||
const dispatch = createEventDispatcher<{ save: void; sync: void }>();
|
||||
|
||||
@@ -39,7 +40,7 @@
|
||||
|
||||
<!-- Row 1: Trigger + Warning + Buttons -->
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div data-onboarding={onboardingId}>
|
||||
<span class="mb-1 block text-xs text-neutral-500 dark:text-neutral-400">Trigger</span>
|
||||
<DropdownSelect
|
||||
value={syncTrigger}
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<section data-onboarding="upgrades-filters">
|
||||
<h2
|
||||
class="mb-3 flex items-center gap-2 text-lg font-semibold text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
|
||||
@@ -102,13 +102,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Schedule -->
|
||||
<div>
|
||||
<div data-onboarding="upgrades-schedule">
|
||||
<span class="mb-1 block text-xs text-neutral-500 dark:text-neutral-400">Schedule</span>
|
||||
<CronInput bind:value={cronValue} {minIntervalMinutes} {onWarning} />
|
||||
</div>
|
||||
|
||||
<!-- Filter Mode -->
|
||||
<div>
|
||||
<div data-onboarding="upgrades-filter-mode">
|
||||
<span class="mb-1 block text-xs text-neutral-500 dark:text-neutral-400">Mode</span>
|
||||
<DropdownSelect
|
||||
value={filterMode}
|
||||
|
||||
@@ -262,7 +262,12 @@
|
||||
<div class="mb-4">
|
||||
<ActionsBar>
|
||||
<SearchAction {searchStore} placeholder="Search filters..." />
|
||||
<ActionButton icon={Plus} title="Add filter" on:click={addFilter} />
|
||||
<ActionButton
|
||||
icon={Plus}
|
||||
title="Add filter"
|
||||
onboarding="upgrades-add-filter"
|
||||
on:click={addFilter}
|
||||
/>
|
||||
</ActionsBar>
|
||||
</div>
|
||||
|
||||
@@ -392,17 +397,19 @@
|
||||
|
||||
<svelte:fragment slot="expanded" let:row>
|
||||
<div class="space-y-4 p-6">
|
||||
<FilterGroupComponent
|
||||
group={row.group}
|
||||
appType={resolvedAppType}
|
||||
on:change={handleChange}
|
||||
/>
|
||||
<div data-onboarding="upgrades-filter-rules">
|
||||
<FilterGroupComponent
|
||||
group={row.group}
|
||||
appType={resolvedAppType}
|
||||
on:change={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Selection Settings -->
|
||||
<Card flush padding="md">
|
||||
<h3 class="mb-3 text-sm font-medium text-neutral-700 dark:text-neutral-300">Settings</h3>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<div data-onboarding="upgrades-cutoff">
|
||||
<label
|
||||
for="cutoff-{row.id}"
|
||||
class="block text-sm font-medium text-neutral-600 dark:text-neutral-400"
|
||||
@@ -424,7 +431,7 @@
|
||||
Score threshold for "cutoff met"
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div data-onboarding="upgrades-method">
|
||||
<label
|
||||
for="selector-{row.id}"
|
||||
class="mb-1 block text-sm font-medium text-neutral-600 dark:text-neutral-400"
|
||||
@@ -450,7 +457,7 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div data-onboarding="upgrades-count">
|
||||
<label
|
||||
for="count-{row.id}"
|
||||
class="block text-sm font-medium text-neutral-600 dark:text-neutral-400"
|
||||
@@ -472,7 +479,7 @@
|
||||
Items per run (max {countMax} at this schedule)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div data-onboarding="upgrades-cooldown">
|
||||
<label
|
||||
for="tag-{row.id}"
|
||||
class="block text-sm font-medium text-neutral-600 dark:text-neutral-400"
|
||||
@@ -503,8 +510,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</div></svelte:fragment
|
||||
>
|
||||
</ExpandableTable>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -299,13 +299,15 @@
|
||||
on:click={() => (showDeleteModal = true)}
|
||||
/>
|
||||
{/if}
|
||||
<Button
|
||||
text={saving ? 'Saving...' : 'Save'}
|
||||
icon={Save}
|
||||
iconColor="text-blue-600 dark:text-blue-400"
|
||||
disabled={saving || !canSubmit}
|
||||
on:click={handleSave}
|
||||
/>
|
||||
<div data-onboarding="arr-save">
|
||||
<Button
|
||||
text={saving ? 'Saving...' : 'Save'}
|
||||
icon={Save}
|
||||
iconColor="text-blue-600 dark:text-blue-400"
|
||||
disabled={saving || !canSubmit}
|
||||
on:click={handleSave}
|
||||
/>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</StickyCard>
|
||||
|
||||
@@ -313,7 +315,7 @@
|
||||
class="space-y-4 rounded-lg border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
<!-- Type Row -->
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-2" data-onboarding="arr-type">
|
||||
<span class="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
Type{#if mode === 'create'}<span class="text-red-500">*</span>{/if}
|
||||
</span>
|
||||
@@ -330,63 +332,68 @@
|
||||
on:change={(e) => update('type', e.detail)}
|
||||
/>
|
||||
</div>
|
||||
<!-- Name + Status Row -->
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-end">
|
||||
<div class="flex-1">
|
||||
<FormInput
|
||||
label="Name"
|
||||
name="name"
|
||||
value={name}
|
||||
placeholder="e.g., Main Radarr, 4K Sonarr"
|
||||
required
|
||||
on:input={(e) => update('name', e.detail)}
|
||||
/>
|
||||
<!-- Name, URL, API Key -->
|
||||
<div data-onboarding="arr-connection" class="space-y-4">
|
||||
<!-- Name + Status Row -->
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-end">
|
||||
<div class="flex-1">
|
||||
<FormInput
|
||||
label="Name"
|
||||
name="name"
|
||||
value={name}
|
||||
placeholder="e.g., Main Radarr, 4K Sonarr"
|
||||
required
|
||||
on:input={(e) => update('name', e.detail)}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<span class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
>Status</span
|
||||
>
|
||||
<DropdownSelect
|
||||
value={enabled}
|
||||
options={enabledOptions}
|
||||
on:change={(e) => update('enabled', e.detail)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<span class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">Status</span>
|
||||
<DropdownSelect
|
||||
value={enabled}
|
||||
options={enabledOptions}
|
||||
on:change={(e) => update('enabled', e.detail)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if enabled === 'false'}
|
||||
<p class="text-xs text-amber-600 dark:text-amber-400">
|
||||
Disabled instances are excluded from sync operations
|
||||
</p>
|
||||
{/if}
|
||||
<!-- URL Row -->
|
||||
<FormInput
|
||||
label="URL"
|
||||
name="url"
|
||||
type="url"
|
||||
value={url}
|
||||
placeholder="http://localhost:7878"
|
||||
description="Use container name if on the same Docker network, e.g. http://radarr:7878"
|
||||
required
|
||||
on:input={(e) => update('url', e.detail)}
|
||||
/>
|
||||
<!-- API Key + Test Connection Row -->
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-end">
|
||||
<div class="flex-1">
|
||||
<FormInput
|
||||
label="API Key"
|
||||
name="api_key"
|
||||
value={apiKey}
|
||||
placeholder={mode === 'edit' ? '••••••••••••••••' : 'Enter API key'}
|
||||
description={mode === 'edit' ? 'Leave blank to keep existing key' : ''}
|
||||
required
|
||||
private_
|
||||
on:input={(e) => update('apiKey', e.detail)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
text={testing ? 'Testing...' : 'Test Connection'}
|
||||
icon={testing ? Loader2 : Wifi}
|
||||
disabled={testing || !apiKey || !url || (mode === 'create' && !type)}
|
||||
on:click={testConnection}
|
||||
{#if enabled === 'false'}
|
||||
<p class="text-xs text-amber-600 dark:text-amber-400">
|
||||
Disabled instances are excluded from sync operations
|
||||
</p>
|
||||
{/if}
|
||||
<!-- URL Row -->
|
||||
<FormInput
|
||||
label="URL"
|
||||
name="url"
|
||||
type="url"
|
||||
value={url}
|
||||
placeholder="http://localhost:7878"
|
||||
description="Use container name if on the same Docker network, e.g. http://radarr:7878"
|
||||
required
|
||||
on:input={(e) => update('url', e.detail)}
|
||||
/>
|
||||
<!-- API Key + Test Connection Row -->
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-end">
|
||||
<div class="flex-1">
|
||||
<FormInput
|
||||
label="API Key"
|
||||
name="api_key"
|
||||
value={apiKey}
|
||||
placeholder={mode === 'edit' ? '••••••••••••••••' : 'Enter API key'}
|
||||
description={mode === 'edit' ? 'Leave blank to keep existing key' : ''}
|
||||
required
|
||||
private_
|
||||
on:input={(e) => update('apiKey', e.detail)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
text={testing ? 'Testing...' : 'Test Connection'}
|
||||
icon={testing ? Loader2 : Wifi}
|
||||
disabled={testing || !apiKey || !url || (mode === 'create' && !type)}
|
||||
on:click={testConnection}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tags Row -->
|
||||
<div class="space-y-2">
|
||||
|
||||
@@ -53,13 +53,19 @@
|
||||
buttonText="Link Database"
|
||||
buttonHref="/databases/new"
|
||||
buttonIcon={Plus}
|
||||
onboarding="db-add"
|
||||
/>
|
||||
{:else}
|
||||
<div class="space-y-6 p-4 sm:p-8">
|
||||
<!-- Actions Bar -->
|
||||
<ActionsBar>
|
||||
<SearchAction searchStore={search} placeholder="Search databases..." />
|
||||
<ActionButton icon={Plus} title="Link Database" on:click={() => goto('/databases/new')} />
|
||||
<ActionButton
|
||||
icon={Plus}
|
||||
title="Link Database"
|
||||
on:click={() => goto('/databases/new')}
|
||||
onboarding="db-add"
|
||||
/>
|
||||
<ActionButton icon={Info} title="Info" on:click={() => (showInfoModal = true)} />
|
||||
<ViewToggle bind:value={$view} />
|
||||
</ActionsBar>
|
||||
|
||||
@@ -19,25 +19,29 @@
|
||||
label: 'Changes',
|
||||
href: `/databases/${database.id}/changes`,
|
||||
icon: GitBranch,
|
||||
active: currentPath.endsWith('/changes')
|
||||
active: currentPath.endsWith('/changes'),
|
||||
onboarding: 'db-tab-changes'
|
||||
},
|
||||
{
|
||||
label: 'Commits',
|
||||
href: `/databases/${database.id}/commits`,
|
||||
icon: History,
|
||||
active: currentPath.includes('/commits')
|
||||
active: currentPath.includes('/commits'),
|
||||
onboarding: 'db-tab-commits'
|
||||
},
|
||||
{
|
||||
label: 'Conflicts',
|
||||
href: `/databases/${database.id}/conflicts`,
|
||||
icon: GitPullRequestClosed,
|
||||
active: currentPath.includes('/conflicts')
|
||||
active: currentPath.includes('/conflicts'),
|
||||
onboarding: 'db-tab-conflicts'
|
||||
},
|
||||
{
|
||||
label: 'Tweaks',
|
||||
href: `/databases/${database.id}/tweaks`,
|
||||
icon: Wrench,
|
||||
active: currentPath.includes('/tweaks')
|
||||
active: currentPath.includes('/tweaks'),
|
||||
onboarding: 'db-tab-tweaks'
|
||||
},
|
||||
...(database.hasPat
|
||||
? [
|
||||
@@ -53,7 +57,8 @@
|
||||
label: 'Settings',
|
||||
href: `/databases/${database.id}/settings`,
|
||||
icon: Settings,
|
||||
active: currentPath.includes('/settings')
|
||||
active: currentPath.includes('/settings'),
|
||||
onboarding: 'db-tab-settings'
|
||||
}
|
||||
]
|
||||
: [];
|
||||
|
||||
@@ -241,13 +241,15 @@
|
||||
on:click={() => (showDeleteModal = true)}
|
||||
/>
|
||||
{/if}
|
||||
<Button
|
||||
text={saving ? 'Saving...' : 'Save'}
|
||||
icon={Save}
|
||||
iconColor="text-blue-600 dark:text-blue-400"
|
||||
disabled={saving || !canSubmit}
|
||||
on:click={handleSave}
|
||||
/>
|
||||
<div data-onboarding="db-save">
|
||||
<Button
|
||||
text={saving ? 'Saving...' : 'Save'}
|
||||
icon={Save}
|
||||
iconColor="text-blue-600 dark:text-blue-400"
|
||||
disabled={saving || !canSubmit}
|
||||
on:click={handleSave}
|
||||
/>
|
||||
</div>
|
||||
{#if mode === 'edit'}
|
||||
<Button
|
||||
href={repoInfo?.htmlUrl ?? instance?.repository_url}
|
||||
|
||||
@@ -1,141 +1,93 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Play } from 'lucide-svelte';
|
||||
import { cutscene } from '$lib/client/cutscene/store';
|
||||
import { STAGES, PIPELINES } from '$lib/client/cutscene/definitions/index.ts';
|
||||
import { STAGES, GROUPS } from '$lib/client/cutscene/definitions/index.ts';
|
||||
import ActionsBar from '$ui/actions/ActionsBar.svelte';
|
||||
import SearchAction from '$ui/actions/SearchAction.svelte';
|
||||
import ViewToggle from '$ui/actions/ViewToggle.svelte';
|
||||
import Card from '$ui/card/Card.svelte';
|
||||
import CardGrid from '$ui/card/CardGrid.svelte';
|
||||
import ExpandableCard from '$ui/card/ExpandableCard.svelte';
|
||||
import Button from '$ui/button/Button.svelte';
|
||||
import Label from '$ui/label/Label.svelte';
|
||||
import { createDataPageStore } from '$lib/client/stores/dataPage';
|
||||
import { alertStore } from '$alerts/store';
|
||||
|
||||
function handleStartPipeline(id: string): void {
|
||||
cutscene.startPipeline(id);
|
||||
interface StageItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
steps: number;
|
||||
}
|
||||
|
||||
function handleStartStage(id: string): void {
|
||||
cutscene.startStage(id);
|
||||
}
|
||||
|
||||
const pipelines = Object.values(PIPELINES).map((p) => ({
|
||||
type: 'pipeline' as const,
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
count: p.stages.length,
|
||||
countLabel: p.stages.length === 1 ? 'stage' : 'stages'
|
||||
// Build flat list of all stages
|
||||
const allStages: StageItem[] = Object.entries(STAGES).map(([id, stage]) => ({
|
||||
id,
|
||||
name: stage.name,
|
||||
description: stage.description,
|
||||
steps: stage.steps.length
|
||||
}));
|
||||
|
||||
const stages = Object.values(STAGES).map((s) => ({
|
||||
type: 'stage' as const,
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
count: s.steps.length,
|
||||
countLabel: s.steps.length === 1 ? 'step' : 'steps'
|
||||
}));
|
||||
|
||||
const allItems = [...pipelines, ...stages];
|
||||
|
||||
const { search, view, filtered } = createDataPageStore(allItems, {
|
||||
const { search, filtered } = createDataPageStore(allStages, {
|
||||
storageKey: 'onboardingView',
|
||||
searchKeys: ['name', 'description'],
|
||||
searchKey: 'onboardingSearch'
|
||||
searchKeys: ['name', 'description']
|
||||
});
|
||||
|
||||
$: pipelineResults = $filtered.filter((i) => i.type === 'pipeline');
|
||||
$: stageResults = $filtered.filter((i) => i.type === 'stage');
|
||||
// Group filtered stages by their group
|
||||
$: filteredIds = new Set($filtered.map((s) => s.id));
|
||||
$: filteredGroups = GROUPS.map((group) => ({
|
||||
...group,
|
||||
stages: group.stages.filter((id) => filteredIds.has(id))
|
||||
})).filter((group) => group.stages.length > 0);
|
||||
|
||||
async function handleStart(id: string): Promise<void> {
|
||||
if (window.innerWidth < 768) {
|
||||
alertStore.add(
|
||||
'warning',
|
||||
'Onboarding walkthroughs are only available on desktop. Move to a larger screen to continue.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
await cutscene.startStage(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Onboarding - Profilarr</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6 px-4 pt-8 pb-8 md:px-8 md:pt-12">
|
||||
<div class="p-4 md:p-8">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-neutral-900 dark:text-neutral-50">Onboarding</h1>
|
||||
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Guided walkthroughs to help you get the most out of Profilarr. Run a full pipeline or replay
|
||||
individual stages.
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-neutral-900 md:text-3xl dark:text-neutral-50">Onboarding</h1>
|
||||
<p class="mt-2 text-base text-neutral-600 md:mt-3 md:text-lg dark:text-neutral-400">
|
||||
Guided walkthroughs to help you get the most out of Profilarr. Run any stage at your own pace.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions Bar -->
|
||||
<ActionsBar>
|
||||
<SearchAction searchStore={search} placeholder="Search..." responsive />
|
||||
<ViewToggle bind:value={$view} />
|
||||
</ActionsBar>
|
||||
<div class="space-y-6">
|
||||
<!-- Actions Bar -->
|
||||
<ActionsBar>
|
||||
<SearchAction searchStore={search} placeholder="Search stages..." responsive />
|
||||
</ActionsBar>
|
||||
|
||||
<!-- Pipelines -->
|
||||
{#if pipelineResults.length > 0}
|
||||
<div>
|
||||
<h2
|
||||
class="mb-3 text-xs font-semibold tracking-wide text-neutral-500 uppercase dark:text-neutral-400"
|
||||
>
|
||||
Pipelines
|
||||
</h2>
|
||||
{#if $view === 'table'}
|
||||
<div
|
||||
class="overflow-hidden rounded-xl border border-neutral-300 dark:border-neutral-700/60"
|
||||
>
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead
|
||||
class="border-b border-neutral-200 bg-neutral-100 text-xs text-neutral-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-400"
|
||||
>
|
||||
<tr>
|
||||
<th class="px-4 py-2 font-medium">Name</th>
|
||||
<th class="px-4 py-2 font-medium">Description</th>
|
||||
<th class="px-4 py-2 font-medium">Stages</th>
|
||||
<th class="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-200 dark:divide-neutral-700/60">
|
||||
{#each pipelineResults as item}
|
||||
<tr class="bg-white dark:bg-neutral-900">
|
||||
<td class="px-4 py-3 font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{item.name}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-neutral-500 dark:text-neutral-400">
|
||||
{item.description}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-neutral-500 dark:text-neutral-400">
|
||||
{item.count}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<Button
|
||||
text="Start"
|
||||
icon={Play}
|
||||
iconColor="text-accent-600 dark:text-accent-400"
|
||||
size="xs"
|
||||
on:click={() => handleStartPipeline(item.id)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<CardGrid flush>
|
||||
{#each pipelineResults as item}
|
||||
<Card>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Groups -->
|
||||
{#each filteredGroups as group}
|
||||
<ExpandableCard title={group.name} description={group.description}>
|
||||
<div class="divide-y divide-neutral-200 dark:divide-neutral-700/60">
|
||||
{#each group.stages as stageId}
|
||||
{@const stage = STAGES[stageId]}
|
||||
{#if stage}
|
||||
<div class="flex items-center gap-4 px-6 py-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{item.name}
|
||||
{stage.name}
|
||||
</h3>
|
||||
<Label variant="secondary" size="sm" rounded="md">
|
||||
{item.count}
|
||||
{item.countLabel}
|
||||
{stage.steps.length}
|
||||
{stage.steps.length === 1 ? 'step' : 'steps'}
|
||||
</Label>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-neutral-600 dark:text-neutral-400">
|
||||
{item.description}
|
||||
{stage.description}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -143,96 +95,13 @@
|
||||
icon={Play}
|
||||
iconColor="text-accent-600 dark:text-accent-400"
|
||||
size="sm"
|
||||
on:click={() => handleStartPipeline(item.id)}
|
||||
on:click={() => handleStart(stageId)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
{/each}
|
||||
</CardGrid>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Individual Stages -->
|
||||
{#if stageResults.length > 0}
|
||||
<div>
|
||||
<h2
|
||||
class="mb-3 text-xs font-semibold tracking-wide text-neutral-500 uppercase dark:text-neutral-400"
|
||||
>
|
||||
Stages
|
||||
</h2>
|
||||
{#if $view === 'table'}
|
||||
<div
|
||||
class="overflow-hidden rounded-xl border border-neutral-300 dark:border-neutral-700/60"
|
||||
>
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead
|
||||
class="border-b border-neutral-200 bg-neutral-100 text-xs text-neutral-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-400"
|
||||
>
|
||||
<tr>
|
||||
<th class="px-4 py-2 font-medium">Name</th>
|
||||
<th class="px-4 py-2 font-medium">Description</th>
|
||||
<th class="px-4 py-2 font-medium">Steps</th>
|
||||
<th class="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-200 dark:divide-neutral-700/60">
|
||||
{#each stageResults as item}
|
||||
<tr class="bg-white dark:bg-neutral-900">
|
||||
<td class="px-4 py-3 font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{item.name}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-neutral-500 dark:text-neutral-400">
|
||||
{item.description}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-neutral-500 dark:text-neutral-400">
|
||||
{item.count}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<Button
|
||||
text="Start"
|
||||
icon={Play}
|
||||
iconColor="text-accent-600 dark:text-accent-400"
|
||||
size="xs"
|
||||
on:click={() => handleStartStage(item.id)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<CardGrid flush>
|
||||
{#each stageResults as item}
|
||||
<Card>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{item.name}
|
||||
</h3>
|
||||
<Label variant="secondary" size="sm" rounded="md">
|
||||
{item.count}
|
||||
{item.countLabel}
|
||||
</Label>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-neutral-600 dark:text-neutral-400">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
text="Start"
|
||||
icon={Play}
|
||||
iconColor="text-accent-600 dark:text-accent-400"
|
||||
size="sm"
|
||||
on:click={() => handleStartStage(item.id)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</CardGrid>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</ExpandableCard>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { Sliders, ShieldCheck, Bell, Clock, FileText, Archive, Info } from 'lucide-svelte';
|
||||
import {
|
||||
Sliders,
|
||||
ShieldCheck,
|
||||
GraduationCap,
|
||||
Bell,
|
||||
Clock,
|
||||
FileText,
|
||||
Archive,
|
||||
Info
|
||||
} from 'lucide-svelte';
|
||||
|
||||
const settingsItems = [
|
||||
{
|
||||
@@ -16,6 +25,13 @@
|
||||
icon: ShieldCheck,
|
||||
iconClass: 'text-rose-600 dark:text-rose-400'
|
||||
},
|
||||
{
|
||||
label: 'Onboarding',
|
||||
href: '/onboarding',
|
||||
description: 'Guided walkthroughs and interactive tutorials',
|
||||
icon: GraduationCap,
|
||||
iconClass: 'text-violet-600 dark:text-violet-400'
|
||||
},
|
||||
{
|
||||
label: 'Notifications',
|
||||
href: '/settings/notifications',
|
||||
|
||||
@@ -19,9 +19,8 @@
|
||||
AlertTriangle,
|
||||
Info
|
||||
} from 'lucide-svelte';
|
||||
import StickyCard from '$ui/card/StickyCard.svelte';
|
||||
import Card from '$ui/card/Card.svelte';
|
||||
import CardGrid from '$ui/card/CardGrid.svelte';
|
||||
import ExpandableCard from '$ui/card/ExpandableCard.svelte';
|
||||
import DirtyModal from '$ui/modal/DirtyModal.svelte';
|
||||
import Button from '$ui/button/Button.svelte';
|
||||
import Toggle from '$ui/toggle/Toggle.svelte';
|
||||
import FormInput from '$ui/form/FormInput.svelte';
|
||||
@@ -250,490 +249,446 @@
|
||||
}}
|
||||
>
|
||||
<div class="p-4 md:p-8">
|
||||
<StickyCard position="top">
|
||||
<div slot="left">
|
||||
<h1 class="text-xl font-semibold text-neutral-900 dark:text-neutral-50">
|
||||
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-neutral-900 md:text-3xl dark:text-neutral-50">
|
||||
General Settings
|
||||
</h1>
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<p class="mt-2 text-base text-neutral-600 md:mt-3 md:text-lg dark:text-neutral-400">
|
||||
Configure general application settings and preferences
|
||||
</p>
|
||||
</div>
|
||||
<div slot="right">
|
||||
<Button
|
||||
text={saving ? 'Saving...' : 'Save'}
|
||||
icon={saving ? Loader2 : Save}
|
||||
iconColor="text-blue-600 dark:text-blue-400"
|
||||
loading={saving}
|
||||
disabled={saving || !$isDirty}
|
||||
type="submit"
|
||||
/>
|
||||
</div>
|
||||
</StickyCard>
|
||||
<Button
|
||||
text={saving ? 'Saving...' : 'Save'}
|
||||
icon={saving ? Loader2 : Save}
|
||||
iconColor="text-blue-600 dark:text-blue-400"
|
||||
loading={saving}
|
||||
disabled={saving || !$isDirty}
|
||||
type="submit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<CardGrid columns={1} gap="md" flush>
|
||||
<!-- ==================== Interface (UI) ==================== -->
|
||||
<Card>
|
||||
<svelte:fragment slot="header">
|
||||
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">Interface</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Customize the look and feel of the application
|
||||
</p>
|
||||
</svelte:fragment>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-5">
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium text-neutral-900 dark:text-neutral-50">
|
||||
Emoji Icons
|
||||
</span>
|
||||
<Toggle
|
||||
label={uiNavIconStyle === 'emoji' ? 'Enabled' : 'Disabled'}
|
||||
checked={uiNavIconStyle === 'emoji'}
|
||||
fullWidth
|
||||
on:change={(e) => {
|
||||
uiNavIconStyle = e.detail ? 'emoji' : 'lucide';
|
||||
update('ui_nav_icon_style', uiNavIconStyle);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-2">
|
||||
<span class="mb-1 block text-sm font-medium text-neutral-900 dark:text-neutral-50">
|
||||
Alert Position
|
||||
</span>
|
||||
<DropdownSelect
|
||||
value={uiAlertPosition}
|
||||
options={alertPositionOptions}
|
||||
fullWidth
|
||||
fixed
|
||||
on:change={(e) => {
|
||||
uiAlertPosition = e.detail as AlertPosition;
|
||||
update('ui_alert_position', uiAlertPosition);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-2">
|
||||
<label
|
||||
for="ui_alert_duration"
|
||||
class="mb-1 block text-sm font-medium text-neutral-900 dark:text-neutral-50"
|
||||
>
|
||||
Alert Duration (seconds)
|
||||
</label>
|
||||
<NumberInput
|
||||
name="ui_alert_duration"
|
||||
id="ui_alert_duration"
|
||||
value={uiAlertDurationSeconds}
|
||||
min={0}
|
||||
step={1}
|
||||
onchange={(v) => {
|
||||
uiAlertDurationSeconds = v;
|
||||
update('ui_alert_duration_seconds', v);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Alerts -->
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 border-t border-neutral-200 pt-4 dark:border-neutral-700"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-semibold tracking-wide text-neutral-500 uppercase dark:text-neutral-400"
|
||||
>
|
||||
Test
|
||||
</span>
|
||||
<Button
|
||||
text="Success"
|
||||
icon={CheckCircle}
|
||||
iconColor="text-green-600 dark:text-green-400"
|
||||
size="sm"
|
||||
on:click={() => alertStore.add('success', 'Success alert example.')}
|
||||
/>
|
||||
<Button
|
||||
text="Error"
|
||||
icon={XCircle}
|
||||
iconColor="text-red-600 dark:text-red-400"
|
||||
size="sm"
|
||||
on:click={() => alertStore.add('error', 'Error alert example.')}
|
||||
/>
|
||||
<Button
|
||||
text="Warning"
|
||||
icon={AlertTriangle}
|
||||
iconColor="text-yellow-500 dark:text-yellow-400"
|
||||
size="sm"
|
||||
on:click={() => alertStore.add('warning', 'Warning alert example.')}
|
||||
/>
|
||||
<Button
|
||||
text="Info"
|
||||
icon={Info}
|
||||
iconColor="text-blue-600 dark:text-blue-400"
|
||||
size="sm"
|
||||
on:click={() => alertStore.add('info', 'Info alert example.')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- ==================== Arr Instance Defaults ==================== -->
|
||||
<Card>
|
||||
<svelte:fragment slot="header">
|
||||
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">
|
||||
Arr Instance Defaults
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Configure default settings applied when adding new Radarr/Sonarr instances
|
||||
</p>
|
||||
</svelte:fragment>
|
||||
|
||||
<div class="space-y-4">
|
||||
<Toggle
|
||||
label="Apply Default Delay Profile"
|
||||
checked={arrApplyDefaultDelayProfiles}
|
||||
on:change={(e) => {
|
||||
arrApplyDefaultDelayProfiles = e.detail;
|
||||
update('arr_apply_default_delay_profiles', e.detail);
|
||||
}}
|
||||
<div class="space-y-6">
|
||||
<!-- ==================== Interface (UI) ==================== -->
|
||||
<ExpandableCard
|
||||
title="Interface"
|
||||
description="Customize the look and feel of the application"
|
||||
>
|
||||
<svelte:fragment slot="header-right">
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div class="flex items-center gap-1" on:click|stopPropagation>
|
||||
<Button
|
||||
icon={CheckCircle}
|
||||
iconColor="text-green-600 dark:text-green-400"
|
||||
size="xs"
|
||||
on:click={() => alertStore.add('success', 'Success alert example.')}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="arr_apply_default_delay_profiles"
|
||||
value={arrApplyDefaultDelayProfiles ? 'on' : ''}
|
||||
<Button
|
||||
icon={XCircle}
|
||||
iconColor="text-red-600 dark:text-red-400"
|
||||
size="xs"
|
||||
on:click={() => alertStore.add('error', 'Error alert example.')}
|
||||
/>
|
||||
<Button
|
||||
icon={AlertTriangle}
|
||||
iconColor="text-yellow-500 dark:text-yellow-400"
|
||||
size="xs"
|
||||
on:click={() => alertStore.add('warning', 'Warning alert example.')}
|
||||
/>
|
||||
<Button
|
||||
icon={Info}
|
||||
iconColor="text-blue-600 dark:text-blue-400"
|
||||
size="xs"
|
||||
on:click={() => alertStore.add('info', 'Info alert example.')}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- ==================== Backup Configuration ==================== -->
|
||||
<Card>
|
||||
<svelte:fragment slot="header">
|
||||
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">Backups</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Configure automatic backups, schedule, and retention policy
|
||||
</p>
|
||||
</svelte:fragment>
|
||||
|
||||
<div class="grid gap-4" class:sm:grid-cols-5={backupEnabled}>
|
||||
</svelte:fragment>
|
||||
<div class="px-6 py-4">
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-5">
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium text-neutral-900 dark:text-neutral-50">
|
||||
Automatic Backups
|
||||
Emoji Icons
|
||||
</span>
|
||||
<Toggle
|
||||
label={backupEnabled ? 'Enabled' : 'Disabled'}
|
||||
checked={backupEnabled}
|
||||
fullWidth={backupEnabled}
|
||||
label={uiNavIconStyle === 'emoji' ? 'Enabled' : 'Disabled'}
|
||||
checked={uiNavIconStyle === 'emoji'}
|
||||
fullWidth
|
||||
on:change={(e) => {
|
||||
backupEnabled = e.detail;
|
||||
update('backup_enabled', e.detail);
|
||||
uiNavIconStyle = e.detail ? 'emoji' : 'lucide';
|
||||
update('ui_nav_icon_style', uiNavIconStyle);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<input type="hidden" name="backup_enabled" value={backupEnabled ? 'on' : ''} />
|
||||
|
||||
{#if backupEnabled}
|
||||
<div class="sm:col-span-2">
|
||||
<div class="sm:col-span-2">
|
||||
<span class="mb-1 block text-sm font-medium text-neutral-900 dark:text-neutral-50">
|
||||
Alert Position
|
||||
</span>
|
||||
<DropdownSelect
|
||||
value={uiAlertPosition}
|
||||
options={alertPositionOptions}
|
||||
fullWidth
|
||||
fixed
|
||||
on:change={(e) => {
|
||||
uiAlertPosition = e.detail as AlertPosition;
|
||||
update('ui_alert_position', uiAlertPosition);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-2">
|
||||
<label
|
||||
for="ui_alert_duration"
|
||||
class="mb-1 block text-sm font-medium text-neutral-900 dark:text-neutral-50"
|
||||
>
|
||||
Alert Duration (seconds)
|
||||
</label>
|
||||
<NumberInput
|
||||
name="ui_alert_duration"
|
||||
id="ui_alert_duration"
|
||||
value={uiAlertDurationSeconds}
|
||||
min={0}
|
||||
step={1}
|
||||
onchange={(v) => {
|
||||
uiAlertDurationSeconds = v;
|
||||
update('ui_alert_duration_seconds', v);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ExpandableCard>
|
||||
|
||||
<!-- ==================== Arr Instance Defaults ==================== -->
|
||||
<ExpandableCard
|
||||
title="Arr Instance Defaults"
|
||||
description="Configure default settings applied when adding new Radarr/Sonarr instances"
|
||||
>
|
||||
<div class="px-6 py-4">
|
||||
<Toggle
|
||||
label="Apply Default Delay Profile"
|
||||
checked={arrApplyDefaultDelayProfiles}
|
||||
on:change={(e) => {
|
||||
arrApplyDefaultDelayProfiles = e.detail;
|
||||
update('arr_apply_default_delay_profiles', e.detail);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="arr_apply_default_delay_profiles"
|
||||
value={arrApplyDefaultDelayProfiles ? 'on' : ''}
|
||||
/>
|
||||
</div>
|
||||
</ExpandableCard>
|
||||
|
||||
<!-- ==================== Backup Configuration ==================== -->
|
||||
<ExpandableCard
|
||||
title="Backups"
|
||||
description="Configure automatic backups, schedule, and retention policy"
|
||||
>
|
||||
<div class="grid gap-4 px-6 py-4" class:sm:grid-cols-5={backupEnabled}>
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium text-neutral-900 dark:text-neutral-50">
|
||||
Automatic Backups
|
||||
</span>
|
||||
<Toggle
|
||||
label={backupEnabled ? 'Enabled' : 'Disabled'}
|
||||
checked={backupEnabled}
|
||||
fullWidth={backupEnabled}
|
||||
on:change={(e) => {
|
||||
backupEnabled = e.detail;
|
||||
update('backup_enabled', e.detail);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<input type="hidden" name="backup_enabled" value={backupEnabled ? 'on' : ''} />
|
||||
|
||||
{#if backupEnabled}
|
||||
<div class="sm:col-span-2">
|
||||
<span class="mb-1 block text-sm font-medium text-neutral-900 dark:text-neutral-50">
|
||||
Schedule
|
||||
</span>
|
||||
<DropdownSelect
|
||||
value={backupSchedule}
|
||||
options={backupScheduleOptions}
|
||||
fullWidth
|
||||
fixed
|
||||
on:change={(e) => {
|
||||
backupSchedule = e.detail;
|
||||
update('backup_schedule', e.detail);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-2">
|
||||
<label
|
||||
for="backup_retention_days"
|
||||
class="mb-1 block text-sm font-medium text-neutral-900 dark:text-neutral-50"
|
||||
>
|
||||
Retention Period (days)
|
||||
</label>
|
||||
<NumberInput
|
||||
name="backup_retention_days"
|
||||
id="backup_retention_days"
|
||||
value={backupRetentionDays}
|
||||
min={1}
|
||||
max={365}
|
||||
onchange={(v) => {
|
||||
backupRetentionDays = v;
|
||||
update('backup_retention_days', v);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<input type="hidden" name="backup_schedule" value={backupSchedule} />
|
||||
</div>
|
||||
</ExpandableCard>
|
||||
|
||||
<!-- ==================== Logging Configuration ==================== -->
|
||||
<ExpandableCard
|
||||
title="Logging"
|
||||
description="Configure application logs, rotation, and retention"
|
||||
>
|
||||
<svelte:fragment slot="header-right">
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div on:click|stopPropagation>
|
||||
<Button text="Reset" icon={RotateCcw} size="xs" on:click={resetLoggingDefaults} />
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
<div class="space-y-4 px-6 py-4">
|
||||
<div class="grid gap-4" class:sm:grid-cols-7={logEnabled}>
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium text-neutral-900 dark:text-neutral-50">
|
||||
Logging
|
||||
</span>
|
||||
<Toggle
|
||||
label={logEnabled ? 'Enabled' : 'Disabled'}
|
||||
checked={logEnabled}
|
||||
fullWidth={logEnabled}
|
||||
on:change={(e) => {
|
||||
logEnabled = e.detail;
|
||||
update('log_enabled', e.detail);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<input type="hidden" name="log_enabled" value={logEnabled ? 'on' : ''} />
|
||||
|
||||
{#if logEnabled}
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium text-neutral-900 dark:text-neutral-50">
|
||||
Schedule
|
||||
File Logging
|
||||
</span>
|
||||
<DropdownSelect
|
||||
value={backupSchedule}
|
||||
options={backupScheduleOptions}
|
||||
<Toggle
|
||||
label={logFileLogging ? 'Enabled' : 'Disabled'}
|
||||
checked={logFileLogging}
|
||||
fullWidth
|
||||
fixed
|
||||
on:change={(e) => {
|
||||
backupSchedule = e.detail;
|
||||
update('backup_schedule', e.detail);
|
||||
logFileLogging = e.detail;
|
||||
update('log_file_logging', e.detail);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium text-neutral-900 dark:text-neutral-50">
|
||||
Console Logging
|
||||
</span>
|
||||
<Toggle
|
||||
label={logConsoleLogging ? 'Enabled' : 'Disabled'}
|
||||
checked={logConsoleLogging}
|
||||
fullWidth
|
||||
on:change={(e) => {
|
||||
logConsoleLogging = e.detail;
|
||||
update('log_console_logging', e.detail);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-2">
|
||||
<span class="mb-1 block text-sm font-medium text-neutral-900 dark:text-neutral-50">
|
||||
Minimum Level
|
||||
</span>
|
||||
<div class="font-mono">
|
||||
<DropdownSelect
|
||||
value={logMinLevel}
|
||||
options={logLevelOptions}
|
||||
fullWidth
|
||||
fixed
|
||||
on:change={(e) => {
|
||||
logMinLevel = e.detail as 'DEBUG' | 'INFO' | 'WARN' | 'ERROR';
|
||||
update('log_min_level', logMinLevel);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-2">
|
||||
<label
|
||||
for="backup_retention_days"
|
||||
for="log_retention_days"
|
||||
class="mb-1 block text-sm font-medium text-neutral-900 dark:text-neutral-50"
|
||||
>
|
||||
Retention Period (days)
|
||||
</label>
|
||||
<NumberInput
|
||||
name="backup_retention_days"
|
||||
id="backup_retention_days"
|
||||
value={backupRetentionDays}
|
||||
name="log_retention_days"
|
||||
id="log_retention_days"
|
||||
value={logRetentionDays}
|
||||
min={1}
|
||||
max={365}
|
||||
onchange={(v) => {
|
||||
backupRetentionDays = v;
|
||||
update('backup_retention_days', v);
|
||||
logRetentionDays = v;
|
||||
update('log_retention_days', v);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<input type="hidden" name="backup_schedule" value={backupSchedule} />
|
||||
</div>
|
||||
</Card>
|
||||
<input type="hidden" name="log_file_logging" value={logFileLogging ? 'on' : ''} />
|
||||
<input type="hidden" name="log_console_logging" value={logConsoleLogging ? 'on' : ''} />
|
||||
<input type="hidden" name="log_min_level" value={logMinLevel} />
|
||||
</div>
|
||||
</ExpandableCard>
|
||||
|
||||
<!-- ==================== Logging Configuration ==================== -->
|
||||
<Card>
|
||||
<svelte:fragment slot="header">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">Logging</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Configure application logs, rotation, and retention
|
||||
</p>
|
||||
</div>
|
||||
<Button text="Reset" icon={RotateCcw} size="xs" on:click={resetLoggingDefaults} />
|
||||
<!-- ==================== TMDB Configuration ==================== -->
|
||||
<ExpandableCard
|
||||
title="TMDB Configuration"
|
||||
description="Configure TMDB API access for searching movies and TV series"
|
||||
>
|
||||
<svelte:fragment slot="header-right">
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div class="flex items-center gap-2" on:click|stopPropagation>
|
||||
<Button
|
||||
text="Test"
|
||||
icon={tmdbTesting ? Loader2 : FlaskConical}
|
||||
loading={tmdbTesting}
|
||||
disabled={tmdbTesting}
|
||||
size="xs"
|
||||
on:click={testTMDBConnection}
|
||||
/>
|
||||
<Button text="Reset" icon={RotateCcw} size="xs" on:click={resetTMDBDefaults} />
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
<div class="space-y-4 px-6 py-4">
|
||||
<div class="relative">
|
||||
<FormInput
|
||||
label="API Read Access Token"
|
||||
name="tmdb_api_key"
|
||||
value={tmdbApiKey}
|
||||
type={tmdbShowKey ? 'text' : 'password'}
|
||||
mono
|
||||
placeholder={data.tmdbSettings.hasApiKey ? '••••••••••••••••' : ''}
|
||||
description={data.tmdbSettings.hasApiKey
|
||||
? 'Leave blank to keep existing key'
|
||||
: 'Use the API Read Access Token (not API Key) from themoviedb.org'}
|
||||
on:input={(e) => {
|
||||
tmdbApiKey = e.detail;
|
||||
update('tmdb_api_key', e.detail);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
slot="suffix"
|
||||
type="button"
|
||||
on:click={() => (tmdbShowKey = !tmdbShowKey)}
|
||||
class="p-1 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
|
||||
>
|
||||
{#if tmdbShowKey}
|
||||
<EyeOff size={16} />
|
||||
{:else}
|
||||
<Eye size={16} />
|
||||
{/if}
|
||||
</button>
|
||||
</FormInput>
|
||||
</div>
|
||||
</div>
|
||||
</ExpandableCard>
|
||||
|
||||
<!-- ==================== AI Configuration (feature-flagged) ==================== -->
|
||||
{#if FEATURES.ai}
|
||||
<ExpandableCard
|
||||
title="AI Configuration"
|
||||
description="Configure AI-powered features. Works with OpenAI, Ollama, LM Studio, or any OpenAI-compatible API."
|
||||
>
|
||||
<svelte:fragment slot="header-right">
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div on:click|stopPropagation>
|
||||
<Button text="Reset" icon={RotateCcw} size="xs" on:click={resetAIDefaults} />
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
<div class="space-y-4 px-6 py-4">
|
||||
<Toggle
|
||||
label="Enable AI Features"
|
||||
checked={aiEnabled}
|
||||
on:change={(e) => {
|
||||
aiEnabled = e.detail;
|
||||
update('ai_enabled', e.detail);
|
||||
}}
|
||||
/>
|
||||
<input type="hidden" name="ai_enabled" value={aiEnabled ? 'on' : ''} />
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-4" class:sm:grid-cols-7={logEnabled}>
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium text-neutral-900 dark:text-neutral-50">
|
||||
Logging
|
||||
</span>
|
||||
<Toggle
|
||||
label={logEnabled ? 'Enabled' : 'Disabled'}
|
||||
checked={logEnabled}
|
||||
fullWidth={logEnabled}
|
||||
on:change={(e) => {
|
||||
logEnabled = e.detail;
|
||||
update('log_enabled', e.detail);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<input type="hidden" name="log_enabled" value={logEnabled ? 'on' : ''} />
|
||||
|
||||
{#if logEnabled}
|
||||
<div>
|
||||
<span
|
||||
class="mb-1 block text-sm font-medium text-neutral-900 dark:text-neutral-50"
|
||||
>
|
||||
File Logging
|
||||
</span>
|
||||
<Toggle
|
||||
label={logFileLogging ? 'Enabled' : 'Disabled'}
|
||||
checked={logFileLogging}
|
||||
fullWidth
|
||||
on:change={(e) => {
|
||||
logFileLogging = e.detail;
|
||||
update('log_file_logging', e.detail);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span
|
||||
class="mb-1 block text-sm font-medium text-neutral-900 dark:text-neutral-50"
|
||||
>
|
||||
Console Logging
|
||||
</span>
|
||||
<Toggle
|
||||
label={logConsoleLogging ? 'Enabled' : 'Disabled'}
|
||||
checked={logConsoleLogging}
|
||||
fullWidth
|
||||
on:change={(e) => {
|
||||
logConsoleLogging = e.detail;
|
||||
update('log_console_logging', e.detail);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-2">
|
||||
<span
|
||||
class="mb-1 block text-sm font-medium text-neutral-900 dark:text-neutral-50"
|
||||
>
|
||||
Minimum Level
|
||||
</span>
|
||||
<div class="font-mono">
|
||||
<DropdownSelect
|
||||
value={logMinLevel}
|
||||
options={logLevelOptions}
|
||||
fullWidth
|
||||
fixed
|
||||
on:change={(e) => {
|
||||
logMinLevel = e.detail as 'DEBUG' | 'INFO' | 'WARN' | 'ERROR';
|
||||
update('log_min_level', logMinLevel);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-2">
|
||||
<label
|
||||
for="log_retention_days"
|
||||
class="mb-1 block text-sm font-medium text-neutral-900 dark:text-neutral-50"
|
||||
>
|
||||
Retention Period (days)
|
||||
</label>
|
||||
<NumberInput
|
||||
name="log_retention_days"
|
||||
id="log_retention_days"
|
||||
value={logRetentionDays}
|
||||
min={1}
|
||||
max={365}
|
||||
onchange={(v) => {
|
||||
logRetentionDays = v;
|
||||
update('log_retention_days', v);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<input type="hidden" name="log_file_logging" value={logFileLogging ? 'on' : ''} />
|
||||
<input type="hidden" name="log_console_logging" value={logConsoleLogging ? 'on' : ''} />
|
||||
<input type="hidden" name="log_min_level" value={logMinLevel} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- ==================== TMDB Configuration ==================== -->
|
||||
<Card>
|
||||
<svelte:fragment slot="header">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">
|
||||
TMDB Configuration
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Configure TMDB API access for searching movies and TV series
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
text="Test"
|
||||
icon={tmdbTesting ? Loader2 : FlaskConical}
|
||||
loading={tmdbTesting}
|
||||
disabled={tmdbTesting}
|
||||
size="xs"
|
||||
on:click={testTMDBConnection}
|
||||
/>
|
||||
<Button text="Reset" icon={RotateCcw} size="xs" on:click={resetTMDBDefaults} />
|
||||
</div>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="relative">
|
||||
{#if aiEnabled}
|
||||
<FormInput
|
||||
label="API Read Access Token"
|
||||
name="tmdb_api_key"
|
||||
value={tmdbApiKey}
|
||||
type={tmdbShowKey ? 'text' : 'password'}
|
||||
label="API URL"
|
||||
name="ai_api_url"
|
||||
value={aiApiUrl}
|
||||
type="url"
|
||||
mono
|
||||
placeholder={data.tmdbSettings.hasApiKey ? '••••••••••••••••' : ''}
|
||||
description={data.tmdbSettings.hasApiKey
|
||||
? 'Leave blank to keep existing key'
|
||||
: 'Use the API Read Access Token (not API Key) from themoviedb.org'}
|
||||
description="OpenAI-compatible endpoint (e.g., Ollama: http://localhost:11434/v1)"
|
||||
on:input={(e) => {
|
||||
tmdbApiKey = e.detail;
|
||||
update('tmdb_api_key', e.detail);
|
||||
aiApiUrl = e.detail;
|
||||
update('ai_api_url', e.detail);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormInput
|
||||
label="API Key"
|
||||
name="ai_api_key"
|
||||
value={aiApiKey}
|
||||
type={aiShowKey ? 'text' : 'password'}
|
||||
mono
|
||||
placeholder={data.aiSettings.hasApiKey ? '••••••••••••••••' : ''}
|
||||
description={data.aiSettings.hasApiKey
|
||||
? 'Leave blank to keep existing key'
|
||||
: 'Required for cloud providers. Leave empty for local APIs.'}
|
||||
on:input={(e) => {
|
||||
aiApiKey = e.detail;
|
||||
update('ai_api_key', e.detail);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
slot="suffix"
|
||||
type="button"
|
||||
on:click={() => (tmdbShowKey = !tmdbShowKey)}
|
||||
on:click={() => (aiShowKey = !aiShowKey)}
|
||||
class="p-1 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
|
||||
>
|
||||
{#if tmdbShowKey}
|
||||
{#if aiShowKey}
|
||||
<EyeOff size={16} />
|
||||
{:else}
|
||||
<Eye size={16} />
|
||||
{/if}
|
||||
</button>
|
||||
</FormInput>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- ==================== AI Configuration (feature-flagged) ==================== -->
|
||||
{#if FEATURES.ai}
|
||||
<Card>
|
||||
<svelte:fragment slot="header">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">
|
||||
AI Configuration
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Configure AI-powered features. Works with OpenAI, Ollama, LM Studio, or any
|
||||
OpenAI-compatible API.
|
||||
</p>
|
||||
</div>
|
||||
<Button text="Reset" icon={RotateCcw} size="xs" on:click={resetAIDefaults} />
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
|
||||
<div class="space-y-4">
|
||||
<Toggle
|
||||
label="Enable AI Features"
|
||||
checked={aiEnabled}
|
||||
on:change={(e) => {
|
||||
aiEnabled = e.detail;
|
||||
update('ai_enabled', e.detail);
|
||||
<FormInput
|
||||
label="Model"
|
||||
name="ai_model"
|
||||
value={aiModel}
|
||||
mono
|
||||
description="e.g., gpt-4o-mini, llama3.2, claude-3-haiku"
|
||||
on:input={(e) => {
|
||||
aiModel = e.detail;
|
||||
update('ai_model', e.detail);
|
||||
}}
|
||||
/>
|
||||
<input type="hidden" name="ai_enabled" value={aiEnabled ? 'on' : ''} />
|
||||
|
||||
{#if aiEnabled}
|
||||
<FormInput
|
||||
label="API URL"
|
||||
name="ai_api_url"
|
||||
value={aiApiUrl}
|
||||
type="url"
|
||||
mono
|
||||
description="OpenAI-compatible endpoint (e.g., Ollama: http://localhost:11434/v1)"
|
||||
on:input={(e) => {
|
||||
aiApiUrl = e.detail;
|
||||
update('ai_api_url', e.detail);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormInput
|
||||
label="API Key"
|
||||
name="ai_api_key"
|
||||
value={aiApiKey}
|
||||
type={aiShowKey ? 'text' : 'password'}
|
||||
mono
|
||||
placeholder={data.aiSettings.hasApiKey ? '••••••••••••••••' : ''}
|
||||
description={data.aiSettings.hasApiKey
|
||||
? 'Leave blank to keep existing key'
|
||||
: 'Required for cloud providers. Leave empty for local APIs.'}
|
||||
on:input={(e) => {
|
||||
aiApiKey = e.detail;
|
||||
update('ai_api_key', e.detail);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
slot="suffix"
|
||||
type="button"
|
||||
on:click={() => (aiShowKey = !aiShowKey)}
|
||||
class="p-1 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
|
||||
>
|
||||
{#if aiShowKey}
|
||||
<EyeOff size={16} />
|
||||
{:else}
|
||||
<Eye size={16} />
|
||||
{/if}
|
||||
</button>
|
||||
</FormInput>
|
||||
|
||||
<FormInput
|
||||
label="Model"
|
||||
name="ai_model"
|
||||
value={aiModel}
|
||||
mono
|
||||
description="e.g., gpt-4o-mini, llama3.2, claude-3-haiku"
|
||||
on:input={(e) => {
|
||||
aiModel = e.detail;
|
||||
update('ai_model', e.detail);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
</CardGrid>
|
||||
{/if}
|
||||
</div>
|
||||
</ExpandableCard>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DirtyModal />
|
||||
</form>
|
||||
|
||||
@@ -34,7 +34,8 @@ const config = {
|
||||
$notifications: './src/lib/server/notifications',
|
||||
$cache: './src/lib/server/utils/cache',
|
||||
$sync: './src/lib/server/sync',
|
||||
$auth: './src/lib/server/utils/auth'
|
||||
$auth: './src/lib/server/utils/auth',
|
||||
$cutscene: './src/lib/client/cutscene'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user