Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ sr 是一個架空世界觀的 2D 六角格回合制策略遊戲:**手牌 +
再由使用者選擇 iframe 嵌入或另開新分頁。
3. 服務目錄、探活與分流屬於獨立的 `hoshi-svc`,不在這個靜態網站的部署物內。

公開頁面使用 `hoshi_cookie_consent` 區分「只用必要 Cookie」與「記住偏好」。訪客明確
選擇語言或接受偏好後,語言會寫入 `.hoshivel.com` 範圍的 `hoshi_lang`,供 Hoshivel、
Hoshi ID 與遊戲入口共用;本站維持固定深色美術方向,不會覆寫其他頁面的外觀偏好。
偏好 Cookie 不承載分析或追蹤資料,登入與安全 Cookie 也不受這個選項停用。

具體流程:官網 Play → `GET https://svc.hoshivel.com/v1/services/sr-game/route?endpoint=web`
→ 取得建議與候選節點 → 使用者進入後才載入該節點原始 `web` endpoint。

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"test": "node --experimental-strip-types --test test/play.test.mjs",
"test": "node --experimental-strip-types --test test/*.test.mjs",
"build": "npm test && astro check && astro build",
"preview": "astro preview",
"check": "astro check",
Expand Down
133 changes: 133 additions & 0 deletions src/components/CookiePreferences.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
---
import { useTranslations, type Locale } from "@/i18n/utils";
import { globalLanguageForLocale } from "@/lib/preferences";

interface Props {
locale: Locale;
}

const { locale } = Astro.props;
const t = useTranslations(locale);
const language = globalLanguageForLocale(locale);
---

<details class="sr-cookie" data-cookie-panel data-language={language} hidden>
<summary>{t("cookie.settings")}</summary>
<div class="sr-cookie__card">
<h2>{t("cookie.title")}</h2>
<p>{t("cookie.body")}</p>
<div class="sr-cookie__actions">
<button class="sr-btn sr-btn--ghost" type="button" data-cookie-essential>
{t("cookie.essential")}
</button>
<button class="sr-btn sr-btn--primary" type="button" data-cookie-accept>
{t("cookie.remember")}
</button>
</div>
</div>
</details>

<style>
.sr-cookie {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: var(--sr-z-overlay);
width: min(30rem, calc(100% - 2rem));
}
.sr-cookie > summary {
width: fit-content;
max-width: 100%;
margin-left: auto;
padding: 0.45rem 0.8rem;
border: 1px solid var(--sr-border-strong);
border-radius: var(--sr-radius);
list-style: none;
color: var(--sr-text-muted);
background: var(--sr-surface-2);
box-shadow: var(--sr-shadow-2);
font-size: var(--sr-text-xs);
cursor: pointer;
}
.sr-cookie > summary::-webkit-details-marker {
display: none;
}
.sr-cookie[open] > summary {
margin-bottom: 0.5rem;
color: var(--sr-accent-ice);
border-color: var(--sr-accent);
}
.sr-cookie__card {
padding: 1.25rem;
border: 1px solid color-mix(in srgb, var(--sr-accent) 62%, var(--sr-border));
border-radius: var(--sr-radius-lg);
color: var(--sr-text);
background: var(--sr-surface-1);
box-shadow: var(--sr-shadow-3);
}
.sr-cookie__card h2 {
color: var(--sr-text-bright);
font-size: var(--sr-text-lg);
}
.sr-cookie__card p {
margin-top: 0.5rem;
color: var(--sr-text-muted);
font-size: var(--sr-text-sm);
line-height: var(--sr-leading);
}
.sr-cookie__actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: 1rem;
}
.sr-cookie__actions .sr-btn {
min-height: 2.5rem;
padding: 0.55rem 0.95rem;
}
@media (max-width: 520px) {
.sr-cookie {
right: 0.6rem;
bottom: 0.6rem;
width: calc(100% - 1.2rem);
}
.sr-cookie__actions,
.sr-cookie__actions .sr-btn {
width: 100%;
}
}
</style>

<script>
import {
acceptPreferences,
globalLanguageForLocale,
readConsent,
rememberLanguage,
useEssentialCookies,
} from "@/lib/preferences";

const panel = document.querySelector<HTMLDetailsElement>("[data-cookie-panel]");
if (panel) {
panel.hidden = false;
panel.open = readConsent() === null;
const current = globalLanguageForLocale(panel.dataset.language ?? "zh-TW");
panel.querySelector("[data-cookie-accept]")?.addEventListener("click", () => {
acceptPreferences(current);
panel.open = false;
});
panel.querySelector("[data-cookie-essential]")?.addEventListener("click", () => {
useEssentialCookies();
panel.open = false;
});
}

document.addEventListener("click", (event) => {
const link = (event.target as HTMLElement | null)?.closest<HTMLElement>(
"[data-hoshi-language]",
);
if (!link) return;
rememberLanguage(globalLanguageForLocale(link.dataset.hoshiLanguage ?? ""));
});
</script>
2 changes: 2 additions & 0 deletions src/components/LanguageSwitcher.astro
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
HTML_LANG,
type Locale,
} from "@/i18n/utils";
import { globalLanguageForLocale } from "@/lib/preferences";

