create-varity-app 2.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +85 -0
  3. package/dist/create.js +141 -0
  4. package/dist/index.js +45 -0
  5. package/dist/utils.js +29 -0
  6. package/package.json +61 -0
  7. package/template/.env.example +17 -0
  8. package/template/KNOWN_ISSUES.md +69 -0
  9. package/template/LICENSE +21 -0
  10. package/template/README.md +241 -0
  11. package/template/gitignore +42 -0
  12. package/template/next-env.d.ts +6 -0
  13. package/template/next.config.js +21 -0
  14. package/template/package.json +39 -0
  15. package/template/postcss.config.js +6 -0
  16. package/template/public/logo.svg +4 -0
  17. package/template/public/robots.txt +4 -0
  18. package/template/public/sitemap.xml +4 -0
  19. package/template/src/app/dashboard/layout.tsx +298 -0
  20. package/template/src/app/dashboard/page.tsx +209 -0
  21. package/template/src/app/dashboard/projects/page.tsx +638 -0
  22. package/template/src/app/dashboard/settings/page.tsx +749 -0
  23. package/template/src/app/dashboard/tasks/page.tsx +301 -0
  24. package/template/src/app/dashboard/team/page.tsx +295 -0
  25. package/template/src/app/globals.css +177 -0
  26. package/template/src/app/icon.svg +4 -0
  27. package/template/src/app/layout.tsx +33 -0
  28. package/template/src/app/login/page.tsx +98 -0
  29. package/template/src/app/not-found.tsx +20 -0
  30. package/template/src/app/page.tsx +23 -0
  31. package/template/src/components/dashboard/DashboardStats.tsx +137 -0
  32. package/template/src/components/dashboard/RecentActivity.tsx +63 -0
  33. package/template/src/components/landing/CTA.tsx +42 -0
  34. package/template/src/components/landing/Features.tsx +116 -0
  35. package/template/src/components/landing/Hero.tsx +146 -0
  36. package/template/src/components/landing/HowItWorks.tsx +80 -0
  37. package/template/src/components/landing/Pricing.tsx +124 -0
  38. package/template/src/components/landing/Testimonials.tsx +78 -0
  39. package/template/src/components/providers.tsx +11 -0
  40. package/template/src/components/shared/Footer.tsx +71 -0
  41. package/template/src/components/shared/Navbar.tsx +87 -0
  42. package/template/src/lib/constants.ts +35 -0
  43. package/template/src/lib/database.ts +7 -0
  44. package/template/src/lib/hooks.ts +331 -0
  45. package/template/src/lib/utils.ts +68 -0
  46. package/template/src/lib/varity.ts +1 -0
  47. package/template/src/services/dashboardService.ts +589 -0
  48. package/template/src/types/index.ts +52 -0
  49. package/template/tailwind.config.js +27 -0
  50. package/template/tsconfig.json +23 -0
  51. package/template/varity.config.json +14 -0
