feat: enable onboarding flag, add cutscene stages for databases and arr instances (#407)

This commit is contained in:
santiagosayshey
2026-04-07 05:11:11 +09:30
committed by GitHub
parent 7d3ddf76f6
commit 5cc142a12f
57 changed files with 1713 additions and 1154 deletions

View File

@@ -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",

View File

@@ -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.

View File

@@ -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

View File

@@ -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>

View File

@@ -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}
/>

View 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>

View File

@@ -12,7 +12,7 @@
function start(): void {
cutscene.dismiss();
cutscene.startPipeline('getting-started', false);
cutscene.startStage('welcome', false);
}
function skip(): void {

View File

@@ -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']
}
];

View File

@@ -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']
};

View 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' }
}
]
};

View 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' }
}
]
};

View 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' }
}
]
};

View 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' }
}
]
};

View 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' }
}
]
};

View File

@@ -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' }
}
]
};

View File

@@ -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' }
}
]
};

View File

@@ -1,4 +1,4 @@
import type { Stage } from '../../types.ts';
import type { Stage } from '$cutscene/types.ts';
export const helpStage: Stage = {
id: 'help',

View File

@@ -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' }
}
]
};

View File

@@ -1,4 +1,4 @@
import type { Stage } from '../../types.ts';
import type { Stage } from '$cutscene/types.ts';
export const personalizeStage: Stage = {
id: 'personalize',

View File

@@ -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' }
}
]
};

View File

@@ -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' }
}
]
};

View 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 };
}

View 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`;
}
};

View File

@@ -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;
}
};

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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));
}

View File

@@ -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}

View 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>

View File

@@ -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>

View File

@@ -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 />

View File

@@ -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 }}>

View File

@@ -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'
: ''}"

View File

@@ -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"

View File

@@ -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'}"

View File

@@ -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>

View File

@@ -9,5 +9,5 @@ export const FEATURES = {
/** AI-powered commit message generation */
ai: false,
/** Cutscene onboarding system */
cutscene: false
cutscene: true
} as const;

View File

@@ -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}

View File

@@ -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>

View File

@@ -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'
}
];

View File

@@ -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
>

View File

@@ -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>

View File

@@ -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 -->

View File

@@ -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}

View File

@@ -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 -->

View File

@@ -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}

View File

@@ -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"
>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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'
}
]
: [];

View File

@@ -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}

View File

@@ -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>

View File

@@ -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',

View File

@@ -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>

View File

@@ -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'
}
}
};