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
6 changes: 3 additions & 3 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1128,7 +1128,6 @@ body { min-height: 100vh; overflow: auto; }

.catalog-toolbar { position: sticky; top: 0; z-index: 15; display: grid; grid-template-columns: minmax(210px, 1fr) repeat(4, minmax(110px, auto)); align-items: end; gap: 8px; margin-bottom: 34px; padding: 10px; border: 1px solid var(--product-line); border-radius: 10px; background: color-mix(in srgb, var(--product-bg), transparent 7%); backdrop-filter: blur(12px); }
.catalog-toolbar input, .catalog-toolbar select { min-width: 0; border: 0; outline: 0; background: transparent; color: var(--product-text); font-size: 12px; }
.catalog-search input { flex: 1; }
.catalog-collection { margin-bottom: 42px; }
.collection-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 14px; }
.collection-heading span { color: var(--product-muted); font-size: 10px; letter-spacing: .09em; text-transform: uppercase; }
Expand Down Expand Up @@ -1188,6 +1187,7 @@ body { min-height: 100vh; overflow: auto; }
.sign-in-empty > svg, .custom-collection-intro > svg { color: var(--product-primary); }
.sign-in-empty h2, .custom-collection-intro h2 { margin: 5px 0 0; }
.sign-in-empty p, .custom-collection-intro p { max-width: 620px; margin: 0 0 10px; color: var(--product-muted); font-size: 13px; line-height: 1.6; }
.profile-settings-content { max-width: 760px; }
.profile-form { display: grid; gap: 17px; padding: 30px; border: 1px solid var(--product-line); border-radius: 12px; background: var(--product-surface); }
.profile-identity { display: flex; align-items: center; gap: 13px; padding-bottom: 18px; border-bottom: 1px solid var(--product-line); }
.profile-identity img { width: 48px; height: 48px; border-radius: 50%; }
Expand Down Expand Up @@ -1676,7 +1676,7 @@ body { min-height: 100vh; overflow: auto; }
.dashboard-columns { grid-template-columns: 1fr; }
.product-section { padding: 22px 18px; }
.catalog-toolbar { position: static; grid-template-columns: 1fr 1fr; }
.catalog-search { grid-column: 1 / -1; }
.catalog-toolbar > .catalog-toolbar-search { grid-column: 1 / -1; }
.product-page .problem-list-head { display: none; }
.product-page .problem-row { position: relative; grid-template-columns: 24px 30px minmax(0, 1fr) 16px; gap: 7px; padding: 12px 4px; }
.product-page .problem-row .difficulty-pill { grid-column: 3; }
Expand Down Expand Up @@ -1727,7 +1727,7 @@ body { min-height: 100vh; overflow: auto; }

@media (max-width: 430px) {
.catalog-toolbar { grid-template-columns: 1fr; }
.catalog-search { grid-column: auto; }
.catalog-toolbar > .catalog-toolbar-search { grid-column: auto; }
.dashboard-hero h1 { font-size: 36px; }
.hero-actions { display: grid; }
.primary-action, .secondary-action { width: 100%; }
Expand Down
2 changes: 1 addition & 1 deletion src/features/catalog/components/problem-catalog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export function ProblemCatalog() {
return <main className="product-page" id="main-content">
<header className="product-page-header"><span className="product-eyebrow"><BookOpen size={14} /> Learn</span><h1>{text.catalog}</h1><p>{text.catalogIntro}</p></header>
<div className="catalog-toolbar">
<FilterField className="catalog-search" icon={<Search aria-hidden="true" size={16} />} label={labels.search}><input aria-label={labels.search} value={query} placeholder={text.search} onChange={(event) => setQuery(event.target.value)} /></FilterField>
<FilterField className="catalog-toolbar-search" icon={<Search aria-hidden="true" size={16} />} label={labels.search}><input aria-label={labels.search} value={query} placeholder={text.search} onChange={(event) => setQuery(event.target.value)} /></FilterField>
<FilterField label={labels.collection}><select aria-label={labels.collection} value={collectionId} onChange={(event) => setCollectionId(event.target.value)}><option value="all">{labels.allCollections}</option>{collections.map((collection) => <option key={collection.publicationId} value={collection.publicationId}>{collection.official ? `Official · ${collection.repository.name}` : `${collection.repository.owner}/${collection.repository.name}`}</option>)}</select></FilterField>
<FilterField icon={<Filter aria-hidden="true" size={15} />} label={labels.difficulty}><select aria-label={labels.difficulty} value={difficulty} onChange={(event) => setDifficulty(event.target.value)}><option value="all">{text.difficulty}</option><option value="easy">Easy</option><option value="medium">Medium</option><option value="hard">Hard</option></select></FilterField>
<FilterField label={labels.topic}><select aria-label={labels.topic} value={topic} onChange={(event) => setTopic(event.target.value)}><option value="all">{text.topic}</option>{topics.map((value) => <option key={value} value={value}>{value}</option>)}</select></FilterField>
Expand Down
51 changes: 51 additions & 0 deletions src/features/catalog/components/product-page-layout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { test, vi } from "vitest";

vi.mock("../../platform/components/app-shell", () => ({
useProduct: () => ({
locale: "zh-TW",
refreshSession: async () => undefined,
session: undefined,
sessionStatus: "loading",
}),
}));

vi.mock("../model/education-model", async () => {
const actual = await vi.importActual<typeof import("../model/education-model")>("../model/education-model");
return {
...actual,
useCatalog: () => ({ collections: [], error: "", loading: false }),
};
});

import { ProfileSettings } from "../../profiles/components/profile-settings";
import { ProblemCatalog } from "./problem-catalog";

test("problem catalog search uses only the toolbar FilterField layout", () => {
const html = renderToStaticMarkup(createElement(ProblemCatalog));

assert.match(html, /class="ui-filter-field catalog-toolbar-search"/u);
assert.doesNotMatch(html, /class="[^"]*\bcatalog-search\b/u);
assert.match(html, /class="ui-filter-control"[^>]*><svg[\s\S]*?<input aria-label="搜尋"/u);
});

test("catalog toolbar search modifier only controls responsive grid placement", () => {
const css = readFileSync(new URL("../../../../app/globals.css", import.meta.url), "utf8");
const rules = [...css.matchAll(/\.catalog-toolbar > \.catalog-toolbar-search\s*\{([^}]*)\}/gu)]
.map((match) => match[1].trim());

assert.deepEqual(rules, ["grid-column: 1 / -1;", "grid-column: auto;"]);
});

