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: 5 additions & 1 deletion apps/web/public/data/styles/dark-matter-neutral.json
Original file line number Diff line number Diff line change
Expand Up @@ -5894,5 +5894,9 @@
}
],
"id": "voyager",
"owner": "Carto"
"owner": "Carto",
"transition": {
"duration": 0,
"delay": 0
}
}
6 changes: 5 additions & 1 deletion apps/web/public/data/styles/positron-neutral.json
Original file line number Diff line number Diff line change
Expand Up @@ -5889,5 +5889,9 @@
}
],
"id": "voyager",
"owner": "Carto"
"owner": "Carto",
"transition": {
"duration": 0,
"delay": 0
}
}
4 changes: 4 additions & 0 deletions apps/web/scripts/process-map-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ interface StyleSpecification {
sprite?: string;
glyphs?: string;
layers?: Record<string, unknown>[];
transition?: { duration?: number; delay?: number };
}

function parseColor(
Expand Down Expand Up @@ -90,6 +91,9 @@ function neutralizeStyle(
theme: "dark" | "light",
): StyleSpecification {
const neutralized = { ...style };
// Theme switches swap styles behind a hidden map canvas. The default 300ms
// paint transition would still be running when the canvas fades back in.
neutralized.transition = { duration: 0, delay: 0 };

if (neutralized.layers) {
neutralized.layers = neutralized.layers.map((layer) => {
Expand Down
48 changes: 48 additions & 0 deletions apps/web/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-mark: var(--mark);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
Expand Down Expand Up @@ -82,6 +83,7 @@
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--mark: #f9452d;
}

.dark {
Expand Down Expand Up @@ -116,6 +118,7 @@
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--mark: #e1f435;
}

@layer base {
Expand Down Expand Up @@ -195,6 +198,51 @@ body::after {
}
}

/* Staggered entrance. Each element sets its own --enter-delay; the
`backwards` fill keeps it hidden during the delay without leaving a
transform or filter behind once the animation ends. */
@keyframes enter-fx {
from {
opacity: 0;
filter: blur(6px);
transform: translateY(14px);
}
}

@keyframes enter-fade {
from {
opacity: 0;
}
}

.enter-fx {
animation: enter-fx 0.6s cubic-bezier(0.16, 1, 0.3, 1) var(--enter-delay, 0s)
backwards;
}

@media (prefers-reduced-motion: reduce) {
.enter-fx {
animation-name: enter-fade;
animation-duration: 0.2s;
}
}

/* Fades the hero map out toward its left, top and bottom edges. A mask
rather than overlays in the background color, so whatever lies behind
(the page glow included) shows through unchanged. */
@utility map-fade {
mask-image:
linear-gradient(to right, transparent, rgb(0 0 0 / 0.3) 11%, #000 22%),
linear-gradient(
to bottom,
transparent,
#000 4rem,
#000 calc(100% - 6rem),
transparent
);
mask-composite: intersect;
}

/* Stock shadcn slot overrides ─────────────────────────────────────────
These rules live outside @layer so they beat Tailwind utility classes.
They compensate for stock component defaults that don't match the
Expand Down
28 changes: 13 additions & 15 deletions apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Analytics } from "@vercel/analytics/next";
import { SpeedInsights } from "@vercel/speed-insights/next";
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import Link from "next/link";
import { ThemeProvider } from "@/components/theme-provider";
import { LogoIcon, GitHubIcon } from "@/components/icons";
import { ModeToggle } from "@/components/mode-toggle";
Expand Down Expand Up @@ -78,29 +79,26 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
<header className="border-border/50 sticky top-0 z-40 h-12 backdrop-blur-xl lg:hidden lg:border-b">
<div className="mx-auto flex h-full w-full max-w-3xl items-center justify-between px-4 sm:px-6 lg:max-w-6xl">
<div className="flex items-center gap-2">
<LogoIcon />
<span className="text-lg font-semibold tracking-tight">
zipkit
</span>
<span className="text-muted-foreground hidden text-base sm:inline">
/
<header className="bg-background/60 sticky top-0 z-40 w-full backdrop-blur-sm">
<div className="mx-auto flex h-14 w-full max-w-3xl items-center justify-between px-4 sm:px-6 lg:max-w-6xl">
<Link href="/" className="flex items-center gap-2.5">
<span className="bg-foreground text-background size-7 rounded-md p-1.5">
<LogoIcon className="size-full" />
</span>
<span className="text-muted-foreground hidden text-base sm:inline">
{"地址英譯 & 郵遞區號"}
<span className="text-foreground/80 font-mono text-sm font-medium tracking-[0.5px] uppercase sm:text-base">
zipkit
</span>
</div>
<div className="flex items-center gap-1">
</Link>
<div className="flex items-center gap-2">
<a
href="https://github.com/ronload/zipkit"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="text-muted-foreground hover:bg-secondary hover:text-foreground flex h-7 w-7 items-center justify-center rounded-md transition-colors"
className="text-muted-foreground hover:bg-secondary hover:text-foreground inline-flex h-8 w-8 items-center justify-center gap-1.5 rounded-full border font-mono text-xs transition-colors sm:w-auto sm:px-3"
>
<GitHubIcon className="h-4 w-4" />
<GitHubIcon className="size-3.5" />
<span className="hidden sm:inline">GitHub</span>
</a>
<ModeToggle />
</div>
Expand Down
7 changes: 2 additions & 5 deletions apps/web/src/components/address-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { DistrictSelect } from "@/components/district-select";
import { RoadCombobox } from "@/components/road-combobox";
import { AddressDetailInputs } from "@/components/address-detail-inputs";
import { ResultCard } from "@/components/result-card";
import { SectionLabel } from "@/components/section-label";
import { toSlug } from "@/lib/slug";

type AddressFormProps = {
Expand Down Expand Up @@ -81,11 +82,7 @@ export function AddressForm({
</div>

<div>
<div className="mb-3">
<span className="text-muted-foreground text-[11px] font-medium tracking-widest uppercase">
{"查詢結果"}
</span>
</div>
<SectionLabel className="mb-3">{"查詢結果"}</SectionLabel>
<ResultCard englishAddress={englishAddress} zip6={zip6} zip3={zip3} />
{state.city && state.district && (
<div className="mt-3">
Expand Down
109 changes: 55 additions & 54 deletions apps/web/src/components/address-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,12 @@ import {
memo,
type ComponentType,
} from "react";
import { ArrowUpRight } from "lucide-react";
import type { City, District } from "@zipkit/core";
import { useAddressState } from "@/hooks/use-address-state";
import { toSlug } from "@/lib/slug";
import { AddressForm } from "@/components/address-form";
import { LogoIcon, GitHubIcon } from "@/components/icons";
import { ModeToggle } from "@/components/mode-toggle";
import { Card } from "@/components/ui/card";
import { BracketBadge } from "@/components/bracket-badge";

/* ------------------------------------------------------------------ */
/* Module-level preload */
Expand Down Expand Up @@ -54,9 +53,13 @@ if (

const MAX_RETRIES = 2;

const POSTAL_DATA_URL =
"https://www.post.gov.tw/post/internet/Download/index.jsp?ID=2292";

interface TaiwanMapProps {
city: City | null;
district: District | null;
cities: City[];
}

type MapLoadState =
Expand Down Expand Up @@ -117,11 +120,11 @@ function useLazyMap() {
/* ------------------------------------------------------------------ */

const MapPlaceholder = () => (
<div className="border-border/50 bg-muted/30 h-full w-full animate-pulse rounded-xl border" />
<div className="bg-muted/30 map-fade h-full w-full animate-pulse" />
);

const MapError = ({ onRetry }: { onRetry: () => void }) => (
<div className="border-border/50 bg-muted/10 flex h-full w-full flex-col items-center justify-center gap-3 rounded-xl border">
<div className="flex h-full w-full flex-col items-center justify-center gap-3">
<p className="text-muted-foreground text-sm">地圖載入失敗</p>
<button
type="button"
Expand All @@ -138,12 +141,9 @@ const LazyMap = memo(function LazyMap({
Component,
city,
district,
}: {
Component: ComponentType<TaiwanMapProps>;
city: City | null;
district: District | null;
}) {
return <Component city={city} district={district} />;
cities,
}: TaiwanMapProps & { Component: ComponentType<TaiwanMapProps> }) {
return <Component city={city} district={district} cities={cities} />;
});

/* ------------------------------------------------------------------ */
Expand Down Expand Up @@ -200,52 +200,53 @@ export function AddressPage({ cities }: AddressPageProps) {
}, [load]);

return (
<div className="mx-auto w-full max-w-3xl px-4 py-8 sm:px-6 sm:py-12 lg:grid lg:min-h-screen lg:max-w-6xl lg:grid-cols-[minmax(0,30rem)_1fr] lg:items-center lg:gap-8 lg:py-0">
<div>
<Card className="mb-6 hidden border-l lg:flex" size="sm">
<div className="flex items-center justify-between px-4">
<div className="flex items-center gap-2">
<LogoIcon />
<span className="text-lg font-semibold tracking-tight">
zipkit
</span>
<span className="text-muted-foreground text-base">/</span>
<span className="text-muted-foreground text-base">
{"地址英譯 & 郵遞區號"}
<div className="@container">
<div className="relative mx-auto w-full max-w-3xl px-4 py-8 sm:px-6 sm:py-12 lg:flex lg:min-h-[calc(100dvh-3.5rem)] lg:max-w-6xl lg:items-center">
<div className="relative z-10 lg:w-[46%] lg:min-w-[440px]">
<div className="enter-fx [--enter-delay:100ms]">
<BracketBadge href={POSTAL_DATA_URL} className="ml-1">
{"資料來源"}
<span aria-hidden="true" className="opacity-40">
·
</span>
</div>
<div className="flex items-center gap-1">
<a
href="https://github.com/ronload/zipkit"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="text-muted-foreground hover:bg-secondary hover:text-foreground flex h-7 w-7 items-center justify-center rounded-md transition-colors"
>
<GitHubIcon className="h-4 w-4" />
</a>
<ModeToggle />
</div>
{"中華郵政"}
<ArrowUpRight
aria-hidden="true"
className="size-3.5 opacity-60"
/>
</BracketBadge>
</div>
</Card>
<AddressForm cities={cities} {...addressState} />
</div>
<h1 className="enter-fx mt-6 text-[2rem] leading-[1.15] font-semibold tracking-tight [--enter-delay:180ms] sm:text-4xl lg:text-[2.5rem]">
{"台灣地址英譯"}
<br />
{"與 3+3 郵遞區號查詢"}
</h1>
<div className="enter-fx mt-8 [--enter-delay:320ms]">
<AddressForm cities={cities} {...addressState} />
</div>
</div>

{/* Always in the DOM — SSR grid is correct, zero layout shift.
CSS `hidden lg:block` toggles visibility without JS. */}
<div className="hidden lg:block">
<div className="sticky top-0 h-screen">
{mapState.status === "ready" ? (
<LazyMap
Component={mapState.Component}
city={addressState.state.city}
district={addressState.state.district}
/>
) : mapState.status === "error" ? (
<MapError onRetry={retry} />
) : (
<MapPlaceholder />
)}
{/* Always in the DOM so the SSR layout is final; `hidden lg:block`
toggles it without JS. The panel bleeds to the viewport's right
edge: 100cqw is the page width, 100% the centered container's.
The inner wrapper sticks under the header and never outgrows the
viewport, so the camera only frames what is on screen. Only the
canvas fades out at the edges; labels and controls stay crisp. */}
<div className="isolate hidden lg:absolute lg:inset-y-0 lg:right-[calc((100%_-_100cqw)/2)] lg:left-[42%] lg:block">
<div className="[&_.maplibregl-canvas]:map-fade sticky top-14 h-[min(100%,100dvh_-_3.5rem)]">
{mapState.status === "ready" ? (
<LazyMap
Component={mapState.Component}
city={addressState.state.city}
district={addressState.state.district}
cities={cities}
/>
) : mapState.status === "error" ? (
<MapError onRetry={retry} />
) : (
<MapPlaceholder />
)}
</div>
</div>
</div>
</div>
Expand Down
46 changes: 46 additions & 0 deletions apps/web/src/components/bracket-badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";

const corner =
"pointer-events-none absolute size-[9px] border-black dark:border-neutral-300";

interface BracketBadgeProps {
href: string;
children: ReactNode;
className?: string;
}

/** External link badge framed by four corner brackets. */
export function BracketBadge({ href, children, className }: BracketBadgeProps) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={cn(
"group relative inline-flex focus-visible:outline-2 focus-visible:outline-offset-[6px] motion-safe:transition-transform motion-safe:duration-200 motion-safe:ease-out motion-safe:hover:scale-[1.03] motion-safe:active:scale-[0.97]",
className,
)}
>
<span
aria-hidden="true"
className={cn(corner, "-top-1 -left-1 border-t-2 border-l-2")}
/>
<span
aria-hidden="true"
className={cn(corner, "-top-1 -right-1 border-t-2 border-r-2")}
/>
<span
aria-hidden="true"
className={cn(corner, "-bottom-1 -left-1 border-b-2 border-l-2")}
/>
<span
aria-hidden="true"
className={cn(corner, "-right-1 -bottom-1 border-r-2 border-b-2")}
/>
<span className="inline-flex h-[26px] items-center gap-1.5 bg-[#ececec] px-2.5 text-[13px] text-black transition-colors group-hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-100 dark:group-hover:bg-neutral-700">
{children}
</span>
</a>
);
}
Loading
Loading