feat: 完整 MVP — 剁手小黑屋反冲动消费 Web App
- 数据层: Prisma 7 + SQLite (better-sqlite3 adapter), Item 模型 - API: POST/GET/PATCH /api/items, 商品 CRUD 与状态流转 - 关押表单: 图片拖拽上传 + Canvas 压缩, Base64 存储 - 首页看守所: 72h 倒计时 Hook, 省钱看板, 冷却/释放卡片交互 - PWA: manifest.ts, apple-icon, viewport 沉浸配置, iOS 引导组件 - 部署: Dockerfile 多阶段构建, docker-compose, Jenkinsfile CI/CD - 图标: 全套专属像素风锁头+购物车图标 (favicon/96/180/192/512/OG)
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, type ChangeEvent, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Upload, ShieldAlert, ArrowLeft, Loader2 } from "lucide-react";
|
||||
|
||||
const MAX_IMAGE_WIDTH = 800;
|
||||
const MAX_IMAGE_HEIGHT = 800;
|
||||
const IMAGE_QUALITY = 0.7;
|
||||
|
||||
function compressImage(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
let { width, height } = img;
|
||||
|
||||
if (width > MAX_IMAGE_WIDTH || height > MAX_IMAGE_HEIGHT) {
|
||||
const ratio = Math.min(
|
||||
MAX_IMAGE_WIDTH / width,
|
||||
MAX_IMAGE_HEIGHT / height
|
||||
);
|
||||
width = Math.round(width * ratio);
|
||||
height = Math.round(height * ratio);
|
||||
}
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
resolve(canvas.toDataURL("image/jpeg", IMAGE_QUALITY));
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = e.target?.result as string;
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export default function AddItemPage() {
|
||||
const router = useRouter();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [price, setPrice] = useState("");
|
||||
const [link, setLink] = useState("");
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [imageBase64, setImageBase64] = useState<string>("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
async function handleImageFile(file: File) {
|
||||
if (!file.type.startsWith("image/")) return;
|
||||
const compressed = await compressImage(file);
|
||||
setImageBase64(compressed);
|
||||
setImagePreview(compressed);
|
||||
}
|
||||
|
||||
function handleFileChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleImageFile(file);
|
||||
}
|
||||
|
||||
function handleDrop(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) handleImageFile(file);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!name || !price || !link || !imageBase64) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch("/api/items", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
price: parseFloat(price),
|
||||
link,
|
||||
image: imageBase64,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
router.push("/");
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 text-zinc-100">
|
||||
<div className="mx-auto max-w-lg px-4 py-8">
|
||||
<button
|
||||
onClick={() => router.push("/")}
|
||||
className="mb-6 flex items-center gap-1.5 text-sm text-zinc-500 transition hover:text-zinc-300"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
返回看守所
|
||||
</button>
|
||||
|
||||
<div className="mb-8 flex items-center gap-3">
|
||||
<ShieldAlert className="text-red-500" size={28} />
|
||||
<h1 className="text-2xl font-bold tracking-tight">关押新商品</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* 商品名称 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-zinc-400">
|
||||
商品名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="例:机械键盘 / AirPods Max"
|
||||
className="w-full rounded-lg border border-zinc-800 bg-zinc-900 px-4 py-2.5 text-zinc-100 placeholder:text-zinc-600 focus:border-red-500/50 focus:outline-none focus:ring-1 focus:ring-red-500/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 价格 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-zinc-400">
|
||||
价格(元)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="w-full rounded-lg border border-zinc-800 bg-zinc-900 px-4 py-2.5 text-zinc-100 placeholder:text-zinc-600 focus:border-red-500/50 focus:outline-none focus:ring-1 focus:ring-red-500/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 购买链接 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-zinc-400">
|
||||
购买链接
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
required
|
||||
value={link}
|
||||
onChange={(e) => setLink(e.target.value)}
|
||||
placeholder="https://..."
|
||||
className="w-full rounded-lg border border-zinc-800 bg-zinc-900 px-4 py-2.5 text-zinc-100 placeholder:text-zinc-600 focus:border-red-500/50 focus:outline-none focus:ring-1 focus:ring-red-500/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 图片上传 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-zinc-400">
|
||||
商品图片
|
||||
</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
className={`flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed transition ${
|
||||
dragOver
|
||||
? "border-red-500 bg-red-500/5"
|
||||
: "border-zinc-700 bg-zinc-900 hover:border-zinc-600"
|
||||
} ${imagePreview ? "p-2" : "p-8"}`}
|
||||
>
|
||||
{imagePreview ? (
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="预览"
|
||||
className="max-h-48 rounded object-contain"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="mb-2 text-zinc-600" size={32} />
|
||||
<p className="text-sm text-zinc-500">
|
||||
点击或拖拽上传商品图片
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-zinc-600">
|
||||
图片将自动压缩至 800px 以内
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{imagePreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setImagePreview(null);
|
||||
setImageBase64("");
|
||||
}}
|
||||
className="mt-2 text-xs text-zinc-500 hover:text-red-400"
|
||||
>
|
||||
移除图片,重新上传
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 提交 */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !name || !price || !link || !imageBase64}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-red-600 py-3 font-semibold text-white transition hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 className="animate-spin" size={18} />
|
||||
) : (
|
||||
<ShieldAlert size={18} />
|
||||
)}
|
||||
{submitting ? "正在关押中…" : "确认关入小黑屋"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { status } = body;
|
||||
|
||||
if (!status || !["saved", "bought"].includes(status)) {
|
||||
return NextResponse.json({ error: "无效的状态值" }, { status: 400 });
|
||||
}
|
||||
|
||||
const item = await prisma.item.update({
|
||||
where: { id },
|
||||
data: { status },
|
||||
});
|
||||
|
||||
return NextResponse.json(item);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
const items = await prisma.item.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return NextResponse.json(items);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
const { name, price, link, image } = body;
|
||||
|
||||
if (!name || price == null || !link || !image) {
|
||||
return NextResponse.json({ error: "缺少必填字段" }, { status: 400 });
|
||||
}
|
||||
|
||||
const item = await prisma.item.create({
|
||||
data: { name, price: Number(price), link, image },
|
||||
});
|
||||
|
||||
return NextResponse.json(item, { status: 201 });
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 439 B |
@@ -0,0 +1,10 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans), system-ui, sans-serif;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,53 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import PwaInstallGuide from "@/components/PwaInstallGuide";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#000000",
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "剁手小黑屋 | ImpulseJail",
|
||||
description: "反冲动消费工具——把想买的商品关进小黑屋冷却 72 小时",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: "小黑屋",
|
||||
statusBarStyle: "black-translucent",
|
||||
},
|
||||
formatDetection: {
|
||||
telephone: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<PwaInstallGuide />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "剁手小黑屋",
|
||||
short_name: "小黑屋",
|
||||
description: "反冲动消费,72小时冷静期",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#000000",
|
||||
theme_color: "#000000",
|
||||
icons: [
|
||||
{
|
||||
src: "/icon-192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "/icon-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 794 KiB |
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ShieldAlert,
|
||||
Plus,
|
||||
Lock,
|
||||
PartyPopper,
|
||||
ShoppingCart,
|
||||
Timer,
|
||||
Ban,
|
||||
Inbox,
|
||||
} from "lucide-react";
|
||||
import { useCountdown } from "@/hooks/useCountdown";
|
||||
|
||||
interface Item {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
link: string;
|
||||
image: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function ItemCard({
|
||||
item,
|
||||
onStatusChange,
|
||||
}: {
|
||||
item: Item;
|
||||
onStatusChange: () => void;
|
||||
}) {
|
||||
const { display, isExpired } = useCountdown(item.createdAt);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
|
||||
async function updateStatus(status: "saved" | "bought") {
|
||||
setUpdating(true);
|
||||
try {
|
||||
await fetch(`/api/items/${item.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
|
||||
if (status === "bought") {
|
||||
window.open(item.link, "_blank");
|
||||
}
|
||||
|
||||
onStatusChange();
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900/80 transition hover:border-zinc-700">
|
||||
<div className="flex gap-4 p-4">
|
||||
{/* 商品图片 */}
|
||||
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-800">
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 商品信息 */}
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-between">
|
||||
<div>
|
||||
<h3 className="truncate font-semibold text-zinc-100">
|
||||
{item.name}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-lg font-bold text-red-400">
|
||||
¥{item.price.toLocaleString("zh-CN", { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 倒计时 */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Timer size={14} className="text-zinc-500" />
|
||||
{isExpired ? (
|
||||
<span className="text-sm font-medium text-emerald-400">
|
||||
冷却完毕
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono text-sm font-bold text-red-500">
|
||||
⏳ {display}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮区 */}
|
||||
<div className="border-t border-zinc-800 px-4 py-3">
|
||||
{isExpired ? (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => updateStatus("saved")}
|
||||
disabled={updating}
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-emerald-600 py-2 text-sm font-semibold text-white transition hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
<PartyPopper size={15} />
|
||||
不想买了,省钱!
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateStatus("bought")}
|
||||
disabled={updating}
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-zinc-700 py-2 text-sm font-medium text-zinc-400 transition hover:border-zinc-600 hover:text-zinc-300 disabled:opacity-50"
|
||||
>
|
||||
<ShoppingCart size={15} />
|
||||
还是想买
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
disabled
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-lg bg-zinc-800 py-2 text-sm font-medium text-zinc-500"
|
||||
>
|
||||
<Lock size={14} />
|
||||
冷却中,不可购买
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
const res = await fetch("/api/items");
|
||||
const data: Item[] = await res.json();
|
||||
setItems(data);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, [fetchItems]);
|
||||
|
||||
const coolingItems = items.filter((i) => i.status === "cooling");
|
||||
const savedTotal = items
|
||||
.filter((i) => i.status === "saved")
|
||||
.reduce((sum, i) => sum + i.price, 0);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 text-zinc-100">
|
||||
<div className="mx-auto max-w-lg px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ShieldAlert className="text-red-500" size={28} />
|
||||
<h1 className="text-xl font-bold tracking-tight">剁手小黑屋</h1>
|
||||
</div>
|
||||
<Link
|
||||
href="/add"
|
||||
className="flex items-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-700"
|
||||
>
|
||||
<Plus size={16} />
|
||||
关押
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 省钱看板 */}
|
||||
<div className="mb-6 rounded-xl border border-emerald-500/20 bg-emerald-950/30 p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-emerald-400/70">
|
||||
<Ban size={16} />
|
||||
历史成功拦截冲动消费
|
||||
</div>
|
||||
<p className="mt-1 text-3xl font-bold text-emerald-400">
|
||||
¥{savedTotal.toLocaleString("zh-CN", { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 在押商品列表 */}
|
||||
<div className="mb-4 flex items-center gap-2 text-sm font-medium text-zinc-400">
|
||||
<Lock size={14} />
|
||||
在押商品({coolingItems.length})
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-zinc-700 border-t-red-500" />
|
||||
</div>
|
||||
) : coolingItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-16 text-zinc-600">
|
||||
<Inbox size={48} className="mb-3" />
|
||||
<p className="text-sm">小黑屋空空如也</p>
|
||||
<p className="mt-1 text-xs">看来你最近很理性!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{coolingItems.map((item) => (
|
||||
<ItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onStatusChange={fetchItems}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { X, Share } from "lucide-react";
|
||||
|
||||
const DISMISS_KEY = "pwa-guide-dismissed-at";
|
||||
const SUPPRESS_DAYS = 7;
|
||||
|
||||
function isDismissed(): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem(DISMISS_KEY);
|
||||
if (!raw) return false;
|
||||
const dismissedAt = Number(raw);
|
||||
return Date.now() - dismissedAt < SUPPRESS_DAYS * 24 * 60 * 60 * 1000;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
try {
|
||||
localStorage.setItem(DISMISS_KEY, String(Date.now()));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export default function PwaInstallGuide() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const isIos = /iPhone|iPad|iPod/i.test(navigator.userAgent);
|
||||
const isStandalone =
|
||||
("standalone" in navigator && (navigator as never)["standalone"]) ||
|
||||
window.matchMedia("(display-mode: standalone)").matches;
|
||||
|
||||
if (isIos && !isStandalone && !isDismissed()) {
|
||||
setVisible(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
function handleClose() {
|
||||
dismiss();
|
||||
setVisible(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||
className="fixed inset-x-0 bottom-0 z-50 px-4 pb-6"
|
||||
>
|
||||
<div className="relative mx-auto max-w-lg rounded-2xl border border-zinc-800 bg-zinc-900/95 p-4 shadow-2xl shadow-black/60 backdrop-blur-md">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="absolute right-3 top-3 rounded-full p-1 text-zinc-500 transition hover:bg-zinc-800 hover:text-zinc-300"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
|
||||
<p className="pr-6 text-sm leading-relaxed text-zinc-300">
|
||||
💡 想要最佳体验?点击底部
|
||||
<span className="mx-1 inline-flex translate-y-0.5">
|
||||
<Share size={14} className="text-blue-400" />
|
||||
</span>
|
||||
选择
|
||||
<span className="font-semibold text-zinc-100">
|
||||
「添加到主屏幕」
|
||||
</span>
|
||||
,即可作为独立 App 沉浸使用。
|
||||
</p>
|
||||
|
||||
{/* 底部小三角指示 Safari 分享按钮位置 */}
|
||||
<div className="mt-3 flex justify-center">
|
||||
<motion.div
|
||||
animate={{ y: [0, 4, 0] }}
|
||||
transition={{ repeat: Infinity, duration: 1.5 }}
|
||||
className="text-zinc-600"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="10"
|
||||
viewBox="0 0 20 10"
|
||||
fill="currentColor"
|
||||
>
|
||||
<polygon points="10,10 0,0 20,0" />
|
||||
</svg>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const COOLING_HOURS = 72;
|
||||
const COOLING_MS = COOLING_HOURS * 60 * 60 * 1000;
|
||||
|
||||
export interface CountdownResult {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
isExpired: boolean;
|
||||
display: string;
|
||||
}
|
||||
|
||||
export function useCountdown(createdAt: string): CountdownResult {
|
||||
const [now, setNow] = useState(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const created = new Date(createdAt).getTime();
|
||||
const deadline = created + COOLING_MS;
|
||||
const remaining = Math.max(0, deadline - now);
|
||||
|
||||
const totalSeconds = Math.floor(remaining / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||
const display = `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
|
||||
|
||||
return {
|
||||
hours,
|
||||
minutes,
|
||||
seconds,
|
||||
isExpired: remaining === 0,
|
||||
display,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
|
||||
|
||||
function createClient() {
|
||||
const url = process.env.DATABASE_URL ?? "file:./dev.db";
|
||||
const adapter = new PrismaBetterSqlite3({ url });
|
||||
return new PrismaClient({ adapter });
|
||||
}
|
||||
|
||||
export const prisma = globalForPrisma.prisma || createClient();
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
Reference in New Issue
Block a user