test("profile settings uses the standard product page width", () => {
const html = renderToStaticMarkup(createElement(ProfileSettings));
const css = readFileSync(new URL("../../../../app/globals.css", import.meta.url), "utf8");

assert.match(html, /<main class="product-page" id="main-content">/u);
assert.match(html, /<div class="profile-settings-content">/u);
assert.doesNotMatch(html, /\bnarrow-page\b/u);
assert.match(css, /\.profile-settings-content\s*\{\s*max-width:\s*760px;\s*\}/u);
});
16 changes: 9 additions & 7 deletions src/features/profiles/components/profile-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,15 @@ export function ProfileSettings() {
}
}

if (erasureMessage) return <main className="product-page narrow-page" id="main-content"><section className="account-erasure-complete" role="status"><UserRound aria-hidden="true" size={28} /><h1>{erasureMessage}</h1><Link className="primary-action" href="/">WASM-OJ</Link></section></main>;
if (erasureMessage) return <main className="product-page" id="main-content"><section className="account-erasure-complete" role="status"><UserRound aria-hidden="true" size={28} /><h1>{erasureMessage}</h1><Link className="primary-action" href="/">WASM-OJ</Link></section></main>;

return <main className="product-page narrow-page" id="main-content">
return <main className="product-page" id="main-content">
<header className="product-page-header"><span className="product-eyebrow"><UserRound aria-hidden="true" size={14} /> {text.eyebrow}</span><h1>{text.title}</h1><p>{text.intro}</p></header>
{sessionStatus === "loading" && <div className="product-load-state" role="status"><span>{text.loading}</span></div>}
{sessionStatus === "error" && <div className="product-error" role="alert"><span>{locale === "zh-TW" ? "無法確認帳號狀態。" : "Could not verify your account."}</span><button type="button" onClick={() => void refreshSession()}>{text.retry}</button></div>}
{sessionStatus === "ready" && !session?.authenticated && <section className="sign-in-empty"><UserRound aria-hidden="true" size={30} /><h2>{text.signIn}</h2><p>{text.signInDetail}</p><a className="primary-action" href="/api/auth/github?return=%2Fsettings%2Fprofile"><GitBranch aria-hidden="true" size={16} />{text.signIn}</a></section>}
{sessionStatus === "ready" && session?.authenticated && <RemoteStateView state={profileState} loadingLabel={text.loading} retryLabel={text.retry} empty={null} isEmpty={() => false}>{(profile) => <>
<div className="profile-settings-content">
{sessionStatus === "loading" && <div className="product-load-state" role="status"><span>{text.loading}</span></div>}
{sessionStatus === "error" && <div className="product-error" role="alert"><span>{locale === "zh-TW" ? "無法確認帳號狀態。" : "Could not verify your account."}</span><button type="button" onClick={() => void refreshSession()}>{text.retry}</button></div>}
{sessionStatus === "ready" && !session?.authenticated && <section className="sign-in-empty"><UserRound aria-hidden="true" size={30} /><h2>{text.signIn}</h2><p>{text.signInDetail}</p><a className="primary-action" href="/api/auth/github?return=%2Fsettings%2Fprofile"><GitBranch aria-hidden="true" size={16} />{text.signIn}</a></section>}
{sessionStatus === "ready" && session?.authenticated && <RemoteStateView state={profileState} loadingLabel={text.loading} retryLabel={text.retry} empty={null} isEmpty={() => false}>{(profile) => <>
<form className="profile-form" onSubmit={(event) => void save(event, profile)}>
<div className="profile-identity"><Image src={profile.avatarUrl} alt="" width={48} height={48} unoptimized /><div><strong>{profile.displayName}</strong><span>@{profile.login} · {profile.verifiedSolvedCount} {text.verified}</span></div></div>
<label>{text.displayName}<input value={profile.displayName} maxLength={80} required onChange={(event) => updateProfile({ displayName: event.target.value })} /></label>
Expand All @@ -146,6 +147,7 @@ export function ProfileSettings() {
<footer><button className="secondary-action" type="button" disabled={deleting} onClick={closeDelete}>{text.cancel}</button><button className="danger-action" type="button" disabled={deleting || deleteConfirmation !== profile.login} onClick={() => void deleteAccount(profile)}><Trash2 aria-hidden="true" size={15} />{deleting ? text.deleting : text.delete}</button></footer>
</div>
</Drawer>
</>}</RemoteStateView>}
</>}</RemoteStateView>}
</div>
</main>;
}