@@ -0,0 +1,301 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { DataTable, EmptyState } from '@varity-labs/ui-kit';
5
+ import { useTasks, useProjects } from '@/lib/hooks';
6
+ import {
7
+ useToast,
8
+ Button,
9
+ Input,
10
+ Textarea,
11
+ Select,
12
+ Dialog,
13
+ ConfirmDialog,
14
+ TaskStatusBadge,
15
+ PriorityBadge
16
+ } from '@varity-labs/ui-kit';
17
+ import { formatDateShort, downloadCSV } from '@/lib/utils';
18
+ import { TASK_STATUS_OPTIONS, PRIORITY_OPTIONS } from '@/lib/constants';
19
+ import { ListTodo, Pencil, Trash2, Download } from 'lucide-react';
20
+ import type { Task } from '@/types';
21
+
22
+ const STATUS_CYCLE: Record<string, Task['status']> = {
23
+ todo: 'in_progress',
24
+ in_progress: 'done',
25
+ done: 'todo',
26
+ };
27
+
28
+ const FILTER_OPTIONS = [
29
+ { value: 'all', label: 'All' },
30
+ ...TASK_STATUS_OPTIONS,
31
+ ];
32
+
33
+ const EMPTY_EDIT = { title: '', description: '', priority: 'medium' as Task['priority'], status: 'todo' as Task['status'] };
34
+
35
+ export default function TasksPage() {
36
+ const { data: tasks, loading, error, update, remove, refresh } = useTasks();
37
+ const { data: projects } = useProjects();
38
+ const toast = useToast();
39
+ const [statusFilter, setStatusFilter] = useState<string>('all');
40
+
41
+ // Edit task dialog
42
+ const [editingTask, setEditingTask] = useState<Task | null>(null);
43
+ const [editForm, setEditForm] = useState(EMPTY_EDIT);
44
+ const [editTitleError, setEditTitleError] = useState('');
45
+ const [editSubmitting, setEditSubmitting] = useState(false);
46
+
47
+ // Delete task confirmation
48
+ const [deletingTaskId, setDeletingTaskId] = useState<string | null>(null);
49
+ const [deleteSubmitting, setDeleteSubmitting] = useState(false);
50
+
51
+ const filteredTasks =
52
+ statusFilter === 'all'
53
+ ? tasks
54
+ : tasks.filter((t) => t.status === statusFilter);
55
+
56
+ function getProjectName(projectId: string): string {
57
+ const project = projects.find((p) => p.id === projectId);
58
+ return project?.name || 'Unknown';
59
+ }
60
+
61
+ async function cycleStatus(task: Task): Promise<void> {
62
+ if (!task.id) return;
63
+ try {
64
+ await update(task.id, { status: STATUS_CYCLE[task.status] });
65
+ toast.success(`Task marked as ${STATUS_CYCLE[task.status].replace('_', ' ')}`);
66
+ } catch {
67
+ toast.error('Failed to update task status');
68
+ }
69
+ }
70
+
71
+ function startEditTask(task: Task) {
72
+ setEditingTask(task);
73
+ setEditForm({
74
+ title: task.title,
75
+ description: task.description || '',
76
+ priority: task.priority,
77
+ status: task.status,
78
+ });
79
+ setEditTitleError('');
80
+ }
81
+
82
+ function resetEditDialog() {
83
+ setEditingTask(null);
84
+ setEditForm(EMPTY_EDIT);
85
+ setEditTitleError('');
86
+ }
87
+
88
+ async function handleEditTask() {
89
+ if (!editingTask?.id) return;
90
+ if (!editForm.title.trim()) {
91
+ setEditTitleError('Task title is required');
92
+ return;
93
+ }
94
+
95
+ setEditSubmitting(true);
96
+ try {
97
+ await update(editingTask.id, {
98
+ title: editForm.title.trim(),
99
+ description: editForm.description,
100
+ priority: editForm.priority,
101
+ status: editForm.status,
102
+ });
103
+ toast.success('Task updated successfully');
104
+ resetEditDialog();
105
+ } catch {
106
+ toast.error('Failed to update task. Please try again.');
107
+ } finally {
108
+ setEditSubmitting(false);
109
+ }
110
+ }
111
+
112
+ async function handleDeleteTask() {
113
+ if (!deletingTaskId) return;
114
+
115
+ setDeleteSubmitting(true);
116
+ try {
117
+ await remove(deletingTaskId);
118
+ toast.success('Task deleted');
119
+ setDeletingTaskId(null);
120
+ } catch {
121
+ toast.error('Failed to delete task. Please try again.');
122
+ } finally {
123
+ setDeleteSubmitting(false);
124
+ }
125
+ }
126
+
127
+ const columns = [
128
+ { key: 'title', header: 'Task', sortable: true },
129
+ {
130
+ key: 'projectId',
131
+ header: 'Project',
132
+ render: (value: string) => (
133
+ <span className="text-gray-600">{getProjectName(value)}</span>
134
+ ),
135
+ },
136
+ {
137
+ key: 'status',
138
+ header: 'Status',
139
+ render: (value: string, row: Task) => (
140
+ <button onClick={() => cycleStatus(row)} title="Click to change status">
141
+ <TaskStatusBadge status={value} />
142
+ </button>
143
+ ),
144
+ },
145
+ {
146
+ key: 'priority',
147
+ header: 'Priority',
148
+ sortable: true,
149
+ render: (value: string) => <PriorityBadge priority={value || 'medium'} />,
150
+ },
151
+ {
152
+ key: 'assignee',
153
+ header: 'Assignee',
154
+ render: (value: string) => value || 'Unassigned',
155
+ },
156
+ {
157
+ key: 'createdAt',
158
+ header: 'Created',
159
+ sortable: true,
160
+ render: (value: string) => formatDateShort(value),
161
+ },
162
+ {
163
+ key: 'id',
164
+ header: '',
165
+ render: (_: string, row: Task) => (
166
+ <div className="flex gap-1">
167
+ <button
168
+ onClick={() => startEditTask(row)}
169
+ className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
170
+ title="Edit task"
171
+ >
172
+ <Pencil className="h-4 w-4" />
173
+ </button>
174
+ <button
175
+ onClick={() => setDeletingTaskId(row.id!)}
176
+ className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600 transition-colors"
177
+ title="Delete task"
178
+ >
179
+ <Trash2 className="h-4 w-4" />
180
+ </button>
181
+ </div>
182
+ ),
183
+ },
184
+ ];
185
+
186
+ return (
187
+ <div className="space-y-6">
188
+ <Dialog
189
+ open={!!editingTask}
190
+ onClose={resetEditDialog}
191
+ title="Edit Task"
192
+ description="Update task details."
193
+ >
194
+ <div className="space-y-4">
195
+ <Input
196
+ label="Task Title"
197
+ required
198
+ value={editForm.title}
199
+ onChange={(e) => { setEditForm({ ...editForm, title: e.target.value }); if (editTitleError) setEditTitleError(''); }}
200
+ error={editTitleError}
201
+ placeholder="What needs to be done?"
202
+ />
203
+ <Textarea
204
+ label="Description"
205
+ value={editForm.description}
206
+ onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
207
+ rows={2}
208
+ placeholder="Add details..."
209
+ />
210
+ <Select
211
+ label="Status"
212
+ value={editForm.status}
213
+ onChange={(e) => setEditForm({ ...editForm, status: e.target.value as Task['status'] })}
214
+ options={[...TASK_STATUS_OPTIONS]}
215
+ />
216
+ <Select
217
+ label="Priority"
218
+ value={editForm.priority}
219
+ onChange={(e) => setEditForm({ ...editForm, priority: e.target.value as Task['priority'] })}
220
+ options={[...PRIORITY_OPTIONS]}
221
+ />
222
+ <div className="flex gap-2 pt-2">
223
+ <Button onClick={handleEditTask} loading={editSubmitting}>Save Changes</Button>
224
+ <Button variant="secondary" onClick={resetEditDialog} disabled={editSubmitting}>Cancel</Button>
225
+ </div>
226
+ </div>
227
+ </Dialog>
228
+
229
+ <ConfirmDialog
230
+ open={!!deletingTaskId}
231
+ onClose={() => setDeletingTaskId(null)}
232
+ onConfirm={handleDeleteTask}
233
+ title="Delete Task"
234
+ description="Are you sure you want to delete this task? This action cannot be undone."
235
+ confirmLabel="Delete Task"
236
+ loading={deleteSubmitting}
237
+ />
238
+
239
+ <div className="flex items-center justify-between">
240
+ <div>
241
+ <h1 className="text-2xl font-bold text-gray-900">Tasks</h1>
242
+ <p className="mt-1 text-sm text-gray-600">
243
+ All tasks across your projects. Click status to update.
244
+ </p>
245
+ </div>
246
+ {tasks.length > 0 && (
247
+ <Button
248
+ variant="secondary"
249
+ size="sm"
250
+ onClick={() => downloadCSV(
251
+ tasks.map((t) => ({ title: t.title, status: t.status, priority: t.priority, description: t.description || '', createdAt: t.createdAt })),
252
+ 'tasks.csv'
253
+ )}
254
+ icon={<Download className="h-4 w-4" />}
255
+ >
256
+ Export
257
+ </Button>
258
+ )}
259
+ </div>
260
+
261
+ {error && (
262
+ <div className="flex items-center justify-between rounded-lg border border-red-200 bg-red-50 px-4 py-3">
263
+ <p className="text-sm text-red-700">Failed to load tasks. Please check your connection and try again.</p>
264
+ <button onClick={refresh} className="text-sm font-medium text-red-700 hover:text-red-800 underline">Retry</button>
265
+ </div>
266
+ )}
267
+
268
+ <div className="flex gap-2">
269
+ {FILTER_OPTIONS.map((option) => (
270
+ <Button
271
+ key={option.value}
272
+ variant={statusFilter === option.value ? 'primary' : 'secondary'}
273
+ size="sm"
274
+ onClick={() => setStatusFilter(option.value)}
275
+ >
276
+ {option.label}
277
+ </Button>
278
+ ))}
279
+ </div>
280
+
281
+ {!loading && tasks.length === 0 ? (
282
+ <EmptyState
283
+ title="No tasks yet"
284
+ description="Tasks will appear here when you add them to projects."
285
+ icon={<ListTodo className="h-12 w-12 text-gray-400" />}
286
+ />
287
+ ) : (
288
+ <div className="rounded-xl border border-gray-200 bg-white shadow-sm">
289
+ <DataTable
290
+ columns={columns}
291
+ data={filteredTasks}
292
+ loading={loading}
293
+ pagination
294
+ pageSize={15}
295
+ hoverable
296
+ />
297
+ </div>
298
+ )}
299
+ </div>
300
+ );
301
+ }
@@ -0,0 +1,295 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { DataTable, EmptyState } from '@varity-labs/ui-kit';
5
+ import { useTeam } from '@/lib/hooks';
6
+ import { Plus, Users, Pencil, Trash2 } from 'lucide-react';
7
+ import {
8
+ Button,
9
+ Input,
10
+ Select,
11
+ Dialog,
12
+ ConfirmDialog,
13
+ useToast,
14
+ RoleBadge
15
+ } from '@varity-labs/ui-kit';
16
+ import { formatDate, isValidEmail } from '@/lib/utils';
17
+ import { ROLE_OPTIONS } from '@/lib/constants';
18
+ import type { TeamMember } from '@/types';
19
+
20
+ const EMPTY_FORM = { name: '', email: '', role: 'member' as TeamMember['role'] };
21
+
22
+ export default function TeamPage() {
23
+ const toast = useToast();
24
+ const { data: team, loading, error, create, update, remove, refresh } = useTeam();
25
+
26
+ // Invite dialog
27
+ const [dialogOpen, setDialogOpen] = useState(false);
28
+ const [formData, setFormData] = useState(EMPTY_FORM);
29
+ const [errors, setErrors] = useState<{ name?: string; email?: string }>({});
30
+ const [submitting, setSubmitting] = useState(false);
31
+
32
+ // Edit role dialog
33
+ const [editingMember, setEditingMember] = useState<TeamMember | null>(null);
34
+ const [editRole, setEditRole] = useState<TeamMember['role']>('member');
35
+ const [editSubmitting, setEditSubmitting] = useState(false);
36
+
37
+ // Remove member confirmation
38
+ const [removingMemberId, setRemovingMemberId] = useState<string | null>(null);
39
+ const [removeSubmitting, setRemoveSubmitting] = useState(false);
40
+
41
+ const removingMember = removingMemberId
42
+ ? team.find((m) => m.id === removingMemberId)
43
+ : null;
44
+
45
+ function resetAndClose() {
46
+ setFormData(EMPTY_FORM);
47
+ setErrors({});
48
+ setDialogOpen(false);
49
+ }
50
+
51
+ function startEditMember(member: TeamMember) {
52
+ setEditingMember(member);
53
+ setEditRole(member.role);
54
+ }
55
+
56
+ function resetEditDialog() {
57
+ setEditingMember(null);
58
+ setEditRole('member');
59
+ }
60
+
61
+ function validateForm(): boolean {
62
+ const newErrors: { name?: string; email?: string } = {};
63
+
64
+ if (!formData.name.trim()) {
65
+ newErrors.name = 'Name is required';
66
+ }
67
+ if (!formData.email.trim()) {
68
+ newErrors.email = 'Email is required';
69
+ } else if (!isValidEmail(formData.email.trim())) {
70
+ newErrors.email = 'Please enter a valid email address';
71
+ }
72
+
73
+ setErrors(newErrors);
74
+ return Object.keys(newErrors).length === 0;
75
+ }
76
+
77
+ async function handleInvite() {
78
+ if (!validateForm()) return;
79
+
80
+ setSubmitting(true);
81
+ try {
82
+ await create({
83
+ name: formData.name.trim(),
84
+ email: formData.email.trim(),
85
+ role: formData.role,
86
+ });
87
+ toast.success(`${formData.name.trim()} added to team`);
88
+ resetAndClose();
89
+ } catch {
90
+ toast.error('Failed to send invitation. Please try again.');
91
+ } finally {
92
+ setSubmitting(false);
93
+ }
94
+ }
95
+
96
+ async function handleEditRole() {
97
+ if (!editingMember?.id) return;
98
+
99
+ setEditSubmitting(true);
100
+ try {
101
+ await update(editingMember.id, { role: editRole });
102
+ toast.success(`${editingMember.name}'s role updated to ${editRole}`);
103
+ resetEditDialog();
104
+ } catch {
105
+ toast.error('Failed to update role. Please try again.');
106
+ } finally {
107
+ setEditSubmitting(false);
108
+ }
109
+ }
110
+
111
+ async function handleRemoveMember() {
112
+ if (!removingMemberId) return;
113
+
114
+ setRemoveSubmitting(true);
115
+ try {
116
+ await remove(removingMemberId);
117
+ toast.success('Team member removed');
118
+ setRemovingMemberId(null);
119
+ } catch {
120
+ toast.error('Failed to remove member. Please try again.');
121
+ } finally {
122
+ setRemoveSubmitting(false);
123
+ }
124
+ }
125
+
126
+ const columns = [
127
+ { key: 'name', header: 'Name', sortable: true },
128
+ { key: 'email', header: 'Email', sortable: true },
129
+ {
130
+ key: 'role',
131
+ header: 'Role',
132
+ render: (value: string) => <RoleBadge role={value} />,
133
+ },
134
+ {
135
+ key: 'joinedAt',
136
+ header: 'Joined',
137
+ sortable: true,
138
+ render: (value: string) => formatDate(value),
139
+ },
140
+ {
141
+ key: 'id',
142
+ header: '',
143
+ render: (_: string, row: TeamMember) => (
144
+ <div className="flex gap-1">
145
+ <button
146
+ onClick={() => startEditMember(row)}
147
+ className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
148
+ title="Edit role"
149
+ >
150
+ <Pencil className="h-4 w-4" />
151
+ </button>
152
+ <button
153
+ onClick={() => setRemovingMemberId(row.id!)}
154
+ className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600 transition-colors"
155
+ title="Remove member"
156
+ >
157
+ <Trash2 className="h-4 w-4" />
158
+ </button>
159
+ </div>
160
+ ),
161
+ },
162
+ ];
163
+
164
+ return (
165
+ <div className="space-y-6">
166
+ <Dialog
167
+ open={dialogOpen}
168
+ onClose={resetAndClose}
169
+ title="Invite Team Member"
170
+ description="Send an invitation to join your team."
171
+ >
172
+ <div className="space-y-4">
173
+ <Input
174
+ label="Name"
175
+ required
176
+ value={formData.name}
177
+ onChange={(e) => {
178
+ setFormData({ ...formData, name: e.target.value });
179
+ if (errors.name) setErrors({ ...errors, name: undefined });
180
+ }}
181
+ error={errors.name}
182
+ placeholder="Full name"
183
+ />
184
+ <Input
185
+ label="Email"
186
+ required
187
+ type="email"
188
+ value={formData.email}
189
+ onChange={(e) => {
190
+ setFormData({ ...formData, email: e.target.value });
191
+ if (errors.email) setErrors({ ...errors, email: undefined });
192
+ }}
193
+ error={errors.email}
194
+ placeholder="email@example.com"
195
+ />
196
+ <Select
197
+ label="Role"
198
+ value={formData.role}
199
+ onChange={(e) =>
200
+ setFormData({ ...formData, role: e.target.value as TeamMember['role'] })
201
+ }
202
+ options={[...ROLE_OPTIONS]}
203
+ />
204
+ <div className="flex gap-2 pt-2">
205
+ <Button onClick={handleInvite} loading={submitting}>
206
+ Send Invite
207
+ </Button>
208
+ <Button variant="secondary" onClick={resetAndClose} disabled={submitting}>
209
+ Cancel
210
+ </Button>
211
+ </div>
212
+ </div>
213
+ </Dialog>
214
+
215
+ <Dialog
216
+ open={!!editingMember}
217
+ onClose={resetEditDialog}
218
+ title="Edit Role"
219
+ description={editingMember ? `Change ${editingMember.name}'s role.` : ''}
220
+ >
221
+ <div className="space-y-4">
222
+ <div>
223
+ <label className="mb-1 block text-sm font-medium text-gray-700">Member</label>
224
+ <p className="text-sm text-gray-900">{editingMember?.name} ({editingMember?.email})</p>
225
+ </div>
226
+ <Select
227
+ label="Role"
228
+ value={editRole}
229
+ onChange={(e) => setEditRole(e.target.value as TeamMember['role'])}
230
+ options={[...ROLE_OPTIONS]}
231
+ />
232
+ <div className="flex gap-2 pt-2">
233
+ <Button onClick={handleEditRole} loading={editSubmitting}>Save Changes</Button>
234
+ <Button variant="secondary" onClick={resetEditDialog} disabled={editSubmitting}>Cancel</Button>
235
+ </div>
236
+ </div>
237
+ </Dialog>
238
+
239
+ <ConfirmDialog
240
+ open={!!removingMemberId}
241
+ onClose={() => setRemovingMemberId(null)}
242
+ onConfirm={handleRemoveMember}
243
+ title="Remove Team Member"
244
+ description={`Are you sure you want to remove ${removingMember?.name || 'this member'} from the team? They will lose access to all projects.`}
245
+ confirmLabel="Remove Member"
246
+ loading={removeSubmitting}
247
+ />
248
+
249
+ {error && (
250
+ <div className="flex items-center justify-between rounded-lg border border-red-200 bg-red-50 px-4 py-3">
251
+ <p className="text-sm text-red-700">Failed to load team data. Please check your connection and try again.</p>
252
+ <button onClick={refresh} className="text-sm font-medium text-red-700 hover:text-red-800 underline">Retry</button>
253
+ </div>
254
+ )}
255
+
256
+ <div className="flex items-center justify-between">
257
+ <div>
258
+ <h1 className="text-2xl font-bold text-gray-900">Team</h1>
259
+ <p className="mt-1 text-sm text-gray-600">
260
+ Manage your team members and roles.
261
+ </p>
262
+ </div>
263
+ <Button
264
+ onClick={() => setDialogOpen(true)}
265
+ icon={<Plus className="h-4 w-4" />}
266
+ >
267
+ Invite Member
268
+ </Button>
269
+ </div>
270
+
271
+ {!loading && team.length === 0 && !dialogOpen ? (
272
+ <EmptyState
273
+ title="No team members yet"
274
+ description="Invite your first team member to start collaborating."
275
+ icon={<Users className="h-12 w-12 text-gray-400" />}
276
+ action={{
277
+ label: 'Invite Member',
278
+ onClick: () => setDialogOpen(true),
279
+ }}
280
+ />
281
+ ) : (
282
+ <div className="rounded-xl border border-gray-200 bg-white shadow-sm">
283
+ <DataTable
284
+ columns={columns}
285
+ data={team}
286
+ loading={loading}
287
+ pagination
288
+ pageSize={10}
289
+ hoverable
290
+ />
291
+ </div>
292
+ )}
293
+ </div>
294
+ );
295
+ }