Add save slot carousel and improve empty states

- Add reusable Carousel component with arrow navigation, drag-to-scroll,
  and click-to-scroll-into-view functionality
- Replace static save slot list with horizontal carousel
- Add empty state with image when no save files exist
- Add prompt state when saves exist but none is selected
- Reset selected slot when entering Save Editor page
This commit is contained in:
Christian Semmler 2026-01-31 15:09:42 -08:00
parent aac1b63b7c
commit 88aae6083a
No known key found for this signature in database
GPG Key ID: 086DAA1360BEEE5C
2 changed files with 272 additions and 26 deletions

184
src/lib/Carousel.svelte Normal file
View File

@ -0,0 +1,184 @@
<script>
import { onMount } from 'svelte';
export let gap = 10;
let trackRef;
let canScrollLeft = false;
let canScrollRight = false;
let isDragging = false;
let dragStartX = 0;
let scrollStartLeft = 0;
// Exposed so parent can check if a drag occurred (to prevent click handling)
export let hasDragged = false;
function updateArrows() {
if (!trackRef) return;
const { scrollLeft, scrollWidth, clientWidth } = trackRef;
canScrollLeft = scrollLeft > 0;
canScrollRight = scrollLeft + clientWidth < scrollWidth - 1;
}
function scrollLeft() {
trackRef?.scrollBy({ left: -200, behavior: 'smooth' });
}
function scrollRight() {
trackRef?.scrollBy({ left: 200, behavior: 'smooth' });
}
function handleMouseDown(e) {
if (e.button !== 0) return;
isDragging = true;
hasDragged = false;
dragStartX = e.pageX;
scrollStartLeft = trackRef.scrollLeft;
trackRef.style.scrollBehavior = 'auto';
}
function handleMouseMove(e) {
if (!isDragging) return;
e.preventDefault();
const dx = e.pageX - dragStartX;
if (Math.abs(dx) > 5) {
hasDragged = true;
}
trackRef.scrollLeft = scrollStartLeft - dx;
}
function handleMouseUp() {
if (!isDragging) return;
isDragging = false;
trackRef.style.scrollBehavior = 'smooth';
}
function handleClick(e) {
// Find the direct child element that was clicked
const clickedCard = e.target.closest('.carousel-track > *');
if (!clickedCard || hasDragged) return;
const trackRect = trackRef.getBoundingClientRect();
const cardRect = clickedCard.getBoundingClientRect();
// Check if card is fully visible
const isFullyVisible = cardRect.left >= trackRect.left && cardRect.right <= trackRect.right;
if (!isFullyVisible) {
// Scroll to bring card into view
const scrollLeft = cardRect.left < trackRect.left
? trackRef.scrollLeft - (trackRect.left - cardRect.left)
: trackRef.scrollLeft + (cardRect.right - trackRect.right);
trackRef.scrollTo({ left: scrollLeft, behavior: 'smooth' });
}
}
onMount(() => {
updateArrows();
const resizeObserver = new ResizeObserver(updateArrows);
resizeObserver.observe(trackRef);
return () => resizeObserver.disconnect();
});
</script>
<div class="carousel">
<button
type="button"
class="carousel-arrow carousel-arrow-left"
class:disabled={!canScrollLeft}
onclick={scrollLeft}
aria-label="Scroll left"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M10 12L6 8l4-4" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions -->
<div
class="carousel-track"
class:dragging={isDragging}
style="gap: {gap}px"
bind:this={trackRef}
role="group"
onscroll={updateArrows}
onclick={handleClick}
onmousedown={handleMouseDown}
onmousemove={handleMouseMove}
onmouseup={handleMouseUp}
onmouseleave={handleMouseUp}
>
<slot />
</div>
<button
type="button"
class="carousel-arrow carousel-arrow-right"
class:disabled={!canScrollRight}
onclick={scrollRight}
aria-label="Scroll right"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<style>
.carousel {
display: flex;
align-items: center;
gap: 8px;
contain: inline-size;
}
.carousel-track {
display: flex;
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
flex: 1;
min-width: 0;
}
.carousel-track::-webkit-scrollbar {
display: none;
}
.carousel-track.dragging {
cursor: grabbing;
user-select: none;
}
.carousel-track:not(.dragging) {
cursor: grab;
}
.carousel-arrow {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: var(--gradient-panel);
border: 1px solid var(--color-border-medium);
border-radius: 50%;
color: var(--color-text-light);
cursor: pointer;
flex-shrink: 0;
transition: all 0.2s ease;
}
.carousel-arrow:hover:not(.disabled) {
border-color: var(--color-border-light);
background: var(--gradient-hover);
}
.carousel-arrow.disabled {
opacity: 0.3;
pointer-events: none;
cursor: default;
}
</style>

View File

