feat(dirty-tracking): implement dirty tracking and unsaved changes modal for media settings and naming sections

This commit is contained in:
Sam Chau
2026-01-03 00:39:11 +10:30
parent d8b650a145
commit fc2211c146
5 changed files with 208 additions and 15 deletions

View File

@@ -16,8 +16,9 @@
export let arrType: 'radarr' | 'sonarr';
export let canWriteToBase: boolean = false;
// Edit mode state
let isEditing = false;
// Edit mode state (exported for parent dirty tracking)
export let isEditing = false;
export let hasChanges = false;
let isSaving = false;
// Layer selection
@@ -29,6 +30,15 @@
let formPropersRepacks: PropersRepacks = settings?.propers_repacks ?? 'doNotPrefer';
let formEnableMediaInfo: boolean = settings?.enable_media_info ?? true;
// Original values for dirty tracking
let originalPropersRepacks: PropersRepacks = 'doNotPrefer';
let originalEnableMediaInfo: boolean = true;
// Dirty tracking
$: hasChanges =
formPropersRepacks !== originalPropersRepacks ||
formEnableMediaInfo !== originalEnableMediaInfo;
// Reset form to current settings
function resetForm() {
formPropersRepacks = settings?.propers_repacks ?? 'doNotPrefer';
@@ -37,6 +47,9 @@
function startEditing() {
resetForm();
// Save original values for dirty tracking
originalPropersRepacks = settings?.propers_repacks ?? 'doNotPrefer';
originalEnableMediaInfo = settings?.enable_media_info ?? true;
isEditing = true;
}
@@ -187,8 +200,8 @@
<button
type="button"
on:click={handleSaveClick}
disabled={isSaving}
class="flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-700 disabled:opacity-50 dark:bg-accent-500 dark:hover:bg-accent-600"
disabled={isSaving || !hasChanges}
class="flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-accent-500 dark:hover:bg-accent-600"
>
{#if isSaving}
<Loader2 size={14} class="animate-spin" />

View File

@@ -25,8 +25,9 @@
export let arrType: 'radarr' | 'sonarr';
export let canWriteToBase: boolean = false;
// Edit mode state
let isEditing = false;
// Edit mode state (exported for parent dirty tracking)
export let isEditing = false;
export let hasChanges = false;
let isSaving = false;
// Layer selection
@@ -60,6 +61,41 @@
let formMovieFormat: string = '';
let formMovieFolderFormat: string = '';
// Original values for dirty tracking
let originalRename: boolean = false;
let originalReplaceIllegalCharacters: boolean = false;
let originalColonReplacement: ColonReplacementFormat = 'delete';
let originalCustomColonReplacement: string = '';
let originalStandardEpisodeFormat: string = '';
let originalDailyEpisodeFormat: string = '';
let originalAnimeEpisodeFormat: string = '';
let originalSeriesFolderFormat: string = '';
let originalSeasonFolderFormat: string = '';
let originalMultiEpisodeStyle: MultiEpisodeStyle = 'extend';
let originalRadarrColonReplacement: RadarrColonReplacementFormat = 'delete';
let originalMovieFormat: string = '';
let originalMovieFolderFormat: string = '';
// Dirty tracking
$: hasChanges = naming
? isSonarrNaming(naming)
? formRename !== originalRename ||
formReplaceIllegalCharacters !== originalReplaceIllegalCharacters ||
formColonReplacement !== originalColonReplacement ||
formCustomColonReplacement !== originalCustomColonReplacement ||
formStandardEpisodeFormat !== originalStandardEpisodeFormat ||
formDailyEpisodeFormat !== originalDailyEpisodeFormat ||
formAnimeEpisodeFormat !== originalAnimeEpisodeFormat ||
formSeriesFolderFormat !== originalSeriesFolderFormat ||
formSeasonFolderFormat !== originalSeasonFolderFormat ||
formMultiEpisodeStyle !== originalMultiEpisodeStyle
: formRename !== originalRename ||
formReplaceIllegalCharacters !== originalReplaceIllegalCharacters ||
formRadarrColonReplacement !== originalRadarrColonReplacement ||
formMovieFormat !== originalMovieFormat ||
formMovieFolderFormat !== originalMovieFolderFormat
: false;
// Reset form to current settings
function resetForm() {
if (naming && isSonarrNaming(naming)) {
@@ -84,6 +120,25 @@
function startEditing() {
resetForm();
// Save original values for dirty tracking
if (naming && isSonarrNaming(naming)) {
originalRename = naming.rename;
originalReplaceIllegalCharacters = naming.replace_illegal_characters;
originalColonReplacement = naming.colon_replacement_format;
originalCustomColonReplacement = naming.custom_colon_replacement_format ?? '';
originalStandardEpisodeFormat = naming.standard_episode_format;
originalDailyEpisodeFormat = naming.daily_episode_format;
originalAnimeEpisodeFormat = naming.anime_episode_format;
originalSeriesFolderFormat = naming.series_folder_format;
originalSeasonFolderFormat = naming.season_folder_format;
originalMultiEpisodeStyle = naming.multi_episode_style;
} else if (naming && isRadarrNaming(naming)) {
originalRename = naming.rename;
originalReplaceIllegalCharacters = naming.replace_illegal_characters;
originalRadarrColonReplacement = naming.colon_replacement_format;
originalMovieFormat = naming.movie_format;
originalMovieFolderFormat = naming.movie_folder_format;
}
isEditing = true;
}
@@ -359,8 +414,8 @@
<button
type="button"
on:click={handleSaveClick}
disabled={isSaving}
class="flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-700 disabled:opacity-50 dark:bg-accent-500 dark:hover:bg-accent-600"
disabled={isSaving || !hasChanges}
class="flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-accent-500 dark:hover:bg-accent-600"
>
{#if isSaving}
<Loader2 size={14} class="animate-spin" />
@@ -512,8 +567,8 @@
<button
type="button"
on:click={handleSaveClick}
disabled={isSaving}
class="flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-700 disabled:opacity-50 dark:bg-accent-500 dark:hover:bg-accent-600"
disabled={isSaving || !hasChanges}
class="flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-accent-500 dark:hover:bg-accent-600"
>
{#if isSaving}
<Loader2 size={14} class="animate-spin" />

View File

@@ -23,8 +23,9 @@
export let arrType: 'radarr' | 'sonarr';
export let canWriteToBase: boolean = false;
// Edit mode state
let isEditing = false;
// Edit mode state (exported for parent dirty tracking)
export let isEditing = false;
export let hasChanges = false;
let isSaving = false;
// Shared expanded state between read-only and edit mode tables
@@ -365,7 +366,7 @@
min={0}
max={markers[1].value}
step={1}
on:input={() => syncToDefinition(def.quality_id)}
onchange={() => syncToDefinition(def.quality_id)}
/>
</div>
@@ -382,7 +383,7 @@
min={markers[0].value}
max={markers[2].value}
step={1}
on:input={() => syncToDefinition(def.quality_id)}
onchange={() => syncToDefinition(def.quality_id)}
/>
</div>
@@ -398,7 +399,7 @@
bind:value={markers[2].value}
min={markers[1].value}
step={1}
on:input={() => syncToDefinition(def.quality_id)}
onchange={() => syncToDefinition(def.quality_id)}
/>
</div>
</div>

View File

@@ -1,7 +1,9 @@
<script lang="ts">
import { beforeNavigate, goto } from '$app/navigation';
import QualityDefinitionsSection from '../components/QualityDefinitionsSection.svelte';
import NamingSection from '../components/NamingSection.svelte';
import MediaSettingsSection from '../components/MediaSettingsSection.svelte';
import Modal from '$ui/modal/Modal.svelte';
import type { PageData } from './$types';
export let data: PageData;
@@ -10,6 +12,49 @@
data.mediaManagement.qualityDefinitions.length > 0 ||
data.mediaManagement.naming !== null ||
data.mediaManagement.mediaSettings !== null;
// Dirty tracking state from child sections
let qualityIsEditing = false;
let qualityHasChanges = false;
let namingIsEditing = false;
let namingHasChanges = false;
let mediaIsEditing = false;
let mediaHasChanges = false;
// Aggregate dirty state
$: pageIsDirty =
(qualityIsEditing && qualityHasChanges) ||
(namingIsEditing && namingHasChanges) ||
(mediaIsEditing && mediaHasChanges);
// Navigation blocking
let showDirtyModal = false;
let pendingNavigationUrl: string | null = null;
beforeNavigate((navigation) => {
if (pageIsDirty) {
navigation.cancel();
pendingNavigationUrl = navigation.to?.url.pathname || null;
showDirtyModal = true;
}
});
function handleDiscardChanges() {
showDirtyModal = false;
if (pendingNavigationUrl) {
// Reset all editing states before navigating
qualityIsEditing = false;
namingIsEditing = false;
mediaIsEditing = false;
goto(pendingNavigationUrl);
}
pendingNavigationUrl = null;
}
function handleStayOnPage() {
showDirtyModal = false;
pendingNavigationUrl = null;
}
</script>
<div class="space-y-8">
@@ -26,18 +71,35 @@
definitions={data.mediaManagement.qualityDefinitions}
arrType="radarr"
canWriteToBase={data.canWriteToBase}
bind:isEditing={qualityIsEditing}
bind:hasChanges={qualityHasChanges}
/>
<NamingSection
naming={data.mediaManagement.naming}
arrType="radarr"
canWriteToBase={data.canWriteToBase}
bind:isEditing={namingIsEditing}
bind:hasChanges={namingHasChanges}
/>
<MediaSettingsSection
settings={data.mediaManagement.mediaSettings}
arrType="radarr"
canWriteToBase={data.canWriteToBase}
bind:isEditing={mediaIsEditing}
bind:hasChanges={mediaHasChanges}
/>
{/if}
</div>
<Modal
open={showDirtyModal}
header="Unsaved Changes"
bodyMessage="You have unsaved changes. Are you sure you want to leave this page? Your changes will be lost."
confirmText="Discard Changes"
cancelText="Stay on Page"
confirmDanger={true}
on:confirm={handleDiscardChanges}
on:cancel={handleStayOnPage}
/>

View File

@@ -1,7 +1,9 @@
<script lang="ts">
import { beforeNavigate, goto } from '$app/navigation';
import QualityDefinitionsSection from '../components/QualityDefinitionsSection.svelte';
import NamingSection from '../components/NamingSection.svelte';
import MediaSettingsSection from '../components/MediaSettingsSection.svelte';
import Modal from '$ui/modal/Modal.svelte';
import type { PageData } from './$types';
export let data: PageData;
@@ -10,6 +12,49 @@
data.mediaManagement.qualityDefinitions.length > 0 ||
data.mediaManagement.naming !== null ||
data.mediaManagement.mediaSettings !== null;
// Dirty tracking state from child sections
let qualityIsEditing = false;
let qualityHasChanges = false;
let namingIsEditing = false;
let namingHasChanges = false;
let mediaIsEditing = false;
let mediaHasChanges = false;
// Aggregate dirty state
$: pageIsDirty =
(qualityIsEditing && qualityHasChanges) ||
(namingIsEditing && namingHasChanges) ||
(mediaIsEditing && mediaHasChanges);
// Navigation blocking
let showDirtyModal = false;
let pendingNavigationUrl: string | null = null;
beforeNavigate((navigation) => {
if (pageIsDirty) {
navigation.cancel();
pendingNavigationUrl = navigation.to?.url.pathname || null;
showDirtyModal = true;
}
});
function handleDiscardChanges() {
showDirtyModal = false;
if (pendingNavigationUrl) {
// Reset all editing states before navigating
qualityIsEditing = false;
namingIsEditing = false;
mediaIsEditing = false;
goto(pendingNavigationUrl);
}
pendingNavigationUrl = null;
}
function handleStayOnPage() {
showDirtyModal = false;
pendingNavigationUrl = null;
}
</script>
<div class="space-y-8">
@@ -26,18 +71,35 @@
definitions={data.mediaManagement.qualityDefinitions}
arrType="sonarr"
canWriteToBase={data.canWriteToBase}
bind:isEditing={qualityIsEditing}
bind:hasChanges={qualityHasChanges}
/>
<NamingSection
naming={data.mediaManagement.naming}
arrType="sonarr"
canWriteToBase={data.canWriteToBase}
bind:isEditing={namingIsEditing}
bind:hasChanges={namingHasChanges}
/>
<MediaSettingsSection
settings={data.mediaManagement.mediaSettings}
arrType="sonarr"
canWriteToBase={data.canWriteToBase}
bind:isEditing={mediaIsEditing}
bind:hasChanges={mediaHasChanges}
/>
{/if}
</div>
<Modal
open={showDirtyModal}
header="Unsaved Changes"
bodyMessage="You have unsaved changes. Are you sure you want to leave this page? Your changes will be lost."
confirmText="Discard Changes"
cancelText="Stay on Page"
confirmDanger={true}
on:confirm={handleDiscardChanges}
on:cancel={handleStayOnPage}
/>