feat: release version 1.0.5
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
|
||||
type Preset = {
|
||||
id: string;
|
||||
name: string;
|
||||
updatedAt: string;
|
||||
state: unknown;
|
||||
};
|
||||
|
||||
type PresetDocument = {
|
||||
schemaVersion: 1;
|
||||
presets: Preset[];
|
||||
};
|
||||
|
||||
export let storageKey: string;
|
||||
export let snapshot: unknown;
|
||||
export let disabled = false;
|
||||
|
||||
const dispatch = createEventDispatcher<{ apply: unknown }>();
|
||||
let presets: Preset[] = [];
|
||||
let selectedId = '';
|
||||
let name = '';
|
||||
let ready = false;
|
||||
let message = '';
|
||||
let messageIsError = false;
|
||||
|
||||
$: selectedPreset = presets.find((preset) => preset.id === selectedId);
|
||||
|
||||
onMount(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(storageKey) ?? 'null') as Partial<PresetDocument> | null;
|
||||
if (parsed?.schemaVersion === 1 && Array.isArray(parsed.presets)) {
|
||||
presets = parsed.presets.filter(isPreset).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||
}
|
||||
} catch {
|
||||
showMessage('프리셋 목록을 읽지 못했습니다.', true);
|
||||
} finally {
|
||||
ready = true;
|
||||
}
|
||||
});
|
||||
|
||||
function isPreset(value: unknown): value is Preset {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const preset = value as Partial<Preset>;
|
||||
return typeof preset.id === 'string' && typeof preset.name === 'string' &&
|
||||
typeof preset.updatedAt === 'string' && 'state' in preset;
|
||||
}
|
||||
|
||||
function persist(next: Preset[]): boolean {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify({ schemaVersion: 1, presets: next } satisfies PresetDocument));
|
||||
presets = next;
|
||||
return true;
|
||||
} catch {
|
||||
showMessage('프리셋을 저장할 수 없습니다. 브라우저 저장 공간을 확인해 주세요.', true);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function showMessage(text: string, isError = false): void {
|
||||
message = text;
|
||||
messageIsError = isError;
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
const normalizedName = name.trim();
|
||||
if (!normalizedName) {
|
||||
showMessage('프리셋 이름을 입력해 주세요.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const existing = presets.find((preset) => preset.name.toLocaleLowerCase('ko-KR') === normalizedName.toLocaleLowerCase('ko-KR'));
|
||||
const saved: Preset = {
|
||||
id: existing?.id ?? globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
name: normalizedName,
|
||||
updatedAt: now,
|
||||
state: JSON.parse(JSON.stringify(snapshot))
|
||||
};
|
||||
const next = [saved, ...presets.filter((preset) => preset.id !== saved.id)];
|
||||
if (!persist(next)) return;
|
||||
selectedId = saved.id;
|
||||
name = saved.name;
|
||||
showMessage(existing ? '같은 이름의 프리셋을 갱신했습니다.' : '현재 조건을 프리셋으로 저장했습니다.');
|
||||
}
|
||||
|
||||
function apply(): void {
|
||||
if (!selectedPreset) return;
|
||||
dispatch('apply', JSON.parse(JSON.stringify(selectedPreset.state)));
|
||||
name = selectedPreset.name;
|
||||
showMessage(`‘${selectedPreset.name}’ 프리셋을 불러왔습니다.`);
|
||||
}
|
||||
|
||||
function remove(): void {
|
||||
if (!selectedPreset) return;
|
||||
const removedName = selectedPreset.name;
|
||||
if (!persist(presets.filter((preset) => preset.id !== selectedPreset!.id))) return;
|
||||
selectedId = '';
|
||||
name = '';
|
||||
showMessage(`‘${removedName}’ 프리셋을 삭제했습니다.`);
|
||||
}
|
||||
|
||||
function selectPreset(): void {
|
||||
if (selectedPreset) name = selectedPreset.name;
|
||||
message = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="preset-manager" aria-label="프리셋 관리">
|
||||
<div class="preset-heading">
|
||||
<strong>프리셋</strong>
|
||||
<span>현재 조건을 이름별로 저장하고 다시 불러옵니다.</span>
|
||||
</div>
|
||||
<div class="preset-controls">
|
||||
<input bind:value={name} maxlength="40" placeholder="프리셋 이름" aria-label="프리셋 이름" disabled={disabled || !ready} on:keydown={(event) => event.key === 'Enter' && save()}>
|
||||
<button class="save" type="button" disabled={disabled || !ready} on:click={save}>저장</button>
|
||||
<select bind:value={selectedId} aria-label="저장된 프리셋" disabled={disabled || presets.length === 0} on:change={selectPreset}>
|
||||
<option value="">{presets.length === 0 ? '저장된 프리셋 없음' : '불러올 프리셋 선택'}</option>
|
||||
{#each presets as preset}<option value={preset.id}>{preset.name}</option>{/each}
|
||||
</select>
|
||||
<button type="button" disabled={disabled || !selectedPreset} on:click={apply}>불러오기</button>
|
||||
<button class="delete" type="button" disabled={disabled || !selectedPreset} on:click={remove}>삭제</button>
|
||||
</div>
|
||||
{#if message}<p class:error={messageIsError} aria-live="polite">{message}</p>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.preset-manager { margin-top: 18px; padding: 14px; border: 1px solid #343b47; border-radius: 11px; background: #12161c; }
|
||||
.preset-heading { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; }
|
||||
.preset-heading strong { color: #e4e8ee; font-size: 14px; }
|
||||
.preset-heading span { color: #7f8998; font-size: 12px; }
|
||||
.preset-controls { display: grid; grid-template-columns: minmax(150px, .8fr) auto minmax(190px, 1fr) auto auto; gap: 7px; }
|
||||
input, select, button { min-width: 0; height: 38px; border: 1px solid #3a4350; border-radius: 8px; font: inherit; }
|
||||
input, select { padding: 0 10px; color: #e8ebf1; background: #0f1319; }
|
||||
input:focus, select:focus { border-color: #d86f9c; outline: 3px solid rgb(216 111 156 / 14%); }
|
||||
button { padding: 0 13px; color: #c7ced8; background: #252c36; font-size: 13px; font-weight: 800; cursor: pointer; }
|
||||
button:hover:not(:disabled) { color: #fff; background: #303844; }
|
||||
button.save { border-color: #bd6e91; color: #21151b; background: #df83aa; }
|
||||
button.save:hover:not(:disabled) { background: #eda0c0; }
|
||||
button.delete { color: #e5a4a4; }
|
||||
button:disabled, input:disabled, select:disabled { opacity: .45; cursor: default; }
|
||||
p { margin: 9px 2px 0; color: #8fc9a6; font-size: 12px; }
|
||||
p.error { color: #ff9f9f; }
|
||||
@media (max-width: 700px) {
|
||||
.preset-heading { align-items: flex-start; flex-direction: column; gap: 4px; }
|
||||
.preset-controls { grid-template-columns: minmax(0, 1fr) auto auto; }
|
||||
.preset-controls select { grid-column: 1 / -1; grid-row: 2; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user