first commit

This commit is contained in:
2026-09-11 15:49:41 +08:00
commit 5b69bc818a
98 changed files with 30551 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
# src/AGENTS.md
## Purpose
`src/` 係全疊應用程式碼:Astro 路由、React/Chakra 前台 UI、D1/Drizzle 資料層、基礎工具,以及 theme 同 layout。
## Ownership
- 擁有 `src/` 全層,亦直接擁有以下冇獨立 child doc 的部分:
- `theme/system.ts` — Chakra systemtokens / semanticTokens / fonts / globalCss),前台顏色同字體的唯一來源。現行設計:editorial 極簡風(暖米白 `#FAF8F4` 底、琥珀金 `brand` accent、墨黑 `ink`、Noto Serif HK 標題),詳見 `docs/superpowers/specs/2026-09-11-editorial-redesign-design.md`
- `layouts/Base.astro` — 前台 SEO headcanonical / OG / Twitter / sitemap link)+ Noto Sans HK。
- `layouts/AdminLayout.astro` — 後台外框,純 CSS、與前台一致的色系。
- `config.ts` — build-time 靜態頁用的 `SITE_NAME` / `SITE_TAGLINE`(可管理內容一律放 D1,唔放呢度)。
- `middleware.ts` — 保護 `/admin`:設 `locals.isAdmin`,無 `ADMIN_PASSWORD` 回 503,未登入導向 `/admin/login`
- `env.d.ts``App.Locals.isAdmin` 型別宣告。
- `pages/` — 路由層同 `/admin` 後台(詳見下面 Routes & SSR / Admin,因 Astro 限制冇獨立 child doc)。
## Local Contracts
- **架構**Astro 7`output: "static"` + per-page SSR+ `@astrojs/react` + Chakra UI v3Emotion),單一 Cloudflare Worker。
- **環境變數**:一律 `getEnv()``src/lib/env.ts`)。Astro v6 起已移除 `Astro.locals.runtime.env`
- **資料流**`.astro` 頁面 → `getDb(getEnv().DB)``src/data/content.ts` 查詢 → props 傳畀 React island。元件唔直接讀 DB。
- **語言**:所有 UI 文案、註解、後台文案都係繁體中文(廣東話),網站唔做雙語。
- **產生檔唔好手改**`dist/``.astro/``.wrangler/``worker-configuration.d.ts`
### Routes & SSR`pages/`
- **預設靜態**`astro.config.mjs``output: "static"`。要讀 D1 或 request-time 資料的頁面/endpoint 必須在檔案內寫 `export const prerender = false`
- 目前 SSR`index.astro``blog/index.astro``blog/[slug].astro``admin/**``sitemap.xml.ts`
- 保持靜態:`about.astro``robots.txt.ts`
- **環境變數**:只用 `getEnv()`,且只可喺 `prerender = false` 的頁面/endpoint 用。
- **前台動態頁**:讀 D1 → 傳 props 畀單一 React island`client:load`),並設邊緣快取 `Cache-Control: public, s-maxage=60, stale-while-revalidate=300`
- **404**`blog/[slug].astro` 找唔到文章 → 設 `Astro.response.status = 404` 並 render noindex 頁。
- **Endpoint**:用 `APIRoute``sitemap.xml.ts` 由 D1 讀已發布文章並設 `s-maxage=3600`
- **SEO**:用 `Base.astro`canonical / OG / sitemap 全部靠 `astro.config.mjs``site`(現為佔位 `https://example.com`,上線前要改)。
### Admin`pages/admin/`
- 所有 `/admin` 路由**必須** `export const prerender = false`
- **認證**由 `middleware.ts` 統一處理;登入喺 `admin/login.astro``checkPassword` + `createSession`),登出喺 `logout.ts`
- **UI**:全部用 `AdminLayout.astro`,純 Astro SSR 表單(`POST` + `formData`),**唔引入** React / Chakra。
- **流程**:每個 POST 處理完 `Astro.redirect` 返對應列表頁。
- **通用動作**`add` / `save` / `delete` / `up` / `down`(排序以交換 `sortOrder` 實作)。
- **通用內容編輯器**`content/[kind].astro``kind ∈ service | feature | step | faq`(見 `db/schema.ts``CONTENT_KINDS`),欄位標籤由檔案內 `META` 定義。
- **網站設定**`settings.astro``data/settings-fields.ts``SETTINGS_GROUPS` / `ALL_SETTING_KEYS` 產生表單,逐 key upsert。
- **文章**`index.astro` 列表、`post/[id].astro` 新增/編輯(`id === "new"` 為新增);slug 自動 `slugify` 並用 `uniqueSlug` 去重。
- **圖片**:一律以 URL 字串處理(暫無上傳)。
- 前台可見性靠 `status``published` / `draft`);列表頁顯示全部,前台只顯示 published。
## Work Guidance
- 改前台視覺先睇 `theme/system.ts` 的 tokens,優先重用語意 token,唔好散落硬編色值。
- 新增 endpoint 或 SSR 頁後,確認 `prerender = false` 同相應快取 header 都有。
- 新增一個內容欄位:先改 `db/schema.ts``npm run db:generate`,再改對應 admin 表單同 `data/content.ts`
- **唔好喺 `src/pages/``.md` 文件**(例如 AGENTS.md):Astro 會將佢變成公開路由(今次已實測 `/AGENTS``/admin/AGENTS`)。Astro 只支援用 `_` 前綴豁免,但同 DOX 的 `AGENTS.md` 命名衝突,所以 `pages/` 的合約一律寫喺本文件。其餘 `src/` 子目錄的 `AGENTS.md`data/db/lib/components)唔會被路由。
## Verification
- `npm run build`(唯一的 build/type 驗證;冇 test/lint/typecheck script)。
- `npm run dev`(:4321)目測前台/Blog/後台;測 `/blog/[slug]` 404、登入/登出流程。
## Child DOX Index
| Path | Scope |
|---|---|
| `components/site/AGENTS.md` | 前台 React island 與 Chakra UI 元件 |
| `data/AGENTS.md` | D1 讀取查詢層與後台設定欄位定義 |
| `db/AGENTS.md` | Drizzle schemamigration 的唯一來源) |
| `lib/AGENTS.md` | auth / env / db / markdown 基礎工具 |
+36
View File
@@ -0,0 +1,36 @@
# src/components/site/AGENTS.md
## Purpose
前台 React 元件(Astro island 內容),用 Chakra UI v3 建立一頁式官網、Blog 列表同文章頁。
## Ownership
- 擁有 `src/components/site/` 所有 `.tsx` 元件。
- 唔擁有資料讀取(由 `.astro` 傳 props)同 Chakra theme(屬父層 `theme/system.ts`)。
## Local Contracts
- **Chakra v3 + Astro**:一個頁面只掛一個 React root island。`Provider``<ChakraProvider value={system}>`)由 `SiteChrome`(首頁)或 `BlogIndex` / `BlogPost` 包住。**唔可以用 `client:only`**(會失去 SEO)。
- **版型**`Section`(統一 max-width / padding / 背景 tone)+ `SectionHeading` 包每個 section。
- **Chrome**`SiteChrome` 組合 `Header` + `<main>` + `Footer` + `WhatsAppFab`Blog 頁自行組合同一批元件。
- **資料**:全部經 props 傳入(型別來自 `data/content.ts` / `db/schema.ts`);元件**唔直接讀 DB**。
- **樣式 token**:用 `theme/system.ts` 的語意 token`brand.*`=琥珀金、`ink`=墨黑、`fg.muted``border.subtle``bg.subtle`),避免散落硬編色值(現時仍有少量硬編 hex,新增時優先跟 token)。
- **設計風格**editorial 極簡風(見 `docs/superpowers/specs/2026-09-11-editorial-redesign-design.md`)——暖米白底、Noto Serif HK 標題、髮絲線分隔、編號式清單。**禁止**:漸層、背景光斑、`rounded-full` 膠囊掣、pill badge 眉題、陰影浮卡、icon 圓角磚。
- **圖示**`lucide-react` 幼線 icon`strokeWidth={1.5}` 為佳);內容項目**唔再用** `extra` 圖示代碼(`service``feature``extra` 已停用;`step``extra` 係步驟編號 `01``05`)。
- **響應式**:用 Chakra 的 `base` / `md` / `lg` object syntax。
- 文案一律繁體中文(廣東話)。
## Work Guidance
- 新增首頁 section:建立元件 → 喺 `HomePage.tsx` 接入 → 資料由 `getHomeData``data/content.ts`)提供。
- 新元件唔好各自建立 `Provider`,跟返現有最外層包法。
## Verification
- `npm run build`
- `npm run dev` 目測 RWD、錨點、FAQ accordion、WhatsApp link、無 console / hydration error。
## Child DOX Index
無。
+140
View File
@@ -0,0 +1,140 @@
import { Box, Container, Grid, HStack, Heading, Image, Stack, Text } from "@chakra-ui/react";
import { ArrowRight, CalendarDays } from "lucide-react";
import type { Settings } from "../../data/content";
import { Footer } from "./Footer";
import { Header } from "./Header";
import { Provider } from "./Provider";
import { WhatsAppFab } from "./WhatsAppFab";
export type BlogListItem = {
slug: string;
title: string;
excerpt: string | null;
coverImage: string | null;
dateLabel: string;
};
export function BlogIndex({ posts, settings }: { posts: BlogListItem[]; settings: Settings }) {
const [featured, ...rest] = posts;
return (
<Provider>
<Header settings={settings} />
<Box as="main" py={{ base: 14, md: 20 }} minH="60vh">
<Container maxW="1200px" px={{ base: 5, md: 8 }}>
<Stack gap="5" mb={{ base: 10, md: 14 }} pb={{ base: 8, md: 10 }} borderBottomWidth="2px" borderColor="ink">
<HStack gap="3" color="brand.700">
<Box w="8" h="2px" bg="brand.500" />
<Text fontSize="xs" fontWeight="700" letterSpacing="0.2em">
BLOG
</Text>
</HStack>
<Heading as="h1" fontSize={{ base: "4xl", md: "6xl" }} fontWeight="900" letterSpacing="-0.01em" color="ink">
</Heading>
<Text fontSize={{ base: "md", md: "lg" }} color="fg.muted" maxW="2xl">
</Text>
</Stack>
{posts.length === 0 ? (
<Text color="fg.muted"></Text>
) : (
<Stack gap={{ base: 10, md: 14 }}>
{/* 最新文章 featured */}
<Grid
asChild
templateColumns={{ base: "1fr", md: "1fr 1fr" }}
gap={{ base: 5, md: 10 }}
alignItems="center"
role="group"
cursor="pointer"
>
<a href={`/blog/${featured.slug}`}>
{featured.coverImage && (
<Box overflow="hidden">
<Image
src={featured.coverImage}
alt={featured.title}
w="full"
aspectRatio="16 / 10"
objectFit="cover"
transition="transform .5s"
_groupHover={{ transform: "scale(1.03)" }}
/>
</Box>
)}
<Stack gap="3">
<HStack gap="2" fontSize="xs" letterSpacing="0.1em" color="fg.muted" fontWeight="medium">
<CalendarDays size={13} />
{featured.dateLabel}
</HStack>
<Heading as="h2" fontSize={{ base: "xl", md: "3xl" }} fontWeight="900" color="ink" lineHeight="1.3" _groupHover={{ color: "brand.700" }} transition="color .2s">
{featured.title}
</Heading>
{featured.excerpt && (
<Text fontSize="sm" color="fg.muted" lineHeight="1.9">
{featured.excerpt}
</Text>
)}
<Text color="brand.700" fontWeight="600" fontSize="sm">
<ArrowRight size={14} style={{ display: "inline", verticalAlign: "-2px" }} />
</Text>
</Stack>
</a>
</Grid>
{/* 其餘文章:髮絲線清單 */}
{rest.length > 0 && (
<Stack gap="0" borderTopWidth="1px" borderColor="border.subtle">
{rest.map((post) => (
<Grid
key={post.slug}
asChild
templateColumns={{ base: "1fr", md: "140px 1fr auto" }}
gap={{ base: 2, md: 8 }}
alignItems={{ md: "center" }}
py={{ base: 5, md: 6 }}
borderBottomWidth="1px"
borderColor="border.subtle"
role="group"
cursor="pointer"
>
<a href={`/blog/${post.slug}`}>
<HStack gap="2" fontSize="xs" letterSpacing="0.08em" color="fg.muted" fontWeight="medium">
<CalendarDays size={13} />
{post.dateLabel}
</HStack>
<Stack gap="1">
<Heading as="h2" fontSize={{ base: "lg", md: "xl" }} fontWeight="700" color="ink" lineHeight="1.4" _groupHover={{ color: "brand.700" }} transition="color .2s">
{post.title}
</Heading>
{post.excerpt && (
<Text fontSize="sm" color="fg.muted" lineHeight="1.7" lineClamp={2}>
{post.excerpt}
</Text>
)}
</Stack>
<Box
color="brand.700"
opacity={{ base: 1, md: 0 }}
transform={{ md: "translateX(-6px)" }}
transition="all .2s"
_groupHover={{ opacity: 1, transform: "translateX(0)" }}
>
<ArrowRight size={20} />
</Box>
</a>
</Grid>
))}
</Stack>
)}
</Stack>
)}
</Container>
</Box>
<Footer settings={settings} />
<WhatsAppFab settings={settings} />
</Provider>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { Box, Button, Container, Heading, Image, Link, Text, VStack } from "@chakra-ui/react";
import { ArrowLeft, CalendarDays } from "lucide-react";
import type { Settings } from "../../data/content";
import { Footer } from "./Footer";
import { Header } from "./Header";
import { Provider } from "./Provider";
import { WhatsAppFab } from "./WhatsAppFab";
const prose = {
color: "#3B3835",
fontSize: "1.05rem",
lineHeight: 1.95,
"& h2": { fontFamily: "'Noto Serif HK', serif", fontSize: "1.6rem", fontWeight: 700, mt: "2.5rem", mb: "0.75rem", color: "#1A1917", letterSpacing: "-0.01em" },
"& h3": { fontFamily: "'Noto Serif HK', serif", fontSize: "1.25rem", fontWeight: 700, mt: "2rem", mb: "0.5rem", color: "#1A1917" },
"& p": { mb: "1.25rem" },
"& ul, & ol": { pl: "1.5rem", mb: "1.25rem" },
"& li": { mb: "0.4rem" },
"& a": { color: "#B45309", textDecoration: "underline", textUnderlineOffset: "3px" },
"& strong": { fontWeight: 700, color: "#1A1917" },
"& blockquote": { borderLeft: "3px solid #D97706", pl: "1rem", color: "#6E6862", my: "1.5rem" },
"& img": { my: "1.5rem" },
"& code": { bg: "#F3EFE7", px: "6px", py: "2px", fontSize: "0.9em" },
"& pre": { bg: "#1A1917", color: "#FAF8F4", p: "1rem", overflowX: "auto", mb: "1.5rem" },
};
type Props = {
title: string;
dateLabel: string;
coverImage: string | null;
html: string;
settings: Settings;
};
export function BlogPost({ title, dateLabel, coverImage, html, settings }: Props) {
return (
<Provider>
<Header settings={settings} />
<Box as="main" py={{ base: 10, md: 16 }}>
<Container maxW="780px" px={{ base: 5, md: 8 }}>
<Link
href="/blog"
display="inline-flex"
alignItems="center"
gap="2"
fontSize="sm"
fontWeight="medium"
color="fg.muted"
mb="8"
_hover={{ color: "brand.700", textDecoration: "none" }}
>
<ArrowLeft size={16} />
Blog
</Link>
<VStack align="flex-start" gap="5" mb="10" pb="8" borderBottomWidth="2px" borderColor="ink">
<Heading as="h1" fontSize={{ base: "3xl", md: "5xl" }} fontWeight="900" letterSpacing="-0.01em" lineHeight="1.25" color="ink">
{title}
</Heading>
{dateLabel && (
<Text fontSize="xs" letterSpacing="0.1em" fontWeight="medium" color="fg.muted">
<CalendarDays size={14} style={{ display: "inline", marginRight: 6, verticalAlign: "-2px" }} />
{dateLabel}
</Text>
)}
</VStack>
{coverImage && (
<Image src={coverImage} alt={title} w="full" mb="10" objectFit="cover" />
)}
<Box css={prose} dangerouslySetInnerHTML={{ __html: html }} />
<Button asChild variant="outline" mt="12" rounded="none" borderColor="ink" color="ink" _hover={{ bg: "ink", color: "white" }}>
<a href="/blog">
<ArrowLeft size={16} />
</a>
</Button>
</Container>
</Box>
<Footer settings={settings} />
<WhatsAppFab settings={settings} />
</Provider>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { Box, Grid, Heading, HStack, Image, Stack, Text } from "@chakra-ui/react";
import { MapPin } from "lucide-react";
import type { CaseStudy } from "../../db/schema";
import { Section } from "./Section";
import { SectionHeading } from "./SectionHeading";
export function Cases({ items }: { items: CaseStudy[] }) {
if (items.length === 0) return null;
const [featured, ...rest] = items;
return (
<Section id="cases" tone="white">
<SectionHeading
eyebrow="完成案例"
title="真實村屋工程案例"
description="我哋為香港各區村屋提供專業太陽能方案,累積豐富施工經驗。"
/>
<Stack gap={{ base: 12, md: 16 }}>
{/* 首案例 featured:雜誌式大圖排版 */}
<Grid
templateColumns={{ base: "1fr", lg: "1.2fr 0.8fr" }}
gap={{ base: 5, lg: 10 }}
alignItems="end"
role="group"
>
<Box overflow="hidden">
<Image
src={featured.imageUrl ?? undefined}
alt={featured.title}
w="full"
aspectRatio={{ base: "4 / 3", lg: "16 / 10" }}
objectFit="cover"
transition="transform .5s"
_groupHover={{ transform: "scale(1.03)" }}
/>
</Box>
<Stack gap="3">
<HStack gap="3" fontSize="xs" letterSpacing="0.1em" color="fg.muted" fontWeight="medium">
<Text>{featured.completedAt}</Text>
{featured.location && (
<HStack gap="1" color="brand.700">
<MapPin size={12} />
<Text>{featured.location}</Text>
</HStack>
)}
</HStack>
<Heading as="h3" fontSize={{ base: "xl", md: "2xl" }} fontWeight="700" color="ink" lineHeight="1.35" _groupHover={{ color: "brand.700" }} transition="color .2s">
{featured.title}
</Heading>
{featured.description && (
<Text fontSize="sm" color="fg.muted" lineHeight="1.9">
{featured.description}
</Text>
)}
</Stack>
</Grid>
{rest.length > 0 && (
<Grid templateColumns={{ base: "1fr", md: "repeat(2, 1fr)", lg: `repeat(${Math.min(rest.length, 3)}, 1fr)` }} gap={{ base: 10, md: 8 }}>
{rest.map((item) => (
<Stack key={item.id} gap="3" role="group" borderTopWidth="2px" borderColor="ink" pt="5">
<Box overflow="hidden">
<Image
src={item.imageUrl ?? undefined}
alt={item.title}
w="full"
aspectRatio="4 / 3"
objectFit="cover"
transition="transform .5s"
_groupHover={{ transform: "scale(1.03)" }}
/>
</Box>
<HStack gap="3" fontSize="xs" letterSpacing="0.1em" color="fg.muted" fontWeight="medium">
<Text>{item.completedAt}</Text>
{item.location && (
<HStack gap="1" color="brand.700">
<MapPin size={12} />
<Text>{item.location}</Text>
</HStack>
)}
</HStack>
<Heading as="h3" fontSize="lg" fontWeight="700" color="ink" lineHeight="1.4" _groupHover={{ color: "brand.700" }} transition="color .2s">
{item.title}
</Heading>
{item.description && (
<Text fontSize="sm" color="fg.muted" lineHeight="1.8">
{item.description}
</Text>
)}
</Stack>
))}
</Grid>
)}
</Stack>
</Section>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { Box, Button, Container, Grid, HStack, Heading, Stack, Text } from "@chakra-ui/react";
import { Mail, MapPin, Phone } from "lucide-react";
import { mailHref, telHref, whatsappHref, type Settings } from "../../data/content";
import { WhatsAppIcon } from "./icons";
export function ContactCta({ settings }: { settings: Settings }) {
const contact = [
{ icon: Phone, label: settings.phone, href: telHref(settings) },
{ icon: Mail, label: settings.email, href: mailHref(settings) },
];
return (
<Box id="contact" bg="ink" color="white" py={{ base: 16, md: 24 }} scrollMarginTop="80px">
<Container maxW="1200px" px={{ base: 5, md: 8 }}>
<Grid templateColumns={{ base: "1fr", lg: "1.2fr 0.8fr" }} gap={{ base: 10, lg: 16 }} alignItems="center">
<Stack gap="6" align="flex-start">
<HStack gap="3" color="brand.400">
<Box w="8" h="2px" bg="brand.400" />
<Text fontSize="xs" fontWeight="700" letterSpacing="0.2em">
</Text>
</HStack>
<Heading as="h2" fontSize={{ base: "3xl", md: "5xl" }} fontWeight="900" letterSpacing="-0.01em" lineHeight="1.2">
</Heading>
<Text fontSize={{ base: "md", md: "lg" }} color="whiteAlpha.700" maxW="xl">
WhatsApp
</Text>
<HStack gap="4" flexWrap="wrap" pt="2">
<Button asChild size="lg" rounded="none" px="8" bg="brand.600" color="white" fontWeight="700" _hover={{ bg: "brand.500" }}>
<a href={whatsappHref(settings)} target="_blank" rel="noopener">
<WhatsAppIcon style={{ width: 18, height: 18 }} />
WhatsApp
</a>
</Button>
<Button asChild variant="outline" rounded="none" borderColor="whiteAlpha.500" color="white" size="lg" px="8" fontWeight="700" _hover={{ bg: "whiteAlpha.100" }}>
<a href={telHref(settings)}>
<Phone size={18} />
</a>
</Button>
</HStack>
</Stack>
<Stack gap="5" borderLeftWidth={{ lg: "1px" }} borderColor="whiteAlpha.200" pl={{ lg: 12 }}>
{contact.map((c) => (
<HStack key={c.label} gap="3" fontSize="sm" color="whiteAlpha.800">
<Box color="brand.400">
<c.icon size={16} />
</Box>
<Text>{c.label}</Text>
</HStack>
))}
<HStack gap="3" align="flex-start" fontSize="sm" color="whiteAlpha.800">
<Box color="brand.400" mt="1">
<MapPin size={16} />
</Box>
<Text>{settings.address}</Text>
</HStack>
</Stack>
</Grid>
</Container>
</Box>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { Accordion, Box, Text } from "@chakra-ui/react";
import { Plus } from "lucide-react";
import type { ContentItem } from "../../db/schema";
import { Section } from "./Section";
import { SectionHeading } from "./SectionHeading";
export function Faq({ items }: { items: ContentItem[] }) {
if (items.length === 0) return null;
return (
<Section id="faq">
<SectionHeading
eyebrow="常見問題"
title="安裝前,你可能想知嘅事"
description="仲有其他疑問?歡迎隨時 WhatsApp 問我哋。"
/>
<Box maxW="860px">
<Accordion.Root collapsible defaultValue={["faq-0"]}>
{items.map((item, index) => (
<Accordion.Item
key={item.id}
value={`faq-${index}`}
borderTopWidth="1px"
borderColor="border.subtle"
_last={{ borderBottomWidth: "1px" }}
>
<Accordion.ItemTrigger py="6" px="0" _hover={{ bg: "transparent" }}>
<Text flex="1" textAlign="start" fontFamily="heading" fontWeight="700" color="ink" fontSize={{ base: "md", md: "lg" }}>
{item.title}
</Text>
<Accordion.ItemIndicator color="brand.700" _open={{ transform: "rotate(45deg)" }}>
<Plus size={18} />
</Accordion.ItemIndicator>
</Accordion.ItemTrigger>
<Accordion.ItemContent>
<Accordion.ItemBody px="0" pb="7" pt="0" color="fg.muted" fontSize="sm" lineHeight="1.9" maxW="2xl">
{item.description}
</Accordion.ItemBody>
</Accordion.ItemContent>
</Accordion.Item>
))}
</Accordion.Root>
</Box>
</Section>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { Box, Heading, SimpleGrid, Stack, Text } from "@chakra-ui/react";
import type { ContentItem } from "../../db/schema";
import { Section } from "./Section";
import { SectionHeading } from "./SectionHeading";
export function Features({ items }: { items: ContentItem[] }) {
return (
<Section id="why" tone="subtle">
<SectionHeading
eyebrow="點解揀我哋"
title="結合專業工程同貼心溝通"
description="我哋唔止做工程,仲重視你由頭到尾嘅體驗同安心。"
/>
<SimpleGrid
columns={{ base: 1, sm: 2, lg: 3 }}
gap="0"
borderWidth="1px"
borderColor="border.subtle"
bg="white"
>
{items.map((item, i) => (
<Stack
key={item.id}
gap="3"
p={{ base: 6, md: 8 }}
borderTopWidth={{ base: i === 0 ? "0" : "1px", sm: i < 2 ? "0" : "1px", lg: i < 3 ? "0" : "1px" }}
borderLeftWidth={{
base: "0",
sm: i % 2 === 1 ? "1px" : "0",
lg: i % 3 === 0 ? "0" : "1px",
}}
borderColor="border.subtle"
>
<Text fontFamily="heading" fontWeight="700" fontSize="sm" letterSpacing="0.1em" color="brand.600">
{String(i + 1).padStart(2, "0")}
</Text>
<Heading as="h3" fontSize="lg" fontWeight="700" color="ink">
{item.title}
</Heading>
<Text fontSize="sm" color="fg.muted" lineHeight="1.8">
{item.description}
</Text>
</Stack>
))}
</SimpleGrid>
</Section>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { Box, Container, HStack, Link, Stack, Text, VStack } from "@chakra-ui/react";
import { Mail, MapPin, Phone } from "lucide-react";
import { mailHref, telHref, whatsappHref, type Settings } from "../../data/content";
import { WhatsAppIcon } from "./icons";
export function Footer({ settings }: { settings: Settings }) {
const name = settings.company_name || "盈豐太陽能工程有限公司";
const shortName = settings.company_short_name || settings.company_name || "盈豐太陽能";
const year = new Date().getFullYear();
return (
<Box as="footer" bg="ink" color="white" borderTopWidth="1px" borderColor="whiteAlpha.200" pt={{ base: 12, md: 16 }} pb="8">
<Container maxW="1200px" px={{ base: 5, md: 8 }}>
<Stack
direction={{ base: "column", md: "row" }}
justify="space-between"
gap={{ base: 10, md: 16 }}
>
<VStack align="flex-start" gap="4" maxW="sm">
<Text fontFamily="heading" fontWeight="900" fontSize="xl" letterSpacing="0.02em">
{shortName}
</Text>
<Text color="whiteAlpha.600" fontSize="sm" lineHeight="1.8">
</Text>
<Text color="whiteAlpha.500" fontSize="xs" letterSpacing="0.08em">
{settings.license_no}
</Text>
</VStack>
<HStack gap={{ base: 10, md: 16 }} align="flex-start">
<VStack align="flex-start" gap="3">
<Text fontWeight="700" fontSize="xs" letterSpacing="0.15em" color="whiteAlpha.500">
</Text>
{[
{ label: "服務範圍", href: "#services" },
{ label: "完成案例", href: "#cases" },
{ label: "服務流程", href: "#process" },
{ label: "常見問題", href: "#faq" },
{ label: "Blog", href: "/blog" },
].map((l) => (
<Link
key={l.href}
href={l.href}
fontSize="sm"
color="whiteAlpha.700"
_hover={{ color: "white", textDecoration: "none" }}
>
{l.label}
</Link>
))}
</VStack>
<VStack align="flex-start" gap="3">
<Text fontWeight="700" fontSize="xs" letterSpacing="0.15em" color="whiteAlpha.500">
</Text>
<HStack gap="2" color="whiteAlpha.700" fontSize="sm">
<Phone size={16} />
<Link href={telHref(settings)} color="whiteAlpha.700" _hover={{ color: "white", textDecoration: "none" }}>
{settings.phone}
</Link>
</HStack>
<HStack gap="2" color="whiteAlpha.700" fontSize="sm">
<WhatsAppIcon style={{ width: 16, height: 16 }} />
<Link href={whatsappHref(settings)} target="_blank" rel="noopener" color="whiteAlpha.700" _hover={{ color: "white", textDecoration: "none" }}>
WhatsApp
</Link>
</HStack>
<HStack gap="2" color="whiteAlpha.700" fontSize="sm">
<Mail size={16} />
<Link href={mailHref(settings)} color="whiteAlpha.700" _hover={{ color: "white", textDecoration: "none" }}>
{settings.email}
</Link>
</HStack>
<HStack gap="2" color="whiteAlpha.700" fontSize="sm" align="flex-start">
<MapPin size={16} style={{ marginTop: 3 }} />
<Text>{settings.address}</Text>
</HStack>
</VStack>
</HStack>
</Stack>
<Box borderTopWidth="1px" borderColor="whiteAlpha.200" mt="12" pt="6">
<HStack justify="space-between" flexWrap="wrap" gap="2">
<Text fontSize="xs" color="whiteAlpha.500">
© {year} {name} All rights reserved.
</Text>
<Link href="/admin" fontSize="xs" color="whiteAlpha.500" _hover={{ color: "whiteAlpha.900", textDecoration: "none" }}>
</Link>
</HStack>
</Box>
</Container>
</Box>
);
}
+150
View File
@@ -0,0 +1,150 @@
import {
Box,
Button,
CloseButton,
Container,
Drawer,
Flex,
HStack,
IconButton,
Link,
Portal,
Stack,
Text,
} from "@chakra-ui/react";
import { Menu, Phone } from "lucide-react";
import { telHref, whatsappHref, type Settings } from "../../data/content";
import { WhatsAppIcon } from "./icons";
const NAV = [
{ label: "服務", href: "#services" },
{ label: "完成案例", href: "#cases" },
{ label: "服務流程", href: "#process" },
{ label: "常見問題", href: "#faq" },
{ label: "Blog", href: "/blog" },
];
export function Header({ settings }: { settings: Settings }) {
const name = settings.company_short_name || settings.company_name || "盈豐太陽能";
const wa = whatsappHref(settings);
return (
<Box
as="header"
position="sticky"
top="0"
zIndex="sticky"
bg="#FAF8F4"
borderBottomWidth="1px"
borderColor="border.subtle"
>
<Container maxW="1200px" px={{ base: 5, md: 8 }}>
<Flex h="72px" align="center" justify="space-between" gap="4">
<Stack asChild gap="0.5" cursor="pointer" lineHeight="1.2">
<a href="/">
<Text fontFamily="heading" fontWeight="900" fontSize="xl" letterSpacing="0.02em" color="ink">
{name}
</Text>
<Text fontSize="11px" letterSpacing="0.12em" color="fg.muted" display={{ base: "none", sm: "block" }}>
{settings.license_no || "村屋太陽能專家"}
</Text>
</a>
</Stack>
<HStack gap="8" display={{ base: "none", lg: "flex" }}>
{NAV.map((item) => (
<Link
key={item.href}
href={item.href}
fontSize="sm"
fontWeight="medium"
color="ink"
_hover={{ color: "brand.700", textDecoration: "none" }}
>
{item.label}
</Link>
))}
</HStack>
<HStack gap="5">
<Link
href={telHref(settings)}
display={{ base: "none", md: "flex" }}
alignItems="center"
gap="2"
fontSize="sm"
fontWeight="semibold"
color="ink"
_hover={{ textDecoration: "none", color: "brand.700" }}
>
<Phone size={16} />
{settings.phone}
</Link>
<Button
asChild
size="sm"
rounded="none"
px="5"
bg="ink"
color="white"
_hover={{ bg: "brand.700" }}
display={{ base: "none", sm: "inline-flex" }}
>
<a href={wa} target="_blank" rel="noopener">
<WhatsAppIcon style={{ width: 16, height: 16 }} />
</a>
</Button>
<Drawer.Root>
<Drawer.Trigger asChild>
<IconButton aria-label="開啟選單" variant="ghost" display={{ base: "inline-flex", lg: "none" }}>
<Menu />
</IconButton>
</Drawer.Trigger>
<Portal>
<Drawer.Backdrop />
<Drawer.Positioner>
<Drawer.Content bg="#FAF8F4">
<Drawer.Header borderBottomWidth="1px" borderColor="border.subtle">
<Drawer.Title fontFamily="heading" fontWeight="900">{name}</Drawer.Title>
</Drawer.Header>
<Drawer.Body>
<Stack gap="1">
{NAV.map((item) => (
<Link
key={item.href}
href={item.href}
py="3"
fontWeight="medium"
color="ink"
borderBottomWidth="1px"
borderColor="border.subtle"
_hover={{ textDecoration: "none", color: "brand.700" }}
>
{item.label}
</Link>
))}
</Stack>
</Drawer.Body>
<Drawer.Footer pb="8">
<Button asChild width="full" rounded="none" bg="ink" color="white" _hover={{ bg: "brand.700" }}>
<a href={wa} target="_blank" rel="noopener">
<WhatsAppIcon style={{ width: 18, height: 18 }} />
WhatsApp
</a>
</Button>
</Drawer.Footer>
<Drawer.CloseTrigger asChild>
<CloseButton size="sm" />
</Drawer.CloseTrigger>
</Drawer.Content>
</Drawer.Positioner>
</Portal>
</Drawer.Root>
</HStack>
</Flex>
</Container>
</Box>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { Box, Button, Container, Grid, HStack, Heading, Image, Link, SimpleGrid, Stack, Text, VStack } from "@chakra-ui/react";
import { ArrowRight, Check } from "lucide-react";
import { whatsappHref, type Settings } from "../../data/content";
import { WhatsAppIcon } from "./icons";
const TRUST = ["註冊電業承辦商", "舊客口碑介紹", "一對一專人跟進"];
export function Hero({ settings }: { settings: Settings }) {
const stats = [
{ label: "一般回本期", value: "約 69 年" },
{ label: "系統可用年期", value: "2025 年" },
{ label: "牌照", value: settings.license_no || "註冊電業承辦商" },
];
return (
<Box pt={{ base: 12, md: 20 }} pb={{ base: 16, md: 24 }}>
<Container maxW="1200px" px={{ base: 5, md: 8 }}>
<Grid templateColumns={{ base: "1fr", lg: "1.05fr 0.95fr" }} gap={{ base: 12, lg: 20 }} alignItems="center">
<VStack align="flex-start" gap="7">
<HStack gap="3" color="brand.700">
<Box w="8" h="2px" bg="brand.500" />
<Text fontSize="xs" fontWeight="700" letterSpacing="0.2em">
{settings.hero_eyebrow || "香港村屋太陽能專家"}
</Text>
</HStack>
<Heading
as="h1"
fontSize={{ base: "4xl", sm: "5xl", md: "6xl" }}
fontWeight="900"
letterSpacing="-0.01em"
lineHeight="1.15"
color="ink"
>
{settings.hero_title}
</Heading>
<Text fontSize={{ base: "md", md: "lg" }} color="fg.muted" maxW="xl">
{settings.hero_subtitle}
</Text>
<HStack gap="6" flexWrap="wrap" pt="1" align="center">
<Button asChild size="lg" rounded="none" px="8" bg="ink" color="white" fontWeight="semibold" _hover={{ bg: "brand.700" }}>
<a href={whatsappHref(settings)} target="_blank" rel="noopener">
<WhatsAppIcon style={{ width: 18, height: 18 }} />
{settings.hero_primary_cta || "免費預約評估"}
</a>
</Button>
<Link
href="#cases"
fontWeight="semibold"
color="ink"
borderBottomWidth="2px"
borderColor="brand.500"
pb="0.5"
_hover={{ color: "brand.700", textDecoration: "none" }}
>
{settings.hero_secondary_cta || "睇完成案例"} <ArrowRight size={15} style={{ display: "inline", verticalAlign: "-2px" }} />
</Link>
</HStack>
<HStack gap={{ base: "4", md: "6" }} flexWrap="wrap" pt="2">
{TRUST.map((t) => (
<HStack key={t} gap="2" color="fg.muted" fontSize="sm">
<Box color="brand.600">
<Check size={15} strokeWidth={3} />
</Box>
<Text>{t}</Text>
</HStack>
))}
</HStack>
</VStack>
<Stack gap="0">
<Image
src={settings.hero_image}
alt="村屋天台太陽能系統"
w="full"
aspectRatio="4 / 3"
objectFit="cover"
/>
<SimpleGrid
columns={3}
borderTopWidth="2px"
borderColor="ink"
pt="5"
mt="6"
gap="4"
>
{stats.map((s) => (
<Stack key={s.label} gap="1">
<Text fontSize="xs" letterSpacing="0.08em" color="fg.muted">
{s.label}
</Text>
<Text fontFamily="heading" fontWeight="700" fontSize={{ base: "md", md: "lg" }} color="brand.700">
{s.value}
</Text>
</Stack>
))}
</SimpleGrid>
</Stack>
</Grid>
</Container>
</Box>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { HomeData } from "../../data/content";
import { Cases } from "./Cases";
import { ContactCta } from "./ContactCta";
import { Faq } from "./Faq";
import { Features } from "./Features";
import { Hero } from "./Hero";
import { Process } from "./Process";
import { Services } from "./Services";
import { SiteChrome } from "./SiteChrome";
import { TrustBar } from "./TrustBar";
export function HomePage({ data }: { data: HomeData }) {
const { settings, services, features, steps, faqs, cases } = data;
return (
<SiteChrome settings={settings}>
<Hero settings={settings} />
<TrustBar />
<Services items={services} />
<Features items={features} />
<Process items={steps} />
<Cases items={cases} />
<Faq items={faqs} />
<ContactCta settings={settings} />
</SiteChrome>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { Box, SimpleGrid, Stack, Text } from "@chakra-ui/react";
import type { ContentItem } from "../../db/schema";
import { Section } from "./Section";
import { SectionHeading } from "./SectionHeading";
export function Process({ items }: { items: ContentItem[] }) {
return (
<Section id="process">
<SectionHeading
eyebrow="服務流程"
title="專業安裝流程 5 步到位"
description="清晰透明嘅流程,每一步都有專人跟進,你唔需要自己四圍撲。"
/>
<Box>
<SimpleGrid columns={{ base: 1, md: 5 }} gap={{ base: 10, md: 6 }}>
{items.map((item) => (
<Stack
key={item.id}
align="flex-start"
textAlign="left"
gap="3"
>
<Box
fontFamily="heading"
fontWeight="900"
fontSize="5xl"
lineHeight="1"
color="brand.600"
>
{item.extra}
</Box>
<Text fontFamily="heading" fontWeight="700" fontSize="lg" color="ink">
{item.title}
</Text>
<Text fontSize="sm" color="fg.muted" lineHeight="1.75">
{item.description}
</Text>
</Stack>
))}
</SimpleGrid>
</Box>
</Section>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { ChakraProvider } from "@chakra-ui/react";
import type { ReactNode } from "react";
import { system } from "../../theme/system";
export function Provider({ children }: { children: ReactNode }) {
return <ChakraProvider value={system}>{children}</ChakraProvider>;
}
+26
View File
@@ -0,0 +1,26 @@
import { Box, Container, type BoxProps } from "@chakra-ui/react";
import type { ReactNode } from "react";
type SectionProps = BoxProps & {
id?: string;
tone?: "paper" | "subtle" | "white";
children: ReactNode;
};
export function Section({ id, tone = "paper", children, ...rest }: SectionProps) {
const bg = tone === "subtle" ? "bg.subtle" : tone === "white" ? "white" : "transparent";
return (
<Box
as="section"
id={id}
bg={bg}
py={{ base: 16, md: 24 }}
scrollMarginTop="80px"
{...rest}
>
<Container maxW="1200px" px={{ base: 5, md: 8 }}>
{children}
</Container>
</Box>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { Box, Heading, HStack, Stack, Text } from "@chakra-ui/react";
type Props = {
eyebrow?: string;
title: string;
description?: string;
align?: "center" | "start";
};
export function SectionHeading({ eyebrow, title, description, align = "start" }: Props) {
return (
<Stack gap="5" textAlign={align} align={align === "center" ? "center" : "flex-start"} mb={{ base: 10, md: 14 }}>
{eyebrow && (
<HStack gap="3" color="brand.700">
<Box w="8" h="2px" bg="brand.500" />
<Text fontSize="xs" fontWeight="700" letterSpacing="0.2em">
{eyebrow}
</Text>
</HStack>
)}
<Heading
as="h2"
fontSize={{ base: "3xl", md: "5xl" }}
fontWeight="900"
letterSpacing="-0.01em"
lineHeight="1.2"
color="ink"
>
{title}
</Heading>
{description && (
<Text fontSize={{ base: "md", md: "lg" }} color="fg.muted" maxW="2xl">
{description}
</Text>
)}
</Stack>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { Box, Grid, Heading, Stack, Text } from "@chakra-ui/react";
import { ArrowRight } from "lucide-react";
import type { ContentItem } from "../../db/schema";
import { Section } from "./Section";
import { SectionHeading } from "./SectionHeading";
export function Services({ items }: { items: ContentItem[] }) {
return (
<Section id="services">
<SectionHeading
eyebrow="服務範圍"
title="專為村屋而設嘅太陽能方案"
description="由天台評估到掛表發電,我哋提供真正一站式嘅村屋太陽能服務。"
/>
<Stack gap="0" borderTopWidth="1px" borderColor="border.subtle">
{items.map((item, i) => (
<Grid
key={item.id}
templateColumns={{ base: "1fr", md: "80px 1fr 2fr 40px" }}
gap={{ base: 2, md: 8 }}
alignItems={{ md: "center" }}
py={{ base: 6, md: 8 }}
borderBottomWidth="1px"
borderColor="border.subtle"
role="group"
>
<Text
fontFamily="heading"
fontWeight="700"
fontSize={{ base: "lg", md: "2xl" }}
color="brand.600"
>
{String(i + 1).padStart(2, "0")}
</Text>
<Heading as="h3" fontSize={{ base: "xl", md: "2xl" }} fontWeight="700" color="ink" _groupHover={{ color: "brand.700" }} transition="color .2s">
{item.title}
</Heading>
<Text fontSize="sm" color="fg.muted" lineHeight="1.8">
{item.description}
</Text>
<Box
color="brand.700"
opacity="0"
transform="translateX(-6px)"
transition="all .2s"
_groupHover={{ opacity: 1, transform: "translateX(0)" }}
display={{ base: "none", md: "block" }}
>
<ArrowRight size={20} />
</Box>
</Grid>
))}
</Stack>
</Section>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { Box } from "@chakra-ui/react";
import type { ReactNode } from "react";
import type { Settings } from "../../data/content";
import { Footer } from "./Footer";
import { Header } from "./Header";
import { Provider } from "./Provider";
import { WhatsAppFab } from "./WhatsAppFab";
export function SiteChrome({ settings, children }: { settings: Settings; children: ReactNode }) {
return (
<Provider>
<Header settings={settings} />
<Box as="main">{children}</Box>
<Footer settings={settings} />
<WhatsAppFab settings={settings} />
</Provider>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { Box, Container, SimpleGrid, Stack, Text } from "@chakra-ui/react";
import { BadgeCheck, FileCheck2, ShieldCheck, Timer } from "lucide-react";
const ITEMS = [
{ icon: ShieldCheck, title: "註冊電業承辦商", desc: "合資格承辦,安全合法" },
{ icon: FileCheck2, title: "上網電價 FiT 代辦", desc: "中電/港燈申請全程跟進" },
{ icon: BadgeCheck, title: "認可人士 AP 簽發", desc: "C/PVS、WR1、GF1 文件" },
{ icon: Timer, title: "2025 年系統壽命", desc: "優質器材,長遠穩定" },
];
export function TrustBar() {
return (
<Box bg="white" borderY="1px solid" borderColor="border.subtle">
<Container maxW="1200px" px={{ base: 5, md: 8 }}>
<SimpleGrid columns={{ base: 1, sm: 2, md: 4 }} gap="0">
{ITEMS.map((item, i) => (
<Stack
key={item.title}
direction="row"
gap="4"
align="flex-start"
py={{ base: 5, md: 8 }}
px={{ md: 6 }}
borderTopWidth={{ base: i === 0 ? "0" : "1px", sm: i < 2 ? "0" : "1px", md: "0" }}
borderLeftWidth={{ base: "0", sm: i % 2 === 1 ? "1px" : "0", md: i === 0 ? "0" : "1px" }}
borderColor="border.subtle"
>
<Box color="brand.700" flexShrink="0" mt="0.5">
<item.icon size={24} strokeWidth={1.5} />
</Box>
<Stack gap="0.5">
<Text fontWeight="700" fontSize="sm" color="ink">
{item.title}
</Text>
<Text fontSize="xs" color="fg.muted">
{item.desc}
</Text>
</Stack>
</Stack>
))}
</SimpleGrid>
</Container>
</Box>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { Link } from "@chakra-ui/react";
import { whatsappHref, type Settings } from "../../data/content";
import { WhatsAppIcon } from "./icons";
export function WhatsAppFab({ settings }: { settings: Settings }) {
return (
<Link
href={whatsappHref(settings)}
target="_blank"
rel="noopener"
position="fixed"
bottom={{ base: "5", md: "7" }}
right={{ base: "5", md: "7" }}
zIndex="dropdown"
boxSize="56px"
rounded="full"
bg="ink"
color="white"
display="flex"
alignItems="center"
justifyContent="center"
boxShadow="0 8px 24px rgba(26,25,23,0.3)"
transition="transform .2s"
_hover={{ transform: "scale(1.08)", textDecoration: "none", bg: "brand.700" }}
aria-label="WhatsApp 查詢"
>
<WhatsAppIcon style={{ width: 28, height: 28 }} />
</Link>
);
}
+7
View File
@@ -0,0 +1,7 @@
export function WhatsAppIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51l-.57-.01c-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 0 1-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 0 1-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 0 1 2.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0 0 12.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 0 0 5.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 0 0-3.48-8.413" />
</svg>
);
}
+6
View File
@@ -0,0 +1,6 @@
/**
* 網站基本資料(build-time 靜態頁用)。
* 公司資料、聯絡方式等「可管理」內容放喺 D1 嘅 site_settings,於 /admin 修改。
*/
export const SITE_NAME = "盈豐太陽能工程有限公司";
export const SITE_TAGLINE = "香港村屋太陽能一站式服務";
+33
View File
@@ -0,0 +1,33 @@
# src/data/AGENTS.md
## Purpose
D1 讀取查詢層(`content.ts`)同後台網站設定欄位定義(`settings-fields.ts`)。
## Ownership
- 擁有 `src/data/` 兩個檔案。
- Drizzle schema 本體喺 `src/db/schema.ts`(見兄弟 `db/AGENTS.md`),呢度只消費其型別。
## Local Contracts
- **查詢集中**:所有前台/Blog 的 D1 查詢寫喺 `content.ts`,唔好散落喺頁面或元件。
- **只回前台可見**`getItems` / `getCases` / `getPublishedPosts` 只回 `status = "published"`,並按 `sortOrder``publishedAt DESC` 排序;`getPostBySlug` 同時要求 published。
- **首頁聚合**`getHomeData(db)``Promise.all` 一次過攞 settings + services/features/steps/faqs + cases,形狀係 `HomeData`
- **`Db` 型別**`DrizzleD1Database<typeof schema>`
- **連結 helper**`whatsappHref` / `telHref` / `mailHref` / `digits` / `formatDate`;電話/WhatsApp 一律經呢啲 helper,唔好散寫。
- **設定欄位**`settings-fields.ts``SETTINGS_GROUPS``/admin/settings` 表單的唯一來源;新增一個 setting key 之後,要同步加落 `migrations/seed.sql`
- 顯示用文案繁體中文。
## Work Guidance
- 新增可管理欄位:改 `settings-fields.ts` → 加 seed default → 喺前端消費(多數喺 `components/site/*`)。
## Verification
- `npm run build`
- `npm run db:seed:local``/admin` 同前台睇得到種子資料。
## Child DOX Index
無。
+104
View File
@@ -0,0 +1,104 @@
import { and, asc, desc, eq } from "drizzle-orm";
import type { DrizzleD1Database } from "drizzle-orm/d1";
import {
cases,
contentItems,
posts,
siteSettings,
type CaseStudy,
type ContentItem,
type ContentKind,
type Post,
} from "../db/schema";
import * as schema from "../db/schema";
export type Db = DrizzleD1Database<typeof schema>;
export type Settings = Record<string, string>;
export type HomeData = {
settings: Settings;
services: ContentItem[];
features: ContentItem[];
steps: ContentItem[];
faqs: ContentItem[];
cases: CaseStudy[];
};
export async function getSettings(db: Db): Promise<Settings> {
const rows = await db.select().from(siteSettings);
return Object.fromEntries(rows.map((r) => [r.key, r.value]));
}
export async function getItems(db: Db, kind: ContentKind): Promise<ContentItem[]> {
return db
.select()
.from(contentItems)
.where(and(eq(contentItems.kind, kind), eq(contentItems.status, "published")))
.orderBy(asc(contentItems.sortOrder));
}
export async function getCases(db: Db): Promise<CaseStudy[]> {
return db
.select()
.from(cases)
.where(eq(cases.status, "published"))
.orderBy(asc(cases.sortOrder));
}
export async function getPublishedPosts(db: Db): Promise<Post[]> {
return db
.select()
.from(posts)
.where(eq(posts.status, "published"))
.orderBy(desc(posts.publishedAt));
}
export async function getPostBySlug(db: Db, slug: string): Promise<Post | null> {
const [post] = await db
.select()
.from(posts)
.where(and(eq(posts.slug, slug), eq(posts.status, "published")))
.limit(1);
return post ?? null;
}
export async function getHomeData(db: Db): Promise<HomeData> {
const [settings, services, features, steps, faqs, caseList] = await Promise.all([
getSettings(db),
getItems(db, "service"),
getItems(db, "feature"),
getItems(db, "step"),
getItems(db, "faq"),
getCases(db),
]);
return { settings, services, features, steps, faqs, cases: caseList };
}
export function digits(input: string | undefined): string {
return (input ?? "").replace(/\D/g, "");
}
export function whatsappHref(
settings: Settings,
text = "你好,我想查詢村屋太陽能安裝,想預約免費評估。",
): string {
return `https://api.whatsapp.com/send?phone=${digits(settings.whatsapp)}&text=${encodeURIComponent(text)}`;
}
export function telHref(settings: Settings): string {
const phone = (settings.phone ?? "").replace(/[^\d+]/g, "");
return `tel:${phone}`;
}
export function mailHref(settings: Settings): string {
return `mailto:${settings.email ?? ""}?subject=${encodeURIComponent("查詢村屋太陽能安裝")}`;
}
export function formatDate(d: Date | null | undefined): string {
if (!d) return "";
return new Date(d).toLocaleDateString("zh-Hant-HK", {
year: "numeric",
month: "long",
day: "numeric",
});
}
+55
View File
@@ -0,0 +1,55 @@
export type FieldType = "text" | "textarea" | "url";
export type SettingsField = {
key: string;
label: string;
type?: FieldType;
placeholder?: string;
};
export type SettingsGroup = {
title: string;
fields: SettingsField[];
};
export const SETTINGS_GROUPS: SettingsGroup[] = [
{
title: "公司資料",
fields: [
{ key: "company_name", label: "公司全名" },
{ key: "company_short_name", label: "公司簡稱" },
{ key: "license_no", label: "電業承辦商編號" },
{ key: "address", label: "辦公地址", type: "textarea" },
{ key: "facebook_url", label: "Facebook 連結", type: "url", placeholder: "https://..." },
],
},
{
title: "聯絡方式",
fields: [
{ key: "phone", label: "查詢電話", placeholder: "+852 9899 5499" },
{ key: "whatsapp", label: "WhatsApp 號碼(純數字)", placeholder: "85298995499" },
{ key: "email", label: "聯絡 Email", placeholder: "[email protected]" },
],
},
{
title: "首頁 Hero",
fields: [
{ key: "hero_eyebrow", label: "眉題" },
{ key: "hero_title", label: "主標題" },
{ key: "hero_subtitle", label: "副標題", type: "textarea" },
{ key: "hero_primary_cta", label: "主要按鈕文字" },
{ key: "hero_secondary_cta", label: "次要按鈕文字" },
{ key: "hero_image", label: "主視覺圖片 URL", type: "url", placeholder: "https://..." },
],
},
{
title: "SEO 設定",
fields: [
{ key: "seo_default_title", label: "預設標題" },
{ key: "seo_default_description", label: "預設描述", type: "textarea" },
{ key: "og_image", label: "分享圖片 URL (OG image)", type: "url", placeholder: "https://..." },
],
},
];
export const ALL_SETTING_KEYS = SETTINGS_GROUPS.flatMap((g) => g.fields.map((f) => f.key));
+33
View File
@@ -0,0 +1,33 @@
# src/db/AGENTS.md
## Purpose
Drizzle schema 定義,係 D1 migration 的唯一來源。
## Ownership
- 擁有 `src/db/schema.ts`
- 產生出嚟的 SQL 喺 `migrations/`(見 `migrations/AGENTS.md`)。
## Local Contracts
- **表**`posts``site_settings``content_items``cases`
- **可重複內容**`content_items``kind` 區分,`CONTENT_KINDS = ["service", "feature", "step", "faq"]`
- **狀態**`posts` / `content_items` / `cases` 都有 `status``draft` | `published`)。
- **型別**:由 `$inferSelect` 匯出(`Post``ContentItem``CaseStudy``SiteSetting` 等),其他地方重用呢啲型別。
- **改 schema 流程**:改 `schema.ts``npm run db:generate` 產生新 migration。**唔好手改** `migrations/*.sql``seed.sql` 除外)。
- 索引命名 `idx_*`,需跟現有欄位組合(例如 `idx_items_kind(kind, status, sort_order)`)。
## Work Guidance
- 加欄位時考慮預設值同 nullability,因為 migration 會套用到已有資料。
- 改完 schema 記得同步 `migrations/seed.sql` 同 admin 表單(`pages/admin`)。
## Verification
- `npm run db:generate`(確認 migration 有正確產生)。
- `npm run build`
## Child DOX Index
無。
+97
View File
@@ -0,0 +1,97 @@
import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core";
/**
* Blog 文章。
*/
export const posts = sqliteTable(
"posts",
{
id: text("id").primaryKey(),
slug: text("slug").notNull(),
title: text("title").notNull(),
excerpt: text("excerpt"),
content: text("content").notNull(),
coverImage: text("cover_image"),
tags: text("tags"),
metaDescription: text("meta_description"),
status: text("status", { enum: ["draft", "published"] })
.notNull()
.default("draft"),
publishedAt: integer("published_at", { mode: "timestamp_ms" }),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.notNull()
.$defaultFn(() => new Date()),
},
(t) => [
uniqueIndex("idx_posts_slug").on(t.slug),
index("idx_posts_published_at").on(t.publishedAt),
index("idx_posts_status").on(t.status),
],
);
/**
* 單例設定(公司資料、Hero 文案、SEO 等),以 key-value 存放。
* 複雜值(例如 JSON 陣列)以 JSON 字串存。
*/
export const siteSettings = sqliteTable("site_settings", {
key: text("key").primaryKey(),
value: text("value").notNull().default(""),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.notNull()
.$defaultFn(() => new Date()),
});
export const CONTENT_KINDS = ["service", "feature", "step", "faq"] as const;
export type ContentKind = (typeof CONTENT_KINDS)[number];
/**
* 可重複的內容項目:服務 / 為何揀我哋 / 流程步驟 / 常見問題。
* 用同一個表以 `kind` 區分,方便後台用一個通用編輯器管理。
*/
export const contentItems = sqliteTable(
"content_items",
{
id: text("id").primaryKey(),
kind: text("kind", { enum: CONTENT_KINDS }).notNull(),
title: text("title").notNull(),
description: text("description").notNull().default(""),
extra: text("extra"),
sortOrder: integer("sort_order").notNull().default(0),
status: text("status", { enum: ["draft", "published"] })
.notNull()
.default("published"),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.notNull()
.$defaultFn(() => new Date()),
},
(t) => [index("idx_items_kind").on(t.kind, t.status, t.sortOrder)],
);
/**
* 完成案例。
*/
export const cases = sqliteTable(
"cases",
{
id: text("id").primaryKey(),
title: text("title").notNull(),
location: text("location"),
completedAt: text("completed_at"),
description: text("description"),
imageUrl: text("image_url"),
sortOrder: integer("sort_order").notNull().default(0),
status: text("status", { enum: ["draft", "published"] })
.notNull()
.default("published"),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.notNull()
.$defaultFn(() => new Date()),
},
(t) => [index("idx_cases_status").on(t.status, t.sortOrder)],
);
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
export type ContentItem = typeof contentItems.$inferSelect;
export type CaseStudy = typeof cases.$inferSelect;
export type SiteSetting = typeof siteSettings.$inferSelect;
+8
View File
@@ -0,0 +1,8 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference path="../worker-configuration.d.ts" />
declare namespace App {
interface Locals {
isAdmin: boolean;
}
}
+113
View File
@@ -0,0 +1,113 @@
---
interface Props {
title: string;
}
const { title } = Astro.props;
const path = Astro.url.pathname;
const links = [
{ href: "/admin", label: "文章" },
{ href: "/admin/content/service", label: "服務" },
{ href: "/admin/content/feature", label: "特色" },
{ href: "/admin/content/step", label: "流程" },
{ href: "/admin/content/faq", label: "常見問題" },
{ href: "/admin/cases", label: "案例" },
{ href: "/admin/settings", label: "設定" },
];
---
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} — 後台</title>
<meta name="robots" content="noindex, nofollow" />
</head>
<body>
<header class="bar">
<div class="inner">
<a class="brand" href="/admin">
<span class="dot"></span>
盈豐太陽能 · 後台
</a>
<nav>
{
links.map((l) => (
<a href={l.href} class={path === l.href || path.startsWith(l.href + "/") ? "active" : ""}>
{l.label}
</a>
))
}
<a href="/" target="_blank">睇網站</a>
<a href="/admin/logout">登出</a>
</nav>
</div>
</header>
<main class="inner">
<slot />
</main>
</body>
</html>
<style is:global>
:root {
font-family: "Noto Sans HK", "PingFang HK", "Microsoft JhengHei", system-ui, sans-serif;
color: #0f1e1a;
background: #f2f8f6;
line-height: 1.65;
font-size: 15px;
}
* { box-sizing: border-box; }
body { margin: 0; }
a { color: #0b8a5e; text-decoration: none; }
a:hover { text-decoration: underline; }
.inner { max-width: 920px; margin: 0 auto; padding: 0 24px; }
.bar { background: #fff; border-bottom: 1px solid rgba(0,0,0,.08); position: sticky; top: 0; z-index: 10; }
.bar .inner { display: flex; align-items: center; justify-content: space-between; height: 60px; gap: 16px; flex-wrap: wrap; }
.brand { font-weight: 800; color: #0f1e1a; display: inline-flex; align-items: center; gap: 8px; }
.brand:hover { text-decoration: none; }
.dot { width: 12px; height: 12px; border-radius: 50%; background: linear-gradient(135deg,#0b8a5e,#0ba5ec); display: inline-block; }
.bar nav { display: flex; gap: 14px; font-size: 13.5px; flex-wrap: wrap; }
.bar nav a { color: #33413c; }
.bar nav a.active { color: #0b8a5e; font-weight: 700; }
main { padding: 32px 24px 100px; }
h1 { font-size: 22px; font-weight: 800; margin: 0 0 6px; }
.sub { color: #5b6b66; font-size: 13.5px; margin: 0 0 24px; }
.card { background: #fff; border: 1px solid rgba(0,0,0,.08); border-radius: 14px; padding: 20px 22px; margin-bottom: 18px; }
.card h2 { font-size: 15px; font-weight: 700; margin: 0 0 14px; }
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid rgba(0,0,0,.08); border-radius: 14px; overflow: hidden; }
th { text-align: left; font-weight: 600; font-size: 12px; color: #5b6b66; padding: 10px 14px; border-bottom: 1px solid rgba(0,0,0,.08); }
td { padding: 12px 14px; border-bottom: 1px solid rgba(0,0,0,.05); font-size: 14px; vertical-align: middle; }
tbody tr:last-child td { border-bottom: none; }
label { display: block; font-size: 13px; color: #5b6b66; margin: 14px 0 6px; font-weight: 600; }
input[type="text"], input[type="url"], textarea, select {
width: 100%; font: inherit; font-size: 14px; padding: 9px 12px;
border: 1px solid rgba(0,0,0,.15); border-radius: 9px; background: #fff; color: #0f1e1a;
}
textarea { min-height: 88px; resize: vertical; }
input:focus, textarea:focus, select:focus { outline: 2px solid #9fdcc2; border-color: #0b8a5e; }
button, .btn {
font: inherit; font-size: 14px; font-weight: 600; padding: 9px 18px; border-radius: 9px; cursor: pointer;
border: 1px solid #0b8a5e; background: #0b8a5e; color: #fff; display: inline-flex; align-items: center; gap: 6px;
}
button:hover { background: #0a6f4c; }
button.secondary, .btn.secondary { background: #fff; color: #0f1e1a; border-color: rgba(0,0,0,.15); }
button.secondary:hover { background: #f2f8f6; }
button.danger { background: #fff; color: #a32d2d; border-color: #f09595; }
button.mini { padding: 5px 10px; font-size: 12.5px; border-radius: 7px; }
.actions { display: flex; gap: 10px; align-items: center; margin-top: 20px; flex-wrap: wrap; }
.badge { font-size: 11px; padding: 2px 8px; border-radius: 20px; border: 1px solid transparent; }
.badge.published { background: #e1f5ee; color: #0f6e56; }
.badge.draft { background: #faeeda; color: #854f0b; }
.muted { color: #8a9793; font-size: 13px; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 640px) { .grid2 { grid-template-columns: 1fr; } }
.row { display: grid; gap: 12px; grid-template-columns: 1fr; }
</style>
+73
View File
@@ -0,0 +1,73 @@
---
import { SITE_NAME } from "../config";
interface Props {
title: string;
description?: string;
noindex?: boolean;
ogType?: "website" | "article";
publishedTime?: Date | null;
image?: string;
}
const {
title,
description = "香港村屋太陽能一站式服務",
noindex = false,
ogType = "website",
publishedTime = null,
image,
} = Astro.props;
const siteName = SITE_NAME;
const origin = (Astro.site ?? Astro.url).toString().replace(/\/$/, "");
const canonical = new URL(Astro.url.pathname, origin + "/").toString();
const fullTitle = title.includes(siteName) ? title : `${title} — ${siteName}`;
const ogImage = image ? (image.startsWith("http") ? image : new URL(image, origin + "/").toString()) : undefined;
---
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{fullTitle}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
{noindex && <meta name="robots" content="noindex, nofollow" />}
<meta property="og:type" content={ogType} />
<meta property="og:site_name" content={siteName} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:locale" content="zh_HK" />
{ogImage && <meta property="og:image" content={ogImage} />}
{publishedTime && <meta property="article:published_time" content={publishedTime.toISOString()} />}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
{ogImage && <meta name="twitter:image" content={ogImage} />}
<link rel="sitemap" href="/sitemap.xml" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+HK:wght@400;500;700;900&family=Noto+Serif+HK:wght@600;700;900&display=swap"
rel="stylesheet"
/>
</head>
<body>
<slot />
</body>
</html>
<style is:global>
html,
body {
margin: 0;
padding: 0;
font-family: "Noto Sans HK", "PingFang HK", "Microsoft JhengHei", system-ui, -apple-system, sans-serif;
background: #faf8f4;
}
</style>
+34
View File
@@ -0,0 +1,34 @@
# src/lib/AGENTS.md
## Purpose
跨層基礎工具:環境變數、DB 連線、後台認證、Markdown 處理。
## Ownership
- 擁有 `src/lib/` 四個檔案。
- 其他層(pages / data / middleware)只消費,唔好複製呢度嘅邏輯。
## Local Contracts
- **`env.ts`**`getEnv()``cloudflare:workers``env`,回 `AppEnv``DB``CACHE``ADMIN_PASSWORD?`)。只能喺 `prerender = false` 的頁面/endpoint 用。**唔用** `Astro.locals.runtime.env`Astro v6 起已移除)。
- **`db.ts`**`getDb(env.DB)` → Drizzle,並 re-export `schema`
- **`auth.ts`**HMAC-SHA256 signed cookie 認證。
- 匯出 `SESSION_COOKIE``checkPassword``createSession``verifySession``sessionCookieOptions`
- Cookie 7 日、`httpOnly``secure``sameSite: "lax"``path: "/"`
- 密碼同 session 比對用 `safeEqual`constant-time),改動要保留防 timing attack 嘅做法。
- **`markdown.ts`**`renderMarkdown``marked`,內容受信任所以**唔 sanitize**)、`slugify`(保留中文)、`autoExcerpt`
## Work Guidance
- 認證相關改動要同時檢查 `src/middleware.ts``pages/admin/login.astro``pages/admin/logout.ts`
- Markdown 內容由後台輸入;如將來開放不受信任輸入,需重新評估 sanitize。
## Verification
- `npm run build`
- 認證改動:`npm run dev` 測登入/登出/未登入導向。
## Child DOX Index
無。
+67
View File
@@ -0,0 +1,67 @@
export const SESSION_COOKIE = "admin_session";
const SESSION_MAX_AGE_SEC = 60 * 60 * 24 * 7; // 7 日
const enc = new TextEncoder();
async function hmacKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"],
);
}
function toBase64Url(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
async function sign(data: string, secret: string): Promise<string> {
const key = await hmacKey(secret);
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(data));
return toBase64Url(sig);
}
/** 長度相等時逐字元 XOR,避免 timing attack */
function safeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
/**
* 比較密碼。兩邊各自做一次 HMAC 先比,
* 避免直接比原文洩漏長度同前綴(timing attack)。
*/
export async function checkPassword(input: string, secret: string): Promise<boolean> {
return safeEqual(await sign(input, secret), await sign(secret, secret));
}
export async function createSession(secret: string): Promise<string> {
const exp = String(Math.floor(Date.now() / 1000) + SESSION_MAX_AGE_SEC);
return `${exp}.${await sign(exp, secret)}`;
}
export async function verifySession(
cookie: string | undefined,
secret: string,
): Promise<boolean> {
if (!cookie) return false;
const [exp, sig] = cookie.split(".");
if (!exp || !sig) return false;
if (Number(exp) * 1000 < Date.now()) return false;
return safeEqual(await sign(exp, secret), sig);
}
export const sessionCookieOptions = {
path: "/",
httpOnly: true,
sameSite: "lax" as const,
secure: true,
maxAge: SESSION_MAX_AGE_SEC,
};
+8
View File
@@ -0,0 +1,8 @@
import { drizzle, type DrizzleD1Database } from "drizzle-orm/d1";
import * as schema from "../db/schema";
export function getDb(env: D1Database): DrizzleD1Database<typeof schema> {
return drizzle(env, { schema });
}
export { schema };
+16
View File
@@ -0,0 +1,16 @@
import { env } from "cloudflare:workers";
export type AppEnv = {
DB: D1Database;
CACHE: KVNamespace;
ADMIN_PASSWORD?: string;
};
/**
* Astro v6 起,`Astro.locals.runtime.env` 已經移除,
* 一律改用 `cloudflare:workers` 嘅 env。
* 只可以喺 on-demandprerender = false)嘅頁面/endpoint 用。
*/
export function getEnv(): AppEnv {
return env as unknown as AppEnv;
}
+32
View File
@@ -0,0 +1,32 @@
import { marked } from "marked";
marked.setOptions({ gfm: true, breaks: false });
/** markdown → HTML。內容係你自己寫嘅(受信任),所以唔做 sanitize。 */
export function renderMarkdown(md: string): string {
return marked.parse(md ?? "", { async: false }) as string;
}
/** 由標題自動產生 slug:去符號、空格轉 -,保留中文字 */
export function slugify(input: string): string {
return (
input
.toLowerCase()
.trim()
.replace(/[^\p{L}\p{N}\s-]/gu, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "") || `post-${Date.now()}`
);
}
/** 攞文章開頭做 SEO description(如果冇手填) */
export function autoExcerpt(md: string, len = 150): string {
const plain = md
.replace(/^#+\s.*$/gm, "")
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/[*_`>#-]/g, "")
.replace(/\s+/g, " ")
.trim();
return plain.length > len ? plain.slice(0, len) + "…" : plain;
}
+35
View File
@@ -0,0 +1,35 @@
import { defineMiddleware } from "astro:middleware";
import { SESSION_COOKIE, verifySession } from "./lib/auth";
import { getEnv } from "./lib/env";
export const onRequest = defineMiddleware(async (context, next) => {
context.locals.isAdmin = false;
const url = new URL(context.request.url);
if (!url.pathname.startsWith("/admin")) return next();
let password: string | undefined;
try {
password = getEnv().ADMIN_PASSWORD;
} catch {
password = undefined;
}
if (!password) {
// 未設 ADMIN_PASSWORD 就咪畀入,費事人人都係 admin
return new Response("Admin 未設定:請執行 `npm run secret` 設定 ADMIN_PASSWORD。", {
status: 503,
});
}
const cookie = context.cookies.get(SESSION_COOKIE)?.value;
if (await verifySession(cookie, password)) {
context.locals.isAdmin = true;
}
if (!context.locals.isAdmin && url.pathname !== "/admin/login") {
return context.redirect("/admin/login");
}
return next();
});
+12
View File
@@ -0,0 +1,12 @@
---
import Base from "../layouts/Base.astro";
import { SITE_NAME } from "../config";
---
<Base title="關於我哋" description={`${SITE_NAME} — 香港村屋太陽能一站式服務。`} noindex={true}>
<div style="max-width:640px;margin:15vh auto;padding:0 24px;text-align:center;font-family:'Noto Sans HK',sans-serif;">
<h1 style="font-size:28px;font-weight:800;color:#1A1917;">我哋嘅服務已整合到主頁</h1>
<p style="color:#6E6862;line-height:1.9;">請返去主頁睇晒我哋嘅村屋太陽能服務、案例同流程。</p>
<p><a href="/" style="color:#B45309;font-weight:600;">← 返去主頁</a></p>
</div>
</Base>
+172
View File
@@ -0,0 +1,172 @@
---
import { and, asc, desc, eq, gt, lt, max } from "drizzle-orm";
import AdminLayout from "../../layouts/AdminLayout.astro";
import { cases } from "../../db/schema";
import { getDb } from "../../lib/db";
import { getEnv } from "../../lib/env";
export const prerender = false;
const db = getDb(getEnv().DB);
function str(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
}
if (Astro.request.method === "POST") {
const form = await Astro.request.formData();
const action = str(form, "action");
if (action === "add") {
const title = str(form, "title");
if (title) {
const [row] = await db.select({ m: max(cases.sortOrder) }).from(cases);
await db.insert(cases).values({
id: crypto.randomUUID(),
title,
location: str(form, "location") || null,
completedAt: str(form, "completedAt") || null,
description: str(form, "description") || null,
imageUrl: str(form, "imageUrl") || null,
sortOrder: (row?.m ?? 0) + 1,
status: str(form, "status") === "draft" ? "draft" : "published",
updatedAt: new Date(),
});
}
} else if (action === "save") {
const id = str(form, "id");
await db
.update(cases)
.set({
title: str(form, "title"),
location: str(form, "location") || null,
completedAt: str(form, "completedAt") || null,
description: str(form, "description") || null,
imageUrl: str(form, "imageUrl") || null,
status: str(form, "status") === "draft" ? "draft" : "published",
updatedAt: new Date(),
})
.where(eq(cases.id, id));
} else if (action === "delete") {
await db.delete(cases).where(eq(cases.id, str(form, "id")));
} else if (action === "up" || action === "down") {
const id = str(form, "id");
const [current] = await db.select().from(cases).where(eq(cases.id, id)).limit(1);
if (current) {
const [adj] = await db
.select()
.from(cases)
.where(
action === "up" ? lt(cases.sortOrder, current.sortOrder) : gt(cases.sortOrder, current.sortOrder),
)
.orderBy(action === "up" ? desc(cases.sortOrder) : asc(cases.sortOrder))
.limit(1);
if (adj) {
await db.update(cases).set({ sortOrder: adj.sortOrder }).where(eq(cases.id, current.id));
await db.update(cases).set({ sortOrder: current.sortOrder }).where(eq(cases.id, adj.id));
}
}
}
return Astro.redirect("/admin/cases");
}
const list = await db.select().from(cases).orderBy(asc(cases.sortOrder));
---
<AdminLayout title="完成案例">
<h1>完成案例</h1>
<p class="sub">前台「完成案例」區塊會按下面次序顯示(只顯示已發布)。</p>
<div class="card">
<h2>新增案例</h2>
<form method="post">
<input type="hidden" name="action" value="add" />
<div class="grid2">
<div>
<label for="a-title">標題</label>
<input id="a-title" name="title" type="text" required placeholder="例:大埔上碗窯 — 雙玻光伏板" />
</div>
<div>
<label for="a-loc">地點</label>
<input id="a-loc" name="location" type="text" placeholder="例:大埔半山" />
</div>
<div>
<label for="a-date">施工日期</label>
<input id="a-date" name="completedAt" type="text" placeholder="例:2026年2月" />
</div>
<div>
<label for="a-img">圖片 URL</label>
<input id="a-img" name="imageUrl" type="text" placeholder="https://..." />
</div>
<div style="grid-column:1/-1">
<label for="a-desc">描述</label>
<textarea id="a-desc" name="description"></textarea>
</div>
</div>
<div class="actions"><button type="submit">+ 新增案例</button></div>
</form>
</div>
{
list.length === 0 ? (
<p class="muted">暫時未有案例。</p>
) : (
list.map((item, i) => (
<div class="card">
<form method="post">
<input type="hidden" name="id" value={item.id} />
<div class="grid2">
<div>
<label>標題</label>
<input name="title" type="text" value={item.title} required />
</div>
<div>
<label>地點</label>
<input name="location" type="text" value={item.location ?? ""} />
</div>
<div>
<label>施工日期</label>
<input name="completedAt" type="text" value={item.completedAt ?? ""} />
</div>
<div>
<label>圖片 URL</label>
<input name="imageUrl" type="text" value={item.imageUrl ?? ""} />
</div>
<div style="grid-column:1/-1">
<label>描述</label>
<textarea name="description">{item.description ?? ""}</textarea>
</div>
<div>
<label>狀態</label>
<select name="status">
<option value="published" selected={item.status === "published"}>已發布</option>
<option value="draft" selected={item.status === "draft"}>草稿</option>
</select>
</div>
</div>
{item.imageUrl && (
<img src={item.imageUrl} alt={item.title} style="margin-top:14px;max-width:220px;border-radius:10px;" />
)}
<div class="actions">
<button type="submit" name="action" value="save">儲存</button>
<button type="submit" name="action" value="up" class="secondary mini" disabled={i === 0}>↑ 上移</button>
<button type="submit" name="action" value="down" class="secondary mini" disabled={i === list.length - 1}>
↓ 下移
</button>
<button
type="submit"
name="action"
value="delete"
class="danger mini"
onclick="return confirm('確定刪除?')"
>
刪除
</button>
</div>
</form>
</div>
))
)
}
</AdminLayout>
+219
View File
@@ -0,0 +1,219 @@
---
import { and, asc, desc, eq, gt, lt, max } from "drizzle-orm";
import AdminLayout from "../../../layouts/AdminLayout.astro";
import { contentItems, CONTENT_KINDS, type ContentKind } from "../../../db/schema";
import { getDb } from "../../../lib/db";
import { getEnv } from "../../../lib/env";
export const prerender = false;
const db = getDb(getEnv().DB);
const kind = Astro.params.kind as ContentKind;
if (!CONTENT_KINDS.includes(kind)) {
return Astro.redirect("/admin");
}
const META: Record<
ContentKind,
{ label: string; extraLabel: string | null; extraHint: string; titleLabel: string; descLabel: string }
> = {
service: {
label: "服務範圍",
extraLabel: null,
extraHint: "",
titleLabel: "服務名稱",
descLabel: "描述",
},
feature: {
label: "為何揀我哋",
extraLabel: null,
extraHint: "",
titleLabel: "特色名稱",
descLabel: "描述",
},
step: {
label: "服務流程",
extraLabel: "步驟編號",
extraHint: "01、02、03…",
titleLabel: "步驟名稱",
descLabel: "描述",
},
faq: {
label: "常見問題",
extraLabel: null,
extraHint: "",
titleLabel: "問題",
descLabel: "答案",
},
};
const meta = META[kind];
function str(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
}
if (Astro.request.method === "POST") {
const form = await Astro.request.formData();
const action = str(form, "action");
if (action === "add") {
const title = str(form, "title");
if (title) {
const [row] = await db
.select({ m: max(contentItems.sortOrder) })
.from(contentItems)
.where(eq(contentItems.kind, kind));
await db.insert(contentItems).values({
id: crypto.randomUUID(),
kind,
title,
description: str(form, "description"),
extra: str(form, "extra") || null,
sortOrder: (row?.m ?? 0) + 1,
status: str(form, "status") === "draft" ? "draft" : "published",
updatedAt: new Date(),
});
}
} else if (action === "save") {
const id = str(form, "id");
await db
.update(contentItems)
.set({
title: str(form, "title"),
description: str(form, "description"),
extra: str(form, "extra") || null,
status: str(form, "status") === "draft" ? "draft" : "published",
updatedAt: new Date(),
})
.where(and(eq(contentItems.id, id), eq(contentItems.kind, kind)));
} else if (action === "delete") {
await db
.delete(contentItems)
.where(and(eq(contentItems.id, str(form, "id")), eq(contentItems.kind, kind)));
} else if (action === "up" || action === "down") {
const id = str(form, "id");
const [current] = await db.select().from(contentItems).where(eq(contentItems.id, id)).limit(1);
if (current) {
const [adj] = await db
.select()
.from(contentItems)
.where(
and(
eq(contentItems.kind, kind),
action === "up"
? lt(contentItems.sortOrder, current.sortOrder)
: gt(contentItems.sortOrder, current.sortOrder),
),
)
.orderBy(action === "up" ? desc(contentItems.sortOrder) : asc(contentItems.sortOrder))
.limit(1);
if (adj) {
await db.update(contentItems).set({ sortOrder: adj.sortOrder }).where(eq(contentItems.id, current.id));
await db.update(contentItems).set({ sortOrder: current.sortOrder }).where(eq(contentItems.id, adj.id));
}
}
}
return Astro.redirect(`/admin/content/${kind}`);
}
const items = await db
.select()
.from(contentItems)
.where(eq(contentItems.kind, kind))
.orderBy(asc(contentItems.sortOrder));
---
<AdminLayout title={meta.label}>
<h1>{meta.label}</h1>
<p class="sub">前台首頁會按下面次序顯示(只顯示「已發布」項目)。用 ↑↓ 調整排序。</p>
<div class="card">
<h2>新增項目</h2>
<form method="post">
<input type="hidden" name="action" value="add" />
<div class="grid2">
<div>
<label for="add-title">{meta.titleLabel}</label>
<input id="add-title" name="title" type="text" required />
</div>
{
meta.extraLabel && (
<div>
<label for="add-extra">{meta.extraLabel}</label>
<input id="add-extra" name="extra" type="text" placeholder={meta.extraHint} />
</div>
)
}
<div style="grid-column:1/-1">
<label for="add-desc">{meta.descLabel}</label>
<textarea id="add-desc" name="description"></textarea>
</div>
</div>
<div class="actions">
<button type="submit">+ 新增</button>
</div>
</form>
</div>
{
items.length === 0 ? (
<p class="muted">暫時未有項目。</p>
) : (
items.map((item, i) => (
<div class="card">
<form method="post">
<input type="hidden" name="id" value={item.id} />
<div class="grid2">
<div>
<label>{meta.titleLabel}</label>
<input name="title" type="text" value={item.title} required />
</div>
{meta.extraLabel && (
<div>
<label>{meta.extraLabel}</label>
<input name="extra" type="text" value={item.extra ?? ""} placeholder={meta.extraHint} />
</div>
)}
<div style="grid-column:1/-1">
<label>{meta.descLabel}</label>
<textarea name="description">{item.description}</textarea>
</div>
<div>
<label>狀態</label>
<select name="status">
<option value="published" selected={item.status === "published"}>已發布</option>
<option value="draft" selected={item.status === "draft"}>草稿</option>
</select>
</div>
</div>
<div class="actions">
<button type="submit" name="action" value="save">儲存</button>
<button type="submit" name="action" value="up" class="secondary mini" disabled={i === 0}>↑ 上移</button>
<button
type="submit"
name="action"
value="down"
class="secondary mini"
disabled={i === items.length - 1}
>
↓ 下移
</button>
<button
type="submit"
name="action"
value="delete"
class="danger mini"
onclick="return confirm('確定刪除?')"
>
刪除
</button>
</div>
</form>
</div>
))
)
}
</AdminLayout>
+75
View File
@@ -0,0 +1,75 @@
---
import { desc, eq, sql } from "drizzle-orm";
import AdminLayout from "../../layouts/AdminLayout.astro";
import { getDb } from "../../lib/db";
import { cases, contentItems, posts } from "../../db/schema";
import { getEnv } from "../../lib/env";
export const prerender = false;
const db = getDb(getEnv().DB);
const all = await db.select().from(posts).orderBy(desc(posts.updatedAt));
const counts = await db
.select({ kind: contentItems.kind, n: sql<number>`count(*)` })
.from(contentItems)
.groupBy(contentItems.kind);
const countOf = (k: string) => counts.find((c) => c.kind === k)?.n ?? 0;
const [caseCount] = await db.select({ n: sql<number>`count(*)` }).from(cases);
---
<AdminLayout title="文章">
<h1>內容總覽</h1>
<p class="sub">管理文章、首頁內容同完成案例。前台會自動更新。</p>
<div class="card">
<h2>首頁內容</h2>
<div class="actions" style="margin-top:0">
<a class="btn secondary" href="/admin/content/service">服務({countOf("service")}</a>
<a class="btn secondary" href="/admin/content/feature">特色({countOf("feature")}</a>
<a class="btn secondary" href="/admin/content/step">流程({countOf("step")}</a>
<a class="btn secondary" href="/admin/content/faq">常見問題({countOf("faq")}</a>
<a class="btn secondary" href="/admin/cases">完成案例({caseCount?.n ?? 0}</a>
<a class="btn secondary" href="/admin/settings">網站設定</a>
</div>
</div>
<div class="actions">
<h2 style="font-size:18px;margin:0;flex:1">文章({all.length}</h2>
<a class="btn" href="/admin/post/new">+ 寫新文</a>
</div>
{
all.length === 0 ? (
<div class="card">
<p class="muted">仲未有任何文章。<a href="/admin/post/new">寫第一篇 →</a></p>
</div>
) : (
<table>
<thead>
<tr>
<th>標題</th>
<th style="width:110px">狀態</th>
<th style="width:130px">更新時間</th>
<th style="width:80px"></th>
</tr>
</thead>
<tbody>
{all.map((p) => (
<tr>
<td>
<a href={`/admin/post/${p.id}`}>{p.title}</a>
<div class="muted">/{p.slug}</div>
</td>
<td>
<span class={`badge ${p.status}`}>{p.status === "published" ? "已發布" : "草稿"}</span>
</td>
<td class="muted">{new Date(p.updatedAt).toLocaleDateString("zh-Hant-HK")}</td>
<td>{p.status === "published" && <a href={`/blog/${p.slug}`} target="_blank">睇</a>}</td>
</tr>
))}
</tbody>
</table>
)
}
</AdminLayout>
+59
View File
@@ -0,0 +1,59 @@
---
import { checkPassword, createSession, SESSION_COOKIE, sessionCookieOptions } from "../../lib/auth";
import { getEnv } from "../../lib/env";
export const prerender = false;
let error = "";
if (Astro.request.method === "POST") {
const form = await Astro.request.formData();
const password = String(form.get("password") ?? "");
const secret = getEnv().ADMIN_PASSWORD!;
if (await checkPassword(password, secret)) {
Astro.cookies.set(SESSION_COOKIE, await createSession(secret), sessionCookieOptions);
return Astro.redirect("/admin");
}
error = "密碼唔啱。";
}
---
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>登入後台</title>
<meta name="robots" content="noindex, nofollow" />
</head>
<body>
<main>
<h1>登入後台</h1>
{error && <p class="err">{error}</p>}
<form method="post">
<label for="password">密碼</label>
<input id="password" name="password" type="password" autofocus required />
<div class="actions">
<button type="submit">登入</button>
</div>
</form>
</main>
</body>
</html>
<style is:global>
:root {
font-family: -apple-system, BlinkMacSystemFont, "PingFang HK", "Noto Sans HK", sans-serif;
color: #2c2c2a; background: #f7f7f5; line-height: 1.65; font-size: 15px;
}
* { box-sizing: border-box; }
body { margin: 0; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
main { width: 100%; max-width: 360px; padding: 32px; background: #fff; border: 1px solid rgba(0,0,0,.08); border-radius: 12px; }
h1 { font-size: 18px; font-weight: 500; margin: 0 0 20px; }
label { display: block; font-size: 13px; color: #5f5e5a; margin-bottom: 6px; }
input { width: 100%; font: inherit; font-size: 14px; padding: 9px 12px; border: 1px solid rgba(0,0,0,.15); border-radius: 8px; }
input:focus { outline: 2px solid #b5d4f4; border-color: #185fa5; }
button { margin-top: 18px; width: 100%; font: inherit; font-size: 14px; padding: 9px 18px; border-radius: 8px; cursor: pointer; border: 1px solid #185fa5; background: #185fa5; color: #fff; }
.err { background: #fcebeb; color: #a32d2d; padding: 10px 14px; border-radius: 8px; font-size: 14px; margin: 0 0 16px; }
</style>
+9
View File
@@ -0,0 +1,9 @@
import type { APIRoute } from "astro";
import { SESSION_COOKIE } from "../../lib/auth";
export const prerender = false;
export const GET: APIRoute = ({ cookies, redirect }) => {
cookies.delete(SESSION_COOKIE, { path: "/" });
return redirect("/admin/login");
};
+187
View File
@@ -0,0 +1,187 @@
---
import { and, eq, ne } from "drizzle-orm";
import AdminLayout from "../../../layouts/AdminLayout.astro";
import { getDb } from "../../../lib/db";
import { posts } from "../../../db/schema";
import { slugify, autoExcerpt } from "../../../lib/markdown";
import { getEnv } from "../../../lib/env";
export const prerender = false;
const { id } = Astro.params;
const isNew = id === "new";
const db = getDb(getEnv().DB);
let error = "";
const [existing] = isNew
? []
: await db.select().from(posts).where(eq(posts.id, id!)).limit(1);
if (!isNew && !existing) {
return Astro.redirect("/admin");
}
/** slug 撞咗就加 -2、-3… */
async function uniqueSlug(base: string, selfId?: string): Promise<string> {
let candidate = base;
for (let i = 2; i < 50; i++) {
const rows = await db
.select({ id: posts.id })
.from(posts)
.where(
selfId
? and(eq(posts.slug, candidate), ne(posts.id, selfId))
: eq(posts.slug, candidate),
)
.limit(1);
if (rows.length === 0) return candidate;
candidate = `${base}-${i}`;
}
return `${base}-${Date.now()}`;
}
if (Astro.request.method === "POST") {
const form = await Astro.request.formData();
const action = String(form.get("action") ?? "save");
if (action === "delete" && !isNew) {
await db.delete(posts).where(eq(posts.id, id!));
return Astro.redirect("/admin");
}
const title = String(form.get("title") ?? "").trim();
const content = String(form.get("content") ?? "");
if (!title) {
error = "標題唔可以留空。";
} else {
const status = String(form.get("status") ?? "draft") === "published" ? "published" : "draft";
const rawSlug = String(form.get("slug") ?? "").trim();
const slug = await uniqueSlug(rawSlug ? slugify(rawSlug) : slugify(title), isNew ? undefined : id);
const excerpt = String(form.get("excerpt") ?? "").trim() || autoExcerpt(content);
const metaDescription = String(form.get("metaDescription") ?? "").trim() || excerpt.slice(0, 155);
const coverImage = String(form.get("coverImage") ?? "").trim() || null;
const tags = String(form.get("tags") ?? "").trim() || null;
const now = new Date();
if (isNew) {
await db.insert(posts).values({
id: crypto.randomUUID(),
slug,
title,
excerpt,
content,
coverImage,
tags,
metaDescription,
status,
publishedAt: status === "published" ? now : null,
updatedAt: now,
});
} else {
await db
.update(posts)
.set({
slug,
title,
excerpt,
content,
coverImage,
tags,
metaDescription,
status,
publishedAt: existing!.publishedAt ?? (status === "published" ? now : null),
updatedAt: now,
})
.where(eq(posts.id, id!));
}
return Astro.redirect("/admin");
}
}
const post = isNew
? {
id: "new",
slug: "",
title: "",
excerpt: "",
content: "",
coverImage: "",
tags: "",
metaDescription: "",
status: "draft" as const,
}
: existing!;
---
<AdminLayout title={isNew ? "寫新文" : `編輯:${post.title}`}>
<h1>{isNew ? "寫新文" : "編輯文章"}</h1>
{error && <p class="err">{error}</p>}
<form method="post">
<label for="title">標題</label>
<input id="title" name="title" type="text" value={post.title} required placeholder="文章標題" />
<label for="slug">網址 slug</label>
<input id="slug" name="slug" type="text" value={post.slug} placeholder="留空會由標題自動產生" />
<label for="excerpt">摘要(列表頁顯示)</label>
<input
id="excerpt"
name="excerpt"
type="text"
value={post.excerpt ?? ""}
placeholder="留空會由內容自動產生"
/>
<label for="coverImage">封面圖片 URL(列表卡片用)</label>
<input
id="coverImage"
name="coverImage"
type="text"
value={post.coverImage ?? ""}
placeholder="https://..."
/>
<label for="tags">標籤(逗號分隔,可選)</label>
<input id="tags" name="tags" type="text" value={post.tags ?? ""} placeholder="村屋,太陽能,指南" />
<label for="metaDescription">SEO description</label>
<input
id="metaDescription"
name="metaDescription"
type="text"
value={post.metaDescription ?? ""}
placeholder="留空會用摘要,建議 120155 字"
/>
<label for="content">內容(支援 Markdown</label>
<textarea id="content" name="content" placeholder={"# 標題\n\n寫啲嘢…"}>{post.content}</textarea>
<label for="status">狀態</label>
<select id="status" name="status">
<option value="draft" selected={post.status === "draft"}>草稿</option>
<option value="published" selected={post.status === "published"}>發布</option>
</select>
<div class="actions">
<button type="submit" name="action" value="save">儲存</button>
<a href="/admin"><button type="button" class="secondary">取消</button></a>
{
!isNew && (
<button
type="submit"
name="action"
value="delete"
class="danger"
onclick="return confirm('確定刪除呢篇文章?')"
>
刪除
</button>
)
}
</div>
</form>
</AdminLayout>
+60
View File
@@ -0,0 +1,60 @@
---
import AdminLayout from "../../layouts/AdminLayout.astro";
import { ALL_SETTING_KEYS, SETTINGS_GROUPS } from "../../data/settings-fields";
import { siteSettings } from "../../db/schema";
import { getSettings } from "../../data/content";
import { getDb } from "../../lib/db";
import { getEnv } from "../../lib/env";
export const prerender = false;
const db = getDb(getEnv().DB);
let saved = false;
if (Astro.request.method === "POST") {
const form = await Astro.request.formData();
const now = new Date();
for (const key of ALL_SETTING_KEYS) {
const value = String(form.get(key) ?? "").trim();
await db
.insert(siteSettings)
.values({ key, value, updatedAt: now })
.onConflictDoUpdate({ target: siteSettings.key, set: { value, updatedAt: now } });
}
saved = true;
}
const settings = await getSettings(db);
---
<AdminLayout title="網站設定">
<h1>網站設定</h1>
<p class="sub">公司資料、聯絡方式、首頁文案同 SEO。儲存後前台會自動更新。</p>
{saved && <p class="badge published" style="display:inline-block;margin-bottom:16px;padding:6px 14px">✓ 已儲存</p>}
<form method="post">
{
SETTINGS_GROUPS.map((group) => (
<div class="card">
<h2>{group.title}</h2>
<div class="grid2">
{group.fields.map((f) => (
<div style={f.type === "textarea" ? "grid-column:1/-1" : ""}>
<label for={f.key}>{f.label}</label>
{f.type === "textarea" ? (
<textarea id={f.key} name={f.key} placeholder={f.placeholder}>{settings[f.key] ?? ""}</textarea>
) : (
<input id={f.key} name={f.key} type="text" value={settings[f.key] ?? ""} placeholder={f.placeholder} />
)}
</div>
))}
</div>
</div>
))
}
<div class="actions">
<button type="submit">儲存設定</button>
</div>
</form>
</AdminLayout>
+50
View File
@@ -0,0 +1,50 @@
---
import Base from "../../layouts/Base.astro";
import { BlogPost } from "../../components/site/BlogPost";
import { formatDate, getPostBySlug, getSettings } from "../../data/content";
import { getDb } from "../../lib/db";
import { renderMarkdown } from "../../lib/markdown";
import { getEnv } from "../../lib/env";
export const prerender = false;
const { slug } = Astro.params;
const db = getDb(getEnv().DB);
const [post, settings] = await Promise.all([getPostBySlug(db, slug!), getSettings(db)]);
if (!post) {
Astro.response.status = 404;
}
const html = post ? renderMarkdown(post.content) : "";
const description = post?.metaDescription || post?.excerpt || "";
---
{
post ? (
<Base
title={post.title}
description={description}
ogType="article"
publishedTime={post.publishedAt}
image={post.coverImage ?? undefined}
>
<BlogPost
title={post.title}
dateLabel={formatDate(post.publishedAt)}
coverImage={post.coverImage}
html={html}
settings={settings}
client:load
/>
</Base>
) : (
<Base title="搵唔到文章" noindex={true}>
<div style="max-width:640px;margin:15vh auto;padding:0 24px;text-align:center;font-family:'Noto Sans HK',sans-serif;">
<h1 style="font-size:28px;font-weight:800;color:#0F1E1A;">搵唔到呢篇文章</h1>
<p style="color:#5B6B66;line-height:1.9;">可能篇文已經被移除,或者網址打錯咗。</p>
<p><a href="/blog" style="color:#0B8A5E;font-weight:600;">← 返去 Blog</a></p>
</div>
</Base>
)
}
+30
View File
@@ -0,0 +1,30 @@
---
import Base from "../../layouts/Base.astro";
import { BlogIndex } from "../../components/site/BlogIndex";
import { getPublishedPosts, getSettings, formatDate } from "../../data/content";
import { getDb } from "../../lib/db";
import { getEnv } from "../../lib/env";
export const prerender = false;
const db = getDb(getEnv().DB);
const [posts, settings] = await Promise.all([getPublishedPosts(db), getSettings(db)]);
const items = posts.map((p) => ({
slug: p.slug,
title: p.title,
excerpt: p.excerpt,
coverImage: p.coverImage,
dateLabel: formatDate(p.publishedAt),
}));
Astro.response.headers.set("Cache-Control", "public, s-maxage=60, stale-while-revalidate=300");
---
<Base
title="太陽能知識庫"
description={settings.seo_default_description || "村屋太陽能嘅實用資訊、安裝心得同回本分析。"}
image={settings.og_image}
>
<BlogIndex posts={items} settings={settings} client:load />
</Base>
+26
View File
@@ -0,0 +1,26 @@
---
import Base from "../layouts/Base.astro";
import { HomePage } from "../components/site/HomePage";
import { getHomeData } from "../data/content";
import { getDb } from "../lib/db";
import { getEnv } from "../lib/env";
export const prerender = false;
const db = getDb(getEnv().DB);
const data = await getHomeData(db);
const settings = data.settings;
Astro.response.headers.set(
"Cache-Control",
"public, s-maxage=60, stale-while-revalidate=300",
);
---
<Base
title={settings.seo_default_title || "香港村屋太陽能一站式服務"}
description={settings.seo_default_description}
image={settings.og_image}
>
<HomePage data={data} client:load />
</Base>
+14
View File
@@ -0,0 +1,14 @@
import type { APIRoute } from "astro";
export const GET: APIRoute = ({ site }) => {
const origin = (site ?? new URL("https://example.com")).toString().replace(/\/$/, "");
return new Response(
`User-agent: *
Allow: /
Disallow: /admin
Sitemap: ${origin}/sitemap.xml
`,
{ headers: { "Content-Type": "text/plain; charset=utf-8" } },
);
};
+32
View File
@@ -0,0 +1,32 @@
import type { APIRoute } from "astro";
import { desc, eq } from "drizzle-orm";
import { getDb } from "../lib/db";
import { posts } from "../db/schema";
import { getEnv } from "../lib/env";
export const prerender = false;
export const GET: APIRoute = async ({ site }) => {
const origin = (site ?? new URL("https://example.com")).toString().replace(/\/$/, "");
const db = getDb(getEnv().DB);
const list = await db
.select({ slug: posts.slug, updatedAt: posts.updatedAt })
.from(posts)
.where(eq(posts.status, "published"))
.orderBy(desc(posts.publishedAt));
const urls = ["/", "/blog", ...list.map((p) => `/blog/${p.slug}`)];
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.map((u) => ` <url><loc>${origin}${u}</loc></url>`).join("\n")}
</urlset>`;
return new Response(xml, {
headers: {
"Content-Type": "application/xml; charset=utf-8",
"Cache-Control": "public, max-age=0, s-maxage=3600",
},
});
};
+74
View File
@@ -0,0 +1,74 @@
import { createSystem, defaultConfig, defineConfig } from "@chakra-ui/react";
// 琥珀金 —— 全站唯一彩色 accent
const brand = {
50: "#FFFBEB",
100: "#FEF3C7",
200: "#FDE68A",
300: "#FCD34D",
400: "#FBBF24",
500: "#F59E0B",
600: "#D97706",
700: "#B45309",
800: "#92400E",
900: "#78350F",
950: "#451A03",
};
const sansFont =
"'Noto Sans HK', 'PingFang HK', 'Microsoft JhengHei', system-ui, -apple-system, sans-serif";
const serifFont = `'Noto Serif HK', ${sansFont}`;
const config = defineConfig({
globalCss: {
"html": {
scrollBehavior: "smooth",
scrollPaddingTop: "88px",
},
"body": {
fontFamily: sansFont,
bg: "#FAF8F4",
color: "#1A1917",
lineHeight: 1.75,
antialiased: "true",
},
"::selection": {
bg: "brand.200",
color: "brand.900",
},
},
theme: {
tokens: {
colors: {
brand,
ink: {
DEFAULT: { value: "#1A1917" },
muted: { value: "#6E6862" },
},
},
fonts: {
heading: { value: serifFont },
body: { value: sansFont },
},
},
semanticTokens: {
colors: {
"bg.subtle": { value: "#F3EFE7" },
"fg.muted": { value: "#6E6862" },
"border.subtle": { value: "#E7E2D9" },
brand: {
solid: { value: "{colors.brand.700}" },
contrast: { value: "{colors.white}" },
fg: { value: "{colors.brand.800}" },
muted: { value: "{colors.brand.100}" },
subtle: { value: "{colors.brand.50}" },
emphasized: { value: "{colors.brand.300}" },
focusRing: { value: "{colors.brand.500}" },
border: { value: "{colors.brand.200}" },
},
},
},
},
});
export const system = createSystem(defaultConfig, config);