interface Props {
locale: Locale;
Expand All @@ -36,6 +37,7 @@ const t = useTranslations(locale);
hreflang={HTML_LANG[l]}
title={LOCALE_LABEL[l]}
aria-current={active ? "true" : undefined}
data-hoshi-language={globalLanguageForLocale(l)}
>
{LOCALE_SHORT[l]}
</a>
Expand Down
52 changes: 52 additions & 0 deletions src/components/PreferenceBootstrap.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
import {
LOCALES,
DEFAULT_LOCALE,
localizedPath,
type Locale,
} from "@/i18n/utils";
import { globalLanguageForLocale } from "@/lib/preferences";

interface Props {
locale: Locale;
path: string;
}

const { locale, path } = Astro.props;
const routes = Object.fromEntries(
LOCALES.map((candidate) => [
globalLanguageForLocale(candidate),
localizedPath(candidate, path),
]),
);
const redirectFromDefault = locale === DEFAULT_LOCALE && path === "/";
---

<script is:inline define:vars={{ routes, redirectFromDefault }}>
// A static site can only honor a saved locale in the browser. Explicit
// locale URLs still win, so only the unprefixed home route redirects.
if (redirectFromDefault) {
var readCookie = function (name) {
var prefix = name + "=";
var parts = document.cookie.split(";");
for (var i = 0; i < parts.length; i += 1) {
var part = parts[i].trim();
if (!part.startsWith(prefix)) continue;
try {
return decodeURIComponent(part.slice(prefix.length));
} catch (_error) {
return null;
}
}
return null;
};
if (readCookie("hoshi_cookie_consent") === "preferences-v1") {
var saved = readCookie("hoshi_lang");
if (saved === "zh-Hant") saved = "zh-TW";
var target = routes[saved];
if (target && target !== location.pathname) {
location.replace(target + location.search + location.hash);
}
}
}
</script>
28 changes: 28 additions & 0 deletions src/i18n/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ const zhHant = {
"footer.summary": "破碎星空之下,啟程未竟之旅",
"footer.rights": "碎界 Shattered Realms",

"cookie.settings": "Cookie 設定",
"cookie.title": "記住你的顯示偏好",
"cookie.body":
"我們不使用分析或追蹤 Cookie。你可以讓 Hoshivel 旗下網站共用語言偏好;外觀只在支援的頁面套用。登入與安全所需的 Cookie 永遠啟用。",
"cookie.essential": "只使用必要 Cookie",
"cookie.remember": "記住偏好",

"hero.badge": "正式上線",

// 預覽 Teaser(#teaser)—— 只有眉標與播放器,不加說明文字
Expand Down Expand Up @@ -236,6 +243,13 @@ const zhCN: Record<UIKey, string> = {
"footer.summary": "破碎星空之下,启程未竟之旅",
"footer.rights": "碎界 Shattered Realms",

"cookie.settings": "Cookie 设置",
"cookie.title": "记住你的显示偏好",
"cookie.body":
"我们不使用分析或跟踪 Cookie。你可以让 Hoshivel 旗下网站共享语言偏好;外观只在支持的页面应用。登录与安全所需的 Cookie 始终启用。",
"cookie.essential": "仅使用必要 Cookie",
"cookie.remember": "记住偏好",

"hero.badge": "正式上线",

"teaser.eyebrow": "预览",
Expand Down Expand Up @@ -380,6 +394,13 @@ const en: Record<UIKey, string> = {
"footer.summary": "Beneath a shattered sky, the journey begins",
"footer.rights": "Shattered Realms",

"cookie.settings": "Cookie settings",
"cookie.title": "Remember your display preferences",
"cookie.body":
"We do not use analytics or tracking cookies. You can share your language across Hoshivel sites; appearance applies only on pages that support it. Cookies required for sign-in and security are always enabled.",
"cookie.essential": "Essential only",
"cookie.remember": "Remember preferences",

"hero.badge": "Now live",

"teaser.eyebrow": "Preview",
Expand Down Expand Up @@ -533,6 +554,13 @@ const ja: Record<UIKey, string> = {
"footer.summary": "砕けた星空の下、未完の旅へ",
"footer.rights": "砕界 Shattered Realms",

"cookie.settings": "Cookie 設定",
"cookie.title": "表示設定を保存",
"cookie.body":
"解析や追跡の Cookie は使用しません。Hoshivel の各サイトで言語を共有できます。外観は対応ページにのみ適用されます。ログインとセキュリティに必要な Cookie は常に有効です。",
"cookie.essential": "必須 Cookie のみ",
"cookie.remember": "設定を保存",

"hero.badge": "正式サービス中",

"teaser.eyebrow": "プレビュー",
Expand Down
4 changes: 4 additions & 0 deletions src/layouts/Layout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import "@/styles/global.css";
import Header from "@/components/Header.astro";
import Footer from "@/components/Footer.astro";
import PreferenceBootstrap from "@/components/PreferenceBootstrap.astro";
import CookiePreferences from "@/components/CookiePreferences.astro";
import {
useTranslations,
localizedPath,
Expand Down Expand Up @@ -56,6 +58,7 @@ const xDefault = Astro.site
<script is:inline>
document.documentElement.classList.add("sr-js");
</script>
<PreferenceBootstrap locale={locale} path={logicalPath} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="canonical" href={canonical} />
<link rel="sitemap" type="application/xml" href="/sitemap.xml" />
Expand Down Expand Up @@ -88,6 +91,7 @@ const xDefault = Astro.site
<Header locale={locale} path={chromePath} isHome={isHomePage} />
<slot />
<Footer locale={locale} path={chromePath} isHome={isHomePage} />
<CookiePreferences locale={locale} />

<script>
// 輕量互動啟動:reveal-on-scroll(IntersectionObserver)+磁吸按鈕。
Expand Down
Loading