first commit
This commit is contained in:
@@ -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 system(tokens / 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 head(canonical / 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 v3(Emotion),單一 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 schema(migration 的唯一來源) |
|
||||
| `lib/AGENTS.md` | auth / env / db / markdown 基礎工具 |
|
||||
@@ -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
|
||||
|
||||
無。
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: "約 6–9 年" },
|
||||
{ label: "系統可用年期", value: "20–25 年" },
|
||||
{ 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: "20–25 年系統壽命", 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 網站基本資料(build-time 靜態頁用)。
|
||||
* 公司資料、聯絡方式等「可管理」內容放喺 D1 嘅 site_settings,於 /admin 修改。
|
||||
*/
|
||||
export const SITE_NAME = "盈豐太陽能工程有限公司";
|
||||
export const SITE_TAGLINE = "香港村屋太陽能一站式服務";
|
||||
@@ -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
|
||||
|
||||
無。
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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
|
||||
|
||||
無。
|
||||
@@ -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;
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
/// <reference path="../worker-configuration.d.ts" />
|
||||
|
||||
declare namespace App {
|
||||
interface Locals {
|
||||
isAdmin: boolean;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
無。
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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-demand(prerender = false)嘅頁面/endpoint 用。
|
||||
*/
|
||||
export function getEnv(): AppEnv {
|
||||
return env as unknown as AppEnv;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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="留空會用摘要,建議 120–155 字"
|
||||
/>
|
||||
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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" } },
|
||||
);
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user