mirror of
https://github.com/isledecomp/isle.pizza.git
synced 2026-03-01 06:17:38 +00:00
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:
parent
aac1b63b7c
commit
88aae6083a
184
src/lib/Carousel.svelte
Normal file
184
src/lib/Carousel.svelte
Normal 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>
|
||||||
@ -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,31 +233,50 @@
|
|||||||
<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}
|
||||||
{#if loading}
|
<Carousel bind:hasDragged={carouselHasDragged}>
|
||||||
<span class="save-status-text">Loading save files...</span>
|
{#if loading}
|
||||||
{:else if error}
|
<span class="save-status-text">Loading save files...</span>
|
||||||
<span class="save-status-text error">{error}</span>
|
{:else if error}
|
||||||
{:else if existingSlots.length === 0}
|
<span class="save-status-text error">{error}</span>
|
||||||
<span class="save-status-text">No save files found</span>
|
{:else}
|
||||||
{:else}
|
{#each existingSlots as slot}
|
||||||
{#each existingSlots as slot}
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
class="save-slot-card"
|
||||||
class="save-slot-card"
|
class:selected={selectedSlot === slot.slotNumber}
|
||||||
class:selected={selectedSlot === slot.slotNumber}
|
onclick={() => handleSlotSelect(slot.slotNumber)}
|
||||||
onclick={() => handleSlotSelect(slot.slotNumber)}
|
>
|
||||||
>
|
<img
|
||||||
<img
|
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}
|
||||||
</div>
|
</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>
|
||||||
|
{: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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user