Skip to content

Commit fe16fdd

Browse files
ChanMeng666claude
andcommitted
feat: transform CLI tool into visual web application platform
- Add Next.js 15 App Router with React 19 support - Create dashboard homepage with quick actions and template categories - Implement visual template editor with block-based editing - Add template library with 6 preset templates (holiday, marketing, newsletter) - Build contacts management page with CRUD and local storage - Create step-by-step email sending wizard - Add settings page for Resend API configuration - Implement Neobrutalism-styled UI with Tailwind CSS and shadcn/ui - Set up responsive sidebar navigation - Configure TypeScript types for templates and blocks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 2a330c7 commit fe16fdd

25 files changed

Lines changed: 10541 additions & 539 deletions

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ node_modules/
33

44
# Build output
55
dist/
6+
.next/
7+
out/
8+
9+
# Next.js
10+
next-env.d.ts
611

712
# Environment variables (SENSITIVE - never commit!)
813
.env
@@ -29,3 +34,4 @@ Thumbs.db
2934

3035
# Claude Code
3136
.claude/
37+
nul

app/contacts/page.tsx

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
'use client'
2+
3+
import { useState, useEffect } from 'react'
4+
import {
5+
Plus,
6+
Search,
7+
Upload,
8+
RefreshCw,
9+
Trash2,
10+
Edit,
11+
Mail,
12+
User
13+
} from 'lucide-react'
14+
import { Button } from '@/components/ui/button'
15+
import { Input } from '@/components/ui/input'
16+
import { Card } from '@/components/ui/card'
17+
18+
interface Contact {
19+
id: string
20+
email: string
21+
firstName: string
22+
lastName: string
23+
createdAt: string
24+
}
25+
26+
const STORAGE_KEY = 'email-platform-contacts'
27+
28+
export default function ContactsPage() {
29+
const [contacts, setContacts] = useState<Contact[]>([])
30+
const [searchQuery, setSearchQuery] = useState('')
31+
const [showAddForm, setShowAddForm] = useState(false)
32+
const [newContact, setNewContact] = useState({
33+
email: '',
34+
firstName: '',
35+
lastName: '',
36+
})
37+
38+
// 从 localStorage 加载联系人
39+
useEffect(() => {
40+
const saved = localStorage.getItem(STORAGE_KEY)
41+
if (saved) {
42+
try {
43+
setContacts(JSON.parse(saved))
44+
} catch (e) {
45+
console.error('Failed to load contacts:', e)
46+
}
47+
}
48+
}, [])
49+
50+
// 保存联系人到 localStorage
51+
const saveContacts = (newContacts: Contact[]) => {
52+
setContacts(newContacts)
53+
localStorage.setItem(STORAGE_KEY, JSON.stringify(newContacts))
54+
}
55+
56+
const handleAddContact = () => {
57+
if (!newContact.email) return
58+
59+
const contact: Contact = {
60+
id: Date.now().toString(),
61+
email: newContact.email,
62+
firstName: newContact.firstName,
63+
lastName: newContact.lastName,
64+
createdAt: new Date().toISOString(),
65+
}
66+
67+
saveContacts([...contacts, contact])
68+
setNewContact({ email: '', firstName: '', lastName: '' })
69+
setShowAddForm(false)
70+
}
71+
72+
const handleDeleteContact = (id: string) => {
73+
saveContacts(contacts.filter(c => c.id !== id))
74+
}
75+
76+
const filteredContacts = contacts.filter(contact => {
77+
const query = searchQuery.toLowerCase()
78+
return (
79+
contact.email.toLowerCase().includes(query) ||
80+
contact.firstName.toLowerCase().includes(query) ||
81+
contact.lastName.toLowerCase().includes(query)
82+
)
83+
})
84+
85+
return (
86+
<div className="p-8">
87+
{/* Header */}
88+
<div className="flex items-center justify-between mb-8">
89+
<div>
90+
<h1 className="text-3xl font-black uppercase tracking-tight mb-2">
91+
Contacts
92+
</h1>
93+
<p className="text-gray-600">
94+
Manage your email recipients
95+
</p>
96+
</div>
97+
<div className="flex gap-2">
98+
<Button variant="outline" className="neo-border">
99+
<Upload className="w-4 h-4 mr-2" />
100+
Import CSV
101+
</Button>
102+
<Button variant="outline" className="neo-border">
103+
<RefreshCw className="w-4 h-4 mr-2" />
104+
Sync Resend
105+
</Button>
106+
<Button
107+
className="neo-button bg-neo-green text-white"
108+
onClick={() => setShowAddForm(true)}
109+
>
110+
<Plus className="w-4 h-4 mr-2" />
111+
Add Contact
112+
</Button>
113+
</div>
114+
</div>
115+
116+
{/* Search */}
117+
<div className="mb-6">
118+
<div className="relative max-w-md">
119+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
120+
<Input
121+
placeholder="Search contacts..."
122+
value={searchQuery}
123+
onChange={(e) => setSearchQuery(e.target.value)}
124+
className="neo-border pl-10"
125+
/>
126+
</div>
127+
</div>
128+
129+
{/* Add Contact Form */}
130+
{showAddForm && (
131+
<Card className="neo-border neo-shadow p-6 mb-6">
132+
<h3 className="font-bold text-lg mb-4">Add New Contact</h3>
133+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
134+
<Input
135+
placeholder="Email *"
136+
type="email"
137+
value={newContact.email}
138+
onChange={(e) => setNewContact({ ...newContact, email: e.target.value })}
139+
className="neo-border"
140+
/>
141+
<Input
142+
placeholder="First Name"
143+
value={newContact.firstName}
144+
onChange={(e) => setNewContact({ ...newContact, firstName: e.target.value })}
145+
className="neo-border"
146+
/>
147+
<Input
148+
placeholder="Last Name"
149+
value={newContact.lastName}
150+
onChange={(e) => setNewContact({ ...newContact, lastName: e.target.value })}
151+
className="neo-border"
152+
/>
153+
</div>
154+
<div className="flex gap-2">
155+
<Button
156+
className="neo-button bg-neo-green text-white"
157+
onClick={handleAddContact}
158+
>
159+
Add Contact
160+
</Button>
161+
<Button
162+
variant="outline"
163+
className="neo-border"
164+
onClick={() => setShowAddForm(false)}
165+
>
166+
Cancel
167+
</Button>
168+
</div>
169+
</Card>
170+
)}
171+
172+
{/* Contacts List */}
173+
<Card className="neo-border neo-shadow">
174+
{contacts.length === 0 ? (
175+
<div className="p-12 text-center">
176+
<User className="w-12 h-12 mx-auto mb-4 text-gray-300" />
177+
<h3 className="text-lg font-bold mb-2">No contacts yet</h3>
178+
<p className="text-gray-600 mb-4">
179+
Add your first contact or import from CSV
180+
</p>
181+
<Button
182+
className="neo-button bg-neo-green text-white"
183+
onClick={() => setShowAddForm(true)}
184+
>
185+
<Plus className="w-4 h-4 mr-2" />
186+
Add Contact
187+
</Button>
188+
</div>
189+
) : (
190+
<div className="overflow-x-auto">
191+
<table className="w-full">
192+
<thead className="bg-gray-50 border-b-4 border-black">
193+
<tr>
194+
<th className="text-left p-4 font-bold uppercase text-sm">Email</th>
195+
<th className="text-left p-4 font-bold uppercase text-sm">First Name</th>
196+
<th className="text-left p-4 font-bold uppercase text-sm">Last Name</th>
197+
<th className="text-left p-4 font-bold uppercase text-sm">Added</th>
198+
<th className="text-right p-4 font-bold uppercase text-sm">Actions</th>
199+
</tr>
200+
</thead>
201+
<tbody>
202+
{filteredContacts.map((contact, index) => (
203+
<tr
204+
key={contact.id}
205+
className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}
206+
>
207+
<td className="p-4">
208+
<div className="flex items-center gap-2">
209+
<Mail className="w-4 h-4 text-gray-400" />
210+
{contact.email}
211+
</div>
212+
</td>
213+
<td className="p-4">{contact.firstName || '-'}</td>
214+
<td className="p-4">{contact.lastName || '-'}</td>
215+
<td className="p-4 text-gray-500 text-sm">
216+
{new Date(contact.createdAt).toLocaleDateString()}
217+
</td>
218+
<td className="p-4 text-right">
219+
<div className="flex justify-end gap-2">
220+
<Button size="sm" variant="ghost">
221+
<Edit className="w-4 h-4" />
222+
</Button>
223+
<Button
224+
size="sm"
225+
variant="ghost"
226+
className="text-red-500 hover:text-red-700"
227+
onClick={() => handleDeleteContact(contact.id)}
228+
>
229+
<Trash2 className="w-4 h-4" />
230+
</Button>
231+
</div>
232+
</td>
233+
</tr>
234+
))}
235+
</tbody>
236+
</table>
237+
</div>
238+
)}
239+
</Card>
240+
241+
{/* Stats */}
242+
{contacts.length > 0 && (
243+
<div className="mt-4 text-sm text-gray-500">
244+
Showing {filteredContacts.length} of {contacts.length} contacts
245+
</div>
246+
)}
247+
</div>
248+
)
249+
}

