feat: some nice animations for settings areas missing it & fix update video

This commit is contained in:
2026-08-21 17:38:11 +09:30
parent f0247428a0
commit 12833f9270
8 changed files with 348 additions and 64 deletions
+149 -11
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { fade } from "svelte/transition";
import { onMount } from "svelte";
import { fade, scale } from "svelte/transition";
import { onMount, tick } from "svelte";
import Switch from "./Switch.svelte";
import {
FEEDBACK_CATEGORIES,
@@ -23,6 +23,7 @@
type FeedbackStatusItem,
} from "@/seqta/utils/feedback/client";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import Spinner from "./Spinner.svelte";
let { onClose, initialFeedbackId = null } = $props<{
onClose: () => void;
@@ -65,6 +66,63 @@
const btnPrimary = $derived(
`${btn} ${isDark ? "bg-zinc-200 text-zinc-900" : "bg-zinc-800 text-white"}`,
);
const panelKey = $derived(
tab === "send"
? successId
? "send-success"
: "send-form"
: selectedItem
? `status-${selectedItem.id}`
: "status-list",
);
let panelBodyEl = $state<HTMLDivElement | null>(null);
let panelHeightPx = $state<number | null>(null);
const panelHeightEase = "cubic-bezier(0.22, 1, 0.36, 1)";
const panelHeightMotion = $derived(
$settingsState.animations && panelHeightPx !== null
? `height 280ms ${panelHeightEase}`
: "none",
);
function measurePanelHeight() {
if (!panelBodyEl) return;
panelHeightPx = panelBodyEl.scrollHeight;
}
function panelMeasure(node: HTMLDivElement) {
panelBodyEl = node;
const update = () => {
panelHeightPx = node.scrollHeight;
};
update();
const ro = new ResizeObserver(() => update());
ro.observe(node);
return {
destroy() {
ro.disconnect();
if (panelBodyEl === node) panelBodyEl = null;
},
};
}
$effect(() => {
panelKey;
includeContact;
errorMessage;
statusLoading;
statusItems.length;
selectedItem;
void tick().then(() => measurePanelHeight());
});
function isStatusDetailKey(key: string): boolean {
return key.startsWith("status-") && key !== "status-list";
}
function errText(e: unknown): string {
if (e instanceof FeedbackApiError) return e.message;
@@ -112,6 +170,7 @@
}
onMount(() => {
void tick().then(() => measurePanelHeight());
if (typeof initialFeedbackId === "string" && initialFeedbackId) {
void openStatusItem(initialFeedbackId);
}
@@ -166,11 +225,11 @@
}}
role="button"
tabindex="-1"
transition:fade={{ duration: 150 }}
transition:fade={{ duration: $settingsState.animations ? 200 : 0 }}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="p-5 mx-4 w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl shadow-2xl border text-[18px] {isDark
class="feedback-dialog p-5 mx-4 w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl shadow-2xl border text-[18px] {isDark
? 'bg-zinc-800 text-white border-zinc-700'
: 'bg-white text-zinc-900 border-zinc-200'}"
onclick={(e) => e.stopPropagation()}
@@ -179,6 +238,12 @@
aria-modal="true"
aria-labelledby="feedback-modal-title"
tabindex="-1"
in:scale={{
duration: $settingsState.animations ? 280 : 0,
start: 0.92,
opacity: 0,
easing: (t) => 1 - Math.pow(1 - t, 3),
}}
>
<div
class="flex gap-1 p-1 mb-4 rounded-full {isDark ? 'bg-zinc-900' : 'bg-zinc-100'}"
@@ -193,8 +258,13 @@
</button>
</div>
{#if tab === "send"}
{#if successId}
<div
class="feedback-panel"
style:height={panelHeightPx === null ? "auto" : `${panelHeightPx}px`}
style:transition={panelHeightMotion}
>
<div class="feedback-panel__body" use:panelMeasure>
{#if panelKey === "send-success" && successId}
<h2 id="feedback-modal-title" class="mb-3 text-xl font-bold">Thanks for the feedback</h2>
<p class="mb-2 text-zinc-600 dark:text-zinc-300">Reference ID:</p>
<p class="mb-4 px-3 py-2 font-mono text-base rounded-lg {isDark ? 'bg-zinc-900' : 'bg-zinc-100'} break-all">{successId}</p>
@@ -202,7 +272,7 @@
<button type="button" class={btnMuted} onclick={() => successId && openStatusItem(successId)}>Check status</button>
<button type="button" class={btnPrimary} onclick={onClose}>Done</button>
</div>
{:else}
{:else if panelKey === "send-form"}
<h2 id="feedback-modal-title" class="mb-1 text-xl font-bold">Send feedback</h2>
<p class="mb-4 text-zinc-600 dark:text-zinc-400">Anonymous by default. Contact and school details are optional.</p>
@@ -274,8 +344,7 @@
</button>
</div>
</div>
{/if}
{:else if selectedItem}
{:else if isStatusDetailKey(panelKey) && selectedItem}
<div class="flex items-start justify-between gap-3 mb-3">
<h2 id="feedback-modal-title" class="text-xl font-bold">Feedback status</h2>
<button type="button" class="{btnMuted} !text-base !px-3 !py-1.5" onclick={() => { selectedItem = null; void loadStatusList(); }}>Back</button>
@@ -292,7 +361,7 @@
{/if}
{#if statusError}<p class="mb-3 text-red-600 dark:text-red-400" role="alert">{statusError}</p>{/if}
<div class="flex gap-3 justify-end">
<button type="button" class={btnMuted} onclick={() => openStatusItem(selectedItem.id)} disabled={statusLoading}>
<button type="button" class={btnMuted} onclick={() => openStatusItem(selectedItem!.id)} disabled={statusLoading}>
{statusLoading ? "Refreshing…" : "Refresh"}
</button>
<button type="button" class={btnPrimary} onclick={onClose}>Close</button>
@@ -306,7 +375,15 @@
</div>
{#if statusError}<p class="mb-3 text-red-600 dark:text-red-400" role="alert">{statusError}</p>{/if}
{#if statusLoading && !statusItems.length}
<p class="text-zinc-500">Loading…</p>
<div class="feedback-loading" aria-busy="true" aria-live="polite">
<Spinner size="md" />
<p class="text-zinc-500">Checking your feedback…</p>
<div class="feedback-loading__skeleton" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</div>
</div>
{:else if !statusItems.length}
<p class="mb-4 text-zinc-500">No feedback yet.</p>
<button type="button" class={btnPrimary} onclick={() => selectTab("send")}>Send feedback</button>
@@ -334,10 +411,71 @@
<button type="button" class={btnMuted} onclick={onClose}>Close</button>
</div>
{/if}
</div>
</div>
</div>
</div>
<style>
.feedback-dialog {
display: flex;
flex-direction: column;
}
.feedback-panel {
position: relative;
overflow: hidden;
min-height: 0;
}
.feedback-panel__body {
width: 100%;
}
.feedback-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
min-height: 14rem;
padding: 1rem 0 1.5rem;
}
.feedback-loading__skeleton {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
margin-top: 0.5rem;
}
.feedback-loading__skeleton span {
display: block;
height: 3.5rem;
border-radius: 0.5rem;
background: color-mix(in srgb, currentColor 8%, transparent);
animation: feedback-skeleton-pulse 1.2s ease-in-out infinite;
}
.feedback-loading__skeleton span:nth-child(2) {
animation-delay: 0.15s;
}
.feedback-loading__skeleton span:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes feedback-skeleton-pulse {
0%,
100% {
opacity: 0.45;
}
50% {
opacity: 0.9;
}
}
:global(.feedback-field),
:global(.feedback-field option) {
-webkit-text-fill-color: currentColor !important;
@@ -2,6 +2,7 @@
import { onMount } from "svelte";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
import CloudPfpAvatar from "@/interface/components/CloudPfpAvatar.svelte";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
let { alwaysShowUserName = false, onClick = undefined } = $props<{
alwaysShowUserName?: boolean;
@@ -19,7 +20,7 @@
return unsubscribe;
});
function handleClickOutside(e: MouseEvent) {
function handleClickOutside(e: PointerEvent) {
if (dropdownEl && !dropdownEl.contains(e.target as Node)) {
open = false;
}
@@ -28,11 +29,11 @@
$effect(() => {
if (open) {
const timer = setTimeout(() => {
document.addEventListener("click", handleClickOutside);
document.addEventListener("pointerdown", handleClickOutside);
}, 0);
return () => {
clearTimeout(timer);
document.removeEventListener("click", handleClickOutside);
document.removeEventListener("pointerdown", handleClickOutside);
};
}
});
@@ -47,7 +48,8 @@
open = false;
}
function handleButtonClick() {
function handleButtonClick(event: MouseEvent) {
event.stopPropagation();
if (onClick) {
onClick();
} else {
@@ -65,7 +67,7 @@
}
</script>
<div class="relative flex items-center" bind:this={dropdownEl}>
<div class="relative z-20 flex shrink-0 items-center" bind:this={dropdownEl}>
<button
type="button"
onclick={handleButtonClick}
@@ -100,7 +102,8 @@
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute right-0 top-full mt-2 w-80 rounded-xl border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 shadow-xl z-[100] overflow-hidden"
class="cloud-dropdown absolute right-0 top-full mt-2 w-80 rounded-xl border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 shadow-xl z-[100] overflow-hidden"
class:cloud-dropdown--motion={$settingsState.animations}
onclick={(e) => e.stopPropagation()}
>
<div class="p-4 border-b border-zinc-200 dark:border-zinc-600">
@@ -160,3 +163,21 @@
</div>
{/if}
</div>
<style>
.cloud-dropdown--motion {
transform-origin: top right;
animation: cloud-dropdown-in 0.22s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
@keyframes cloud-dropdown-in {
from {
opacity: 0;
transform: scale(0.94) translateY(-6px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
</style>
+36 -4
View File
@@ -5,6 +5,7 @@
import MotionDiv from "../MotionDiv.svelte";
import PlainCloseButton from "../PlainCloseButton.svelte";
import CloudHeader from "./CloudHeader.svelte";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
type SettingsPage = "settings" | "themes" | "backgrounds";
@@ -33,10 +34,12 @@
];
const activeIndex = $derived(tabs.findIndex((tab) => tab.id === activePage));
const motion = $derived($settingsState.animations ? "var(--bs-settings-ease)" : "0s");
</script>
<header
class="flex shrink-0 items-center gap-4 border-b border-zinc-200/60 px-5 py-4 dark:border-zinc-700/50"
style="--bs-settings-ease: 0.28s cubic-bezier(0.22, 1, 0.36, 1)"
>
<button type="button" class="hidden shrink-0 lg:block" onclick={onLogoClick}>
<img
@@ -52,7 +55,7 @@
</button>
<div
class="h-12 min-w-[14rem] flex-1 rounded-full bg-zinc-100/80 p-1 dark:bg-zinc-900/50"
class="tab-track h-12 min-w-0 flex-1 rounded-full bg-zinc-100/80 p-1 dark:bg-zinc-900/50"
role="tablist"
aria-label="Settings pages"
>
@@ -68,7 +71,7 @@
role="tab"
aria-selected={activePage === tab.id}
onclick={() => setActivePage(tab.id)}
class="relative z-10 h-10 flex-1 rounded-full px-4 text-base transition-colors duration-200
class="relative z-10 h-10 flex-1 whitespace-nowrap rounded-full px-4 text-base transition-colors duration-200
{activePage === tab.id
? 'font-semibold text-zinc-900 dark:text-white'
: 'text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white'}"
@@ -80,8 +83,13 @@
</div>
<div class="flex shrink-0 items-center gap-2">
{#if showStoreTools}
<label class="relative hidden w-40 md:block xl:w-56">
<div
class="store-search-wrap hidden md:block"
class:store-search-wrap--open={showStoreTools}
style="transition: max-width {motion}, opacity {motion}, transform {motion}"
aria-hidden={!showStoreTools}
>
<label class="relative block w-40 md:w-48 xl:w-56">
<span class="sr-only">Search {activePage}</span>
<svg
class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400"
@@ -96,11 +104,14 @@
type="search"
placeholder={activePage === "themes" ? "Search themes" : "Search backgrounds"}
value={searchTerm}
tabindex={showStoreTools ? undefined : -1}
oninput={(event) => setSearchTerm(event.currentTarget.value)}
class="store-search h-12 w-full rounded-full bg-zinc-100/80 pl-9 pr-4 text-sm text-zinc-900 transition-colors duration-150 placeholder:text-zinc-400 focus:bg-zinc-200/70 dark:bg-zinc-900/50 dark:text-white dark:focus:bg-zinc-700"
/>
</label>
</div>
{#if showStoreTools}
<CloudHeader />
{/if}
@@ -109,6 +120,27 @@
</header>
<style>
.store-search-wrap {
overflow: hidden;
max-width: 0;
opacity: 0;
transform: translateX(8px);
pointer-events: none;
}
.store-search-wrap--open {
max-width: 10rem;
opacity: 1;
transform: translateX(0);
pointer-events: auto;
}
@media (min-width: 1280px) {
.store-search-wrap--open {
max-width: 14rem;
}
}
.store-search,
.store-search:focus,
.store-search:focus-visible {
+6 -3
View File
@@ -56,7 +56,7 @@
let compactActiveTab = $state(0);
let activePage = $state<PageId>("settings");
let activeSection = $state("general");
let activeThemeView = $state<ThemeView>("theme-settings");
let activeThemeView = $state<ThemeView>("theme-store");
let activeBackgroundView = $state<BackgroundView>("background-settings");
let selectedBackgroundCategory = $state("All");
let backgroundCategories = $state<string[]>([]);
@@ -93,9 +93,9 @@
{
label: "Themes",
items: [
{ id: "theme-store", label: "Store" },
{ id: "theme-settings", label: "Theme settings" },
{ id: "create-theme", label: "Create theme" },
{ id: "theme-store", label: "Store" },
],
},
];
@@ -356,7 +356,10 @@
searchTerm={storeSearchTerm}
setSearchTerm={(term) => (storeSearchTerm = term)}
{activePage}
setActivePage={(page) => (activePage = page)}
setActivePage={(page) => {
activePage = page;
if (page === "themes") activeThemeView = "theme-store";
}}
showStoreTools={isStoreView}
onLogoClick={handleDevModeToggle}
onClose={handleClose}