@ -1,6 +1,7 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import BackButton from './BackButton.svelte'; import BackButton from './BackButton.svelte';
import Carousel from './Carousel.svelte';
import MissionScoresEditor from './save-editor/MissionScoresEditor.svelte'; import MissionScoresEditor from './save-editor/MissionScoresEditor.svelte';
import { saveEditorState, currentPage } from '../stores.js'; import { saveEditorState, currentPage } from '../stores.js';
import { listSaveSlots, updateSaveSlot, updatePlayerName } from '../core/savegame/index.js'; import { listSaveSlots, updateSaveSlot, updatePlayerName } from '../core/savegame/index.js';
@ -20,8 +21,9 @@
{ id: 'scores', label: 'Scores', firstSection: null } { id: 'scores', label: 'Scores', firstSection: null }
]; ];
// Reset to default tab/section when navigating to this page // Reset state when navigating to this page
$: if ($currentPage === 'save-editor') { $: if ($currentPage === 'save-editor') {
selectedSlot = null;
activeTab = 'player'; activeTab = 'player';
openSection = 'name'; openSection = 'name';
} }
@ -43,6 +45,9 @@
[Actor.LAURA]: { normal: 'laura.webp', selected: 'laura-selected.webp' } [Actor.LAURA]: { normal: 'laura.webp', selected: 'laura-selected.webp' }
}; };
// Carousel state (bound from Carousel component)
let carouselHasDragged = false;
onMount(async () => { onMount(async () => {
await loadSlots(); await loadSlots();
}); });
@ -63,6 +68,7 @@
} }
function handleSlotSelect(slotNumber) { function handleSlotSelect(slotNumber) {
if (carouselHasDragged) return;
selectedSlot = slotNumber; selectedSlot = slotNumber;
saveEditorState.update(s => ({ ...s, selectedSlot: slotNumber })); saveEditorState.update(s => ({ ...s, selectedSlot: slotNumber }));
} }
@ -227,13 +233,12 @@
<img src="save.webp" alt="LEGO Island Save Editor"> <img src="save.webp" alt="LEGO Island Save Editor">
</div> </div>
<div class="config-main"> <div class="config-main">
<div class="config-presets"> {#if loading || error || existingSlots.length > 0}
<Carousel bind:hasDragged={carouselHasDragged}>
{#if loading} {#if loading}
<span class="save-status-text">Loading save files...</span> <span class="save-status-text">Loading save files...</span>
{:else if error} {:else if error}
<span class="save-status-text error">{error}</span> <span class="save-status-text error">{error}</span>
{:else if existingSlots.length === 0}
<span class="save-status-text">No save files found</span>
{:else} {:else}
{#each existingSlots as slot} {#each existingSlots as slot}
<button <button
@ -246,12 +251,32 @@
src={characterIcons[slot.header?.actorId]?.selected || 'pepper-selected.webp'} src={characterIcons[slot.header?.actorId]?.selected || 'pepper-selected.webp'}
alt={ActorNames[slot.header?.actorId] || 'Character'} alt={ActorNames[slot.header?.actorId] || 'Character'}
class="slot-character-icon" class="slot-character-icon"
draggable="false"
/> />
<span class="slot-name">{slot.playerName}</span> <span class="slot-name">{slot.playerName}</span>
</button> </button>
{/each} {/each}
{/if} {/if}
</Carousel>
{/if}
{#if !loading && !error && existingSlots.length === 0}
<div class="no-saves-state">
<img src="callfail.webp" alt="" class="no-saves-image" />
<span class="no-saves-title">No save files found</span>
<p class="no-saves-description">
Start playing LEGO Island and your save will appear here automatically.
</p>
</div> </div>
{:else if !loading && !error && existingSlots.length > 0 && !currentSlot}
<div class="no-saves-state">
<img src="register.webp" alt="" class="no-saves-image" />
<span class="no-saves-title">Select a save file above</span>
<p class="no-saves-description">
Choose a save slot to view and edit your player name, character, and high scores.
</p>
</div>
{/if}
{#if currentSlot && currentSlot.exists} {#if currentSlot && currentSlot.exists}
<div class="config-tabs"> <div class="config-tabs">
@ -336,6 +361,10 @@
</div> </div>
<style> <style>
:global(#save-editor > .page-inner-content > .config-main > .carousel) {
margin-bottom: 15px;
}
.save-status-text { .save-status-text {
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 0.9em; font-size: 0.9em;
@ -346,6 +375,39 @@
color: #ff6b6b; color: #ff6b6b;
} }
.no-saves-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 48px 24px;
flex: 1;
}
.no-saves-image {
width: 100px;
height: auto;
image-rendering: pixelated;
margin-bottom: 16px;
border-radius: 8px;
}
.no-saves-title {
color: var(--color-text-light);
font-size: 1.1em;
font-weight: bold;
margin-bottom: 8px;
}
.no-saves-description {
color: var(--color-text-muted);
font-size: 0.9em;
line-height: 1.5;
max-width: 280px;
margin: 0;
}
.save-slot-card { .save-slot-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;