app/globals.css

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
@tailwind base;
2+
@tailwind components;
3+
@tailwind utilities;
4+
5+
@layer base {
6+
:root {
7+
--background: 0 0% 100%;
8+
--foreground: 222.2 84% 4.9%;
9+
--card: 0 0% 100%;
10+
--card-foreground: 222.2 84% 4.9%;
11+
--popover: 0 0% 100%;
12+
--popover-foreground: 222.2 84% 4.9%;
13+
--primary: 0 72% 51%;
14+
--primary-foreground: 210 40% 98%;
15+
--secondary: 142 71% 45%;
16+
--secondary-foreground: 222.2 47.4% 11.2%;
17+
--muted: 210 40% 96.1%;
18+
--muted-foreground: 215.4 16.3% 46.9%;
19+
--accent: 38 92% 50%;
20+
--accent-foreground: 222.2 47.4% 11.2%;
21+
--destructive: 0 84.2% 60.2%;
22+
--destructive-foreground: 210 40% 98%;
23+
--border: 214.3 31.8% 91.4%;
24+
--input: 214.3 31.8% 91.4%;
25+
--ring: 0 72% 51%;
26+
--radius: 0.5rem;
27+
}
28+
29+
.dark {
30+
--background: 222.2 84% 4.9%;
31+
--foreground: 210 40% 98%;
32+
--card: 222.2 84% 4.9%;
33+
--card-foreground: 210 40% 98%;
34+
--popover: 222.2 84% 4.9%;
35+
--popover-foreground: 210 40% 98%;
36+
--primary: 0 72% 51%;
37+
--primary-foreground: 210 40% 98%;
38+
--secondary: 142 71% 45%;
39+
--secondary-foreground: 210 40% 98%;
40+
--muted: 217.2 32.6% 17.5%;
41+
--muted-foreground: 215 20.2% 65.1%;
42+
--accent: 38 92% 50%;
43+
--accent-foreground: 210 40% 98%;
44+
--destructive: 0 62.8% 30.6%;
45+
--destructive-foreground: 210 40% 98%;
46+
--border: 217.2 32.6% 17.5%;
47+
--input: 217.2 32.6% 17.5%;
48+
--ring: 0 72% 51%;
49+
}
50+
}
51+
52+
@layer base {
53+
* {
54+
@apply border-border;
55+
}
56+
body {
57+
@apply bg-background text-foreground;
58+
}
59+
}
60+
61+
/* Neobrutalism 风格通用类 */
62+
@layer components {
63+
.neo-border {
64+
@apply border-4 border-black;
65+
}
66+
67+
.neo-shadow {
68+
box-shadow: 4px 4px 0px 0px #000000;
69+
}
70+
71+
.neo-shadow-lg {
72+
box-shadow: 8px 8px 0px 0px #000000;
73+
}
74+
75+
.neo-shadow-red {
76+
box-shadow: 4px 4px 0px 0px #DC2626;
77+
}
78+
79+
.neo-shadow-green {
80+
box-shadow: 4px 4px 0px 0px #16A34A;
81+
}
82+
83+
.neo-button {
84+
@apply neo-border neo-shadow font-bold uppercase tracking-wide;
85+
@apply hover:translate-x-1 hover:translate-y-1 hover:shadow-none;
86+
@apply transition-all duration-150;
87+
}
88+
89+
.neo-card {
90+
@apply neo-border neo-shadow bg-white p-6;
91+
}
92+
93+
.neo-input {
94+
@apply neo-border px-4 py-2 focus:outline-none focus:ring-2 focus:ring-neo-gold;
95+
}
96+
}

app/layout.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { Metadata } from 'next'
2+
import { Inter } from 'next/font/google'
3+
import './globals.css'
4+
import { Sidebar } from '@/components/shared/Sidebar'
5+
import { Toaster } from '@/components/ui/toaster'
6+
7+
const inter = Inter({ subsets: ['latin'] })
8+
9+
export const metadata: Metadata = {
10+
title: 'Email Template Platform',
11+
description: 'Create beautiful email templates for holidays, marketing, and newsletters',
12+
}
13+
14+
export default function RootLayout({
15+
children,
16+
}: {
17+
children: React.ReactNode
18+
}) {
19+
return (
20+
<html lang="en">
21+
<body className={inter.className}>
22+
<div className="flex min-h-screen">
23+
<Sidebar />
24+
<main className="flex-1 bg-gray-50">
25+
{children}
26+
</main>
27+
</div>
28+
<Toaster />
29+
</body>
30+
</html>
31+
)
32+
}

0 commit comments

Comments
 (0)