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>
|
||||
@@ -1,152 +1,110 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import packageInfo from '../../../package.json';
|
||||
import { dev } from '$app/environment';
|
||||
import { renderReleaseMarkdown } from '$lib/release-notes/markdown';
|
||||
import { RELEASES_PAGE_URL } from '$lib/release-notes/release-note-service';
|
||||
import { currentAppVersion, releaseNotes } from '$lib/release-notes/release-note-store';
|
||||
import { versionTagIsNewer } from '$lib/release-notes/version';
|
||||
|
||||
type ReleaseAsset = {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
};
|
||||
|
||||
type GitHubRelease = {
|
||||
tag_name: string;
|
||||
html_url: string;
|
||||
draft: boolean;
|
||||
prerelease: boolean;
|
||||
assets: ReleaseAsset[];
|
||||
};
|
||||
|
||||
type CachedRelease = {
|
||||
checkedAt: number;
|
||||
release: GitHubRelease;
|
||||
};
|
||||
|
||||
const currentVersion = packageInfo.version;
|
||||
const releaseApiUrl = 'https://api.github.com/repos/DD4SN/HolodoriCalc/releases/latest';
|
||||
const releasesUrl = 'https://github.com/DD4SN/HolodoriCalc/releases';
|
||||
const cacheKey = 'holodori-calc:latest-release';
|
||||
const cacheDuration = 6 * 60 * 60 * 1000;
|
||||
|
||||
let status: 'checking' | 'current' | 'available' | 'failed' = 'checking';
|
||||
let latestRelease: GitHubRelease | null = null;
|
||||
|
||||
function versionParts(version: string): number[] {
|
||||
return version.replace(/^v/i, '').split('-', 1)[0].split('.').map((part) => Number.parseInt(part, 10) || 0);
|
||||
}
|
||||
|
||||
function isNewerVersion(candidate: string, current: string): boolean {
|
||||
const candidateParts = versionParts(candidate);
|
||||
const currentParts = versionParts(current);
|
||||
const length = Math.max(candidateParts.length, currentParts.length, 3);
|
||||
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const difference = (candidateParts[index] ?? 0) - (currentParts[index] ?? 0);
|
||||
if (difference !== 0) return difference > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isRelease(value: unknown): value is GitHubRelease {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const release = value as Partial<GitHubRelease>;
|
||||
return typeof release.tag_name === 'string'
|
||||
&& typeof release.html_url === 'string'
|
||||
&& Array.isArray(release.assets);
|
||||
}
|
||||
|
||||
function applyRelease(release: GitHubRelease): void {
|
||||
latestRelease = release;
|
||||
status = isNewerVersion(release.tag_name, currentVersion) ? 'available' : 'current';
|
||||
}
|
||||
|
||||
function readCachedRelease(): CachedRelease | null {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(cacheKey) ?? 'null') as Partial<CachedRelease> | null;
|
||||
if (!parsed || typeof parsed.checkedAt !== 'number' || !isRelease(parsed.release)) return null;
|
||||
return parsed as CachedRelease;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkLatestRelease(): Promise<void> {
|
||||
const cached = readCachedRelease();
|
||||
if (cached && Date.now() - cached.checkedAt < cacheDuration) {
|
||||
applyRelease(cached.release);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), 6000);
|
||||
|
||||
try {
|
||||
const response = await fetch(releaseApiUrl, {
|
||||
headers: { Accept: 'application/vnd.github+json' },
|
||||
signal: controller.signal
|
||||
});
|
||||
if (!response.ok) throw new Error(`GitHub release lookup failed: ${response.status}`);
|
||||
|
||||
const release: unknown = await response.json();
|
||||
if (!isRelease(release) || release.draft || release.prerelease) throw new Error('Invalid GitHub release response');
|
||||
|
||||
localStorage.setItem(cacheKey, JSON.stringify({ checkedAt: Date.now(), release } satisfies CachedRelease));
|
||||
applyRelease(release);
|
||||
} catch {
|
||||
if (cached) applyRelease(cached.release);
|
||||
else status = 'failed';
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function downloadUrl(release: GitHubRelease): string {
|
||||
const zip = release.assets.find((asset) => /^HolodoriCalc-offline-v.+\.zip$/i.test(asset.name));
|
||||
return zip?.browser_download_url ?? release.html_url;
|
||||
}
|
||||
let previewUpdate = false;
|
||||
|
||||
onMount(() => {
|
||||
void checkLatestRelease();
|
||||
previewUpdate = dev && new URLSearchParams(window.location.search).get('previewUpdate') === '1';
|
||||
void releaseNotes.load();
|
||||
});
|
||||
|
||||
$: latestRelease = $releaseNotes.releases[0] ?? null;
|
||||
$: updateAvailable = latestRelease !== null && (previewUpdate || versionTagIsNewer(latestRelease.tagName, currentAppVersion));
|
||||
|
||||
function publishedDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}).format(date);
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class:available={status === 'available'} class="update-notice" aria-live="polite">
|
||||
<div class="version-summary">
|
||||
<span class="label">현재 버전</span>
|
||||
<strong>v{currentVersion}</strong>
|
||||
{#if status === 'checking'}
|
||||
<span class="status">최신 버전을 확인하고 있습니다.</span>
|
||||
{:else if status === 'available' && latestRelease}
|
||||
<span class="status"><b>v{latestRelease.tag_name.replace(/^v/i, '')}</b> 업데이트를 사용할 수 있습니다.</span>
|
||||
{:else if status === 'current'}
|
||||
<span class="status">최신 버전입니다.</span>
|
||||
{:else}
|
||||
<span class="status">업데이트를 확인할 수 없습니다.</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if $releaseNotes.status === 'available' && latestRelease}
|
||||
<aside class:available={updateAvailable} class="update-notice" aria-live="polite">
|
||||
<div class="notice-header">
|
||||
<div class="version-summary">
|
||||
<span class="label">현재 버전</span>
|
||||
<strong>v{currentAppVersion.replace(/^v/i, '')}</strong>
|
||||
{#if previewUpdate}
|
||||
<span class="status"><b>업데이트 알림 미리보기</b></span>
|
||||
{:else if updateAvailable}
|
||||
<span class="status"><b>{latestRelease.tagName}</b> 업데이트를 사용할 수 있습니다.</span>
|
||||
{:else}
|
||||
<span class="status">최신 버전입니다.</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if status === 'available' && latestRelease}
|
||||
<a class="action primary" href={downloadUrl(latestRelease)} target="_blank" rel="noreferrer">최신 버전 다운로드 <span aria-hidden="true">→</span></a>
|
||||
{:else}
|
||||
<a class="action" href={releasesUrl} target="_blank" rel="noreferrer">릴리즈 보기 <span aria-hidden="true">→</span></a>
|
||||
{/if}
|
||||
</aside>
|
||||
<a class="action" href={RELEASES_PAGE_URL} target="_blank" rel="noreferrer">업데이트 내역 확인 <span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
|
||||
{#if updateAvailable}
|
||||
<section class="latest-note" aria-labelledby="latest-release-title">
|
||||
<header>
|
||||
<div>
|
||||
<span class="release-tag">{latestRelease.tagName}</span>
|
||||
<h2 id="latest-release-title">{latestRelease.name}</h2>
|
||||
</div>
|
||||
<time datetime={latestRelease.publishedAt}>{publishedDate(latestRelease.publishedAt)}</time>
|
||||
</header>
|
||||
|
||||
{#if latestRelease.body.trim()}
|
||||
<div class="markdown-body">{@html renderReleaseMarkdown(latestRelease.body)}</div>
|
||||
{:else}
|
||||
<p class="no-notes">작성된 릴리즈 노트가 없습니다.</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.update-notice { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 12px; padding: 14px 18px; border: 1px solid #2c3440; border-radius: 12px; background: #151a22; }
|
||||
.update-notice.available { border-color: rgb(237 139 181 / 38%); background: rgb(237 139 181 / 8%); }
|
||||
.update-notice { margin-bottom: 12px; border: 1px solid #2c3440; border-radius: 12px; background: #151a22; overflow: hidden; }
|
||||
.update-notice.available { border-color: rgb(237 139 181 / 38%); background: rgb(237 139 181 / 6%); }
|
||||
.notice-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 14px 18px; }
|
||||
.version-summary { display: flex; align-items: baseline; min-width: 0; gap: 10px; }
|
||||
.label { color: #7f8998; font-size: 13px; font-weight: 800; }
|
||||
strong { color: #eef1f5; font-size: 15px; }
|
||||
.status { color: #a4adba; font-size: 14px; }
|
||||
.status b { color: #f0a1c3; }
|
||||
.action { flex: 0 0 auto; color: #aab3c0; font-size: 14px; font-weight: 800; text-decoration: none; }
|
||||
.action.primary { color: #f2a6c6; }
|
||||
.available .action { color: #f2a6c6; }
|
||||
.action span { margin-left: 4px; }
|
||||
.action:hover { color: #f2a6c6; }
|
||||
.action:hover { color: #ffd0e2; }
|
||||
.action:focus-visible { outline: 3px solid rgb(239 139 182 / 28%); outline-offset: 3px; }
|
||||
.latest-note { padding: 24px 26px 28px; border-top: 1px solid rgb(237 139 181 / 20%); background: rgb(13 16 22 / 38%); }
|
||||
.latest-note > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding-bottom: 18px; border-bottom: 1px solid rgb(255 255 255 / 8%); }
|
||||
.release-tag { color: #f0a1c3; font-size: 13px; font-weight: 900; }
|
||||
.latest-note h2 { margin: 6px 0 0; color: #edf0f5; font-size: 22px; letter-spacing: -.025em; }
|
||||
.latest-note time { flex: 0 0 auto; color: #858f9e; font-size: 13px; }
|
||||
.markdown-body { padding-top: 18px; color: #b7bfca; font-size: 14px; line-height: 1.7; overflow-wrap: anywhere; }
|
||||
.markdown-body :global(:first-child) { margin-top: 0; }
|
||||
.markdown-body :global(:last-child) { margin-bottom: 0; }
|
||||
.markdown-body :global(h1), .markdown-body :global(h2), .markdown-body :global(h3), .markdown-body :global(h4) { margin: 1.35em 0 .5em; color: #eef1f5; line-height: 1.35; }
|
||||
.markdown-body :global(h1) { font-size: 22px; }
|
||||
.markdown-body :global(h2) { font-size: 19px; }
|
||||
.markdown-body :global(h3) { font-size: 17px; }
|
||||
.markdown-body :global(p), .markdown-body :global(ul), .markdown-body :global(ol), .markdown-body :global(blockquote), .markdown-body :global(pre) { margin: .75em 0; }
|
||||
.markdown-body :global(ul), .markdown-body :global(ol) { padding-left: 1.5em; }
|
||||
.markdown-body :global(a) { color: #f0a1c3; text-underline-offset: 3px; }
|
||||
.markdown-body :global(code) { padding: 2px 5px; border-radius: 5px; color: #e8b3ca; background: #202631; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .9em; }
|
||||
.markdown-body :global(pre) { padding: 14px; border: 1px solid #303846; border-radius: 9px; background: #10141a; overflow-x: auto; }
|
||||
.markdown-body :global(pre code) { padding: 0; color: #c9d0da; background: transparent; }
|
||||
.markdown-body :global(blockquote) { padding-left: 14px; border-left: 3px solid #596475; color: #929cab; }
|
||||
.markdown-body :global(img) { max-width: 100%; border-radius: 9px; }
|
||||
.no-notes { margin: 18px 0 0; color: #818b99; font-size: 14px; }
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.update-notice { align-items: flex-start; flex-direction: column; gap: 8px; }
|
||||
.notice-header { align-items: flex-start; flex-direction: column; gap: 8px; }
|
||||
.version-summary { align-items: flex-start; flex-wrap: wrap; }
|
||||
.latest-note { padding: 22px 18px 24px; }
|
||||
.latest-note > header { flex-direction: column; gap: 8px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user