@mars-stack/cli 3.0.1 → 4.0.0
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.
- package/dist/index.js +350 -6
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/template/.cursor/skills/mars-upgrade-scaffold/SKILL.md +70 -0
- package/template/AGENTS.md +5 -1
- package/template/scripts/ensure-db.mjs +15 -9
- package/template/src/app/(auth)/verify/page.tsx +9 -8
- package/template/src/app/(protected)/dashboard/page.tsx +228 -11
- package/template/src/app/(protected)/files/page.tsx +30 -0
- package/template/src/app/(protected)/layout.tsx +14 -1
- package/template/src/app/(protected)/settings/billing/page.tsx +262 -0
- package/template/src/app/api/auth/signup/route.test.ts +118 -0
- package/template/src/app/api/auth/signup/route.ts +29 -5
- package/template/src/app/api/protected/billing/checkout/route.ts +2 -2
- package/template/src/app/api/protected/billing/portal/route.ts +1 -1
- package/template/src/app/api/protected/billing/subscription/route.ts +13 -0
- package/template/src/app/api/protected/files/list/route.ts +22 -0
- package/template/src/app/pricing/page.tsx +276 -0
- package/template/src/config/routes.ts +3 -0
- package/template/src/features/uploads/components/FileList.tsx +202 -0
- package/template/src/features/uploads/components/FileUploader.tsx +225 -0
- package/template/src/features/uploads/components/index.ts +2 -0
- package/template/src/features/uploads/index.ts +2 -0
- package/template/src/proxy.ts +1 -1
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { Card, Badge, Button, LinkButton } from '@mars-stack/ui';
|
|
5
|
+
import { appConfig } from '@/config/app.config';
|
|
6
|
+
import { routes } from '@/config/routes';
|
|
7
|
+
import { useAuth } from '@/features/auth/context/AuthContext';
|
|
8
|
+
|
|
9
|
+
interface PlanTier {
|
|
10
|
+
name: string;
|
|
11
|
+
description: string;
|
|
12
|
+
monthlyPrice: number;
|
|
13
|
+
annualPrice: number;
|
|
14
|
+
priceId?: string;
|
|
15
|
+
annualPriceId?: string;
|
|
16
|
+
features: string[];
|
|
17
|
+
highlighted?: boolean;
|
|
18
|
+
cta: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const PLACEHOLDER_PLANS: PlanTier[] = [
|
|
22
|
+
{
|
|
23
|
+
name: 'Starter',
|
|
24
|
+
description: 'For individuals getting started.',
|
|
25
|
+
monthlyPrice: 0,
|
|
26
|
+
annualPrice: 0,
|
|
27
|
+
features: [
|
|
28
|
+
'Up to 1,000 requests/month',
|
|
29
|
+
'Basic analytics',
|
|
30
|
+
'Community support',
|
|
31
|
+
'Single user',
|
|
32
|
+
],
|
|
33
|
+
cta: 'Get started free',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'Pro',
|
|
37
|
+
description: 'For growing teams and businesses.',
|
|
38
|
+
monthlyPrice: 29,
|
|
39
|
+
annualPrice: 290,
|
|
40
|
+
features: [
|
|
41
|
+
'Unlimited requests',
|
|
42
|
+
'Advanced analytics',
|
|
43
|
+
'Priority support',
|
|
44
|
+
'Up to 10 team members',
|
|
45
|
+
'Custom integrations',
|
|
46
|
+
'API access',
|
|
47
|
+
],
|
|
48
|
+
highlighted: true,
|
|
49
|
+
cta: 'Start free trial',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'Enterprise',
|
|
53
|
+
description: 'For large-scale operations.',
|
|
54
|
+
monthlyPrice: 99,
|
|
55
|
+
annualPrice: 990,
|
|
56
|
+
features: [
|
|
57
|
+
'Everything in Pro',
|
|
58
|
+
'Unlimited team members',
|
|
59
|
+
'SSO & SAML',
|
|
60
|
+
'Dedicated support',
|
|
61
|
+
'Custom SLA',
|
|
62
|
+
'Audit logs',
|
|
63
|
+
'On-premise option',
|
|
64
|
+
],
|
|
65
|
+
cta: 'Contact sales',
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
function formatPrice(amount: number): string {
|
|
70
|
+
if (amount === 0) return 'Free';
|
|
71
|
+
return `$${amount}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function CheckIcon() {
|
|
75
|
+
return (
|
|
76
|
+
<svg className="h-4 w-4 shrink-0 text-text-success" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
|
77
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
|
78
|
+
</svg>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function PlanCard({
|
|
83
|
+
plan,
|
|
84
|
+
annual,
|
|
85
|
+
onSubscribe,
|
|
86
|
+
isAuthenticated,
|
|
87
|
+
isLoading,
|
|
88
|
+
}: {
|
|
89
|
+
plan: PlanTier;
|
|
90
|
+
annual: boolean;
|
|
91
|
+
onSubscribe: (priceId: string) => void;
|
|
92
|
+
isAuthenticated: boolean;
|
|
93
|
+
isLoading: boolean;
|
|
94
|
+
}) {
|
|
95
|
+
const price = annual ? plan.annualPrice : plan.monthlyPrice;
|
|
96
|
+
const period = annual ? '/year' : '/month';
|
|
97
|
+
const priceId = annual ? plan.annualPriceId : plan.priceId;
|
|
98
|
+
const hasStripe = (appConfig.services.payments.provider as string) === 'stripe';
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<Card
|
|
102
|
+
className={
|
|
103
|
+
plan.highlighted
|
|
104
|
+
? 'relative ring-2 ring-brand-primary'
|
|
105
|
+
: 'relative'
|
|
106
|
+
}
|
|
107
|
+
>
|
|
108
|
+
{plan.highlighted && (
|
|
109
|
+
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
|
110
|
+
<Badge variant="info">Most popular</Badge>
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
<div className="flex flex-col h-full">
|
|
115
|
+
<div>
|
|
116
|
+
<h3 className="text-lg font-semibold text-text-primary">{plan.name}</h3>
|
|
117
|
+
<p className="mt-1 text-sm text-text-secondary">{plan.description}</p>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<div className="mt-4">
|
|
121
|
+
<span className="text-4xl font-bold text-text-primary">{formatPrice(price)}</span>
|
|
122
|
+
{price > 0 && (
|
|
123
|
+
<span className="ml-1 text-sm text-text-muted">{period}</span>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<ul className="mt-6 space-y-3 flex-1">
|
|
128
|
+
{plan.features.map((feature) => (
|
|
129
|
+
<li key={feature} className="flex items-start gap-2">
|
|
130
|
+
<CheckIcon />
|
|
131
|
+
<span className="text-sm text-text-secondary">{feature}</span>
|
|
132
|
+
</li>
|
|
133
|
+
))}
|
|
134
|
+
</ul>
|
|
135
|
+
|
|
136
|
+
<div className="mt-6">
|
|
137
|
+
{price === 0 ? (
|
|
138
|
+
isAuthenticated ? (
|
|
139
|
+
<LinkButton href={routes.dashboard} variant="secondary" size="md" className="w-full justify-center">
|
|
140
|
+
Go to Dashboard
|
|
141
|
+
</LinkButton>
|
|
142
|
+
) : (
|
|
143
|
+
<LinkButton href={routes.signUp} variant="secondary" size="md" className="w-full justify-center">
|
|
144
|
+
{plan.cta}
|
|
145
|
+
</LinkButton>
|
|
146
|
+
)
|
|
147
|
+
) : hasStripe && priceId ? (
|
|
148
|
+
<Button
|
|
149
|
+
variant={plan.highlighted ? 'primary' : 'secondary'}
|
|
150
|
+
size="md"
|
|
151
|
+
className="w-full justify-center"
|
|
152
|
+
onClick={() => onSubscribe(priceId)}
|
|
153
|
+
disabled={isLoading}
|
|
154
|
+
>
|
|
155
|
+
{isLoading ? 'Redirecting...' : plan.cta}
|
|
156
|
+
</Button>
|
|
157
|
+
) : isAuthenticated ? (
|
|
158
|
+
<Button variant={plan.highlighted ? 'primary' : 'secondary'} size="md" className="w-full justify-center" disabled>
|
|
159
|
+
{plan.cta}
|
|
160
|
+
</Button>
|
|
161
|
+
) : (
|
|
162
|
+
<LinkButton
|
|
163
|
+
href={`${routes.signIn}?callbackUrl=${encodeURIComponent(routes.pricing)}`}
|
|
164
|
+
variant={plan.highlighted ? 'primary' : 'secondary'}
|
|
165
|
+
size="md"
|
|
166
|
+
className="w-full justify-center"
|
|
167
|
+
>
|
|
168
|
+
Sign in to subscribe
|
|
169
|
+
</LinkButton>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
</Card>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export default function PricingPage() {
|
|
178
|
+
const [annual, setAnnual] = useState(false);
|
|
179
|
+
const [loadingPriceId, setLoadingPriceId] = useState<string | null>(null);
|
|
180
|
+
const { user } = useAuth();
|
|
181
|
+
const isAuthenticated = !!user;
|
|
182
|
+
const hasStripe = (appConfig.services.payments.provider as string) === 'stripe';
|
|
183
|
+
|
|
184
|
+
const handleSubscribe = async (priceId: string) => {
|
|
185
|
+
if (!isAuthenticated) {
|
|
186
|
+
window.location.href = `${routes.signIn}?callbackUrl=${encodeURIComponent(routes.pricing)}`;
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
setLoadingPriceId(priceId);
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const response = await fetch('/api/protected/billing/checkout', {
|
|
194
|
+
method: 'POST',
|
|
195
|
+
headers: { 'Content-Type': 'application/json' },
|
|
196
|
+
credentials: 'include',
|
|
197
|
+
body: JSON.stringify({ priceId }),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
if (!response.ok) {
|
|
201
|
+
const data: { error?: string } = await response.json();
|
|
202
|
+
throw new Error(data.error ?? 'Failed to create checkout session');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const { url } = (await response.json()) as { url: string };
|
|
206
|
+
window.location.href = url;
|
|
207
|
+
} catch (error) {
|
|
208
|
+
console.error('Checkout error:', error);
|
|
209
|
+
setLoadingPriceId(null);
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<div className="min-h-screen bg-surface-background">
|
|
215
|
+
<div className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:px-8">
|
|
216
|
+
<div className="text-center">
|
|
217
|
+
<h1 className="text-4xl font-bold tracking-tight text-text-primary sm:text-5xl">
|
|
218
|
+
Simple, transparent pricing
|
|
219
|
+
</h1>
|
|
220
|
+
<p className="mx-auto mt-4 max-w-2xl text-lg text-text-secondary">
|
|
221
|
+
Choose the plan that fits your needs. All plans include a 14-day free trial.
|
|
222
|
+
</p>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<div className="mt-8 flex items-center justify-center gap-3">
|
|
226
|
+
<span className={`text-sm font-medium ${!annual ? 'text-text-primary' : 'text-text-muted'}`}>
|
|
227
|
+
Monthly
|
|
228
|
+
</span>
|
|
229
|
+
<button
|
|
230
|
+
type="button"
|
|
231
|
+
role="switch"
|
|
232
|
+
aria-checked={annual}
|
|
233
|
+
onClick={() => setAnnual((prev) => !prev)}
|
|
234
|
+
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ${
|
|
235
|
+
annual ? 'bg-brand-primary' : 'bg-surface-hover'
|
|
236
|
+
}`}
|
|
237
|
+
>
|
|
238
|
+
<span
|
|
239
|
+
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-surface-card shadow-sm transition-transform duration-200 ${
|
|
240
|
+
annual ? 'translate-x-5' : 'translate-x-0'
|
|
241
|
+
}`}
|
|
242
|
+
/>
|
|
243
|
+
</button>
|
|
244
|
+
<span className={`text-sm font-medium ${annual ? 'text-text-primary' : 'text-text-muted'}`}>
|
|
245
|
+
Annual
|
|
246
|
+
</span>
|
|
247
|
+
{annual && (
|
|
248
|
+
<Badge variant="success">Save ~17%</Badge>
|
|
249
|
+
)}
|
|
250
|
+
</div>
|
|
251
|
+
|
|
252
|
+
{!hasStripe && (
|
|
253
|
+
<div className="mx-auto mt-6 max-w-md rounded-lg border border-border-default bg-surface-card p-4 text-center">
|
|
254
|
+
<p className="text-sm text-text-muted">
|
|
255
|
+
Stripe is not configured. Showing placeholder plans. Configure your payments
|
|
256
|
+
provider to enable subscriptions.
|
|
257
|
+
</p>
|
|
258
|
+
</div>
|
|
259
|
+
)}
|
|
260
|
+
|
|
261
|
+
<div className="mt-12 grid gap-8 lg:grid-cols-3">
|
|
262
|
+
{PLACEHOLDER_PLANS.map((plan) => (
|
|
263
|
+
<PlanCard
|
|
264
|
+
key={plan.name}
|
|
265
|
+
plan={plan}
|
|
266
|
+
annual={annual}
|
|
267
|
+
onSubscribe={handleSubscribe}
|
|
268
|
+
isAuthenticated={isAuthenticated}
|
|
269
|
+
isLoading={loadingPriceId !== null}
|
|
270
|
+
/>
|
|
271
|
+
))}
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
4
|
+
import { Button, Badge, Spinner } from '@mars-stack/ui';
|
|
5
|
+
import type { FileRecord } from '../types';
|
|
6
|
+
|
|
7
|
+
function formatFileSize(bytes: number): string {
|
|
8
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
9
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
10
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function formatDate(date: Date | string): string {
|
|
14
|
+
return new Date(date).toLocaleDateString(undefined, {
|
|
15
|
+
year: 'numeric',
|
|
16
|
+
month: 'short',
|
|
17
|
+
day: 'numeric',
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function FileTypeIcon({ contentType }: { contentType: string }) {
|
|
22
|
+
const iconClass = 'h-5 w-5 text-text-muted';
|
|
23
|
+
|
|
24
|
+
if (contentType.startsWith('image/')) {
|
|
25
|
+
return (
|
|
26
|
+
<svg className={iconClass} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
|
27
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0 0 22.5 18.75V5.25A2.25 2.25 0 0 0 20.25 3H3.75A2.25 2.25 0 0 0 1.5 5.25v13.5A2.25 2.25 0 0 0 3.75 21Z" />
|
|
28
|
+
</svg>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (contentType.startsWith('video/')) {
|
|
33
|
+
return (
|
|
34
|
+
<svg className={iconClass} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
|
35
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" />
|
|
36
|
+
</svg>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<svg className={iconClass} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
|
42
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
|
43
|
+
</svg>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface FileListProps {
|
|
48
|
+
refreshKey?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function FileList({ refreshKey }: FileListProps) {
|
|
52
|
+
const [files, setFiles] = useState<FileRecord[]>([]);
|
|
53
|
+
const [loading, setLoading] = useState(true);
|
|
54
|
+
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());
|
|
55
|
+
|
|
56
|
+
const fetchFiles = useCallback(async () => {
|
|
57
|
+
try {
|
|
58
|
+
const response = await fetch('/api/protected/files/list', {
|
|
59
|
+
credentials: 'include',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (response.ok) {
|
|
63
|
+
const data = (await response.json()) as { files: FileRecord[] };
|
|
64
|
+
setFiles(data.files);
|
|
65
|
+
}
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error('Failed to fetch files:', error);
|
|
68
|
+
} finally {
|
|
69
|
+
setLoading(false);
|
|
70
|
+
}
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
fetchFiles();
|
|
75
|
+
}, [fetchFiles, refreshKey]);
|
|
76
|
+
|
|
77
|
+
const handleDelete = useCallback(async (fileId: string) => {
|
|
78
|
+
setDeletingIds((prev) => new Set(prev).add(fileId));
|
|
79
|
+
setFiles((prev) => prev.filter((f) => f.id !== fileId));
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const response = await fetch(`/api/protected/files/${fileId}`, {
|
|
83
|
+
method: 'DELETE',
|
|
84
|
+
credentials: 'include',
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
const restored = files.find((f) => f.id === fileId);
|
|
89
|
+
if (restored) {
|
|
90
|
+
setFiles((prev) => [...prev, restored]);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
const restored = files.find((f) => f.id === fileId);
|
|
95
|
+
if (restored) {
|
|
96
|
+
setFiles((prev) => [...prev, restored]);
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
setDeletingIds((prev) => {
|
|
100
|
+
const next = new Set(prev);
|
|
101
|
+
next.delete(fileId);
|
|
102
|
+
return next;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}, [files]);
|
|
106
|
+
|
|
107
|
+
if (loading) {
|
|
108
|
+
return (
|
|
109
|
+
<div className="flex items-center justify-center py-12">
|
|
110
|
+
<Spinner size="lg" />
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (files.length === 0) {
|
|
116
|
+
return (
|
|
117
|
+
<div className="rounded-xl border border-border-default bg-surface-card p-8 text-center">
|
|
118
|
+
<svg
|
|
119
|
+
className="mx-auto h-10 w-10 text-text-muted"
|
|
120
|
+
fill="none"
|
|
121
|
+
viewBox="0 0 24 24"
|
|
122
|
+
strokeWidth={1}
|
|
123
|
+
stroke="currentColor"
|
|
124
|
+
>
|
|
125
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
|
|
126
|
+
</svg>
|
|
127
|
+
<p className="mt-3 text-sm text-text-secondary">No files uploaded yet.</p>
|
|
128
|
+
<p className="mt-1 text-xs text-text-muted">Drop a file above to get started.</p>
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div className="overflow-hidden rounded-xl border border-border-default">
|
|
135
|
+
<table className="w-full">
|
|
136
|
+
<thead>
|
|
137
|
+
<tr className="border-b border-border-default bg-surface-card">
|
|
138
|
+
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-text-muted">
|
|
139
|
+
File
|
|
140
|
+
</th>
|
|
141
|
+
<th className="hidden px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-text-muted sm:table-cell">
|
|
142
|
+
Size
|
|
143
|
+
</th>
|
|
144
|
+
<th className="hidden px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-text-muted md:table-cell">
|
|
145
|
+
Date
|
|
146
|
+
</th>
|
|
147
|
+
<th className="hidden px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-text-muted sm:table-cell">
|
|
148
|
+
Access
|
|
149
|
+
</th>
|
|
150
|
+
<th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-text-muted">
|
|
151
|
+
Actions
|
|
152
|
+
</th>
|
|
153
|
+
</tr>
|
|
154
|
+
</thead>
|
|
155
|
+
<tbody className="divide-y divide-border-default bg-surface-background">
|
|
156
|
+
{files.map((file) => (
|
|
157
|
+
<tr key={file.id} className="hover:bg-surface-hover transition-colors">
|
|
158
|
+
<td className="px-4 py-3">
|
|
159
|
+
<div className="flex items-center gap-2.5">
|
|
160
|
+
<FileTypeIcon contentType={file.contentType} />
|
|
161
|
+
<span className="truncate text-sm font-medium text-text-primary max-w-[200px]">
|
|
162
|
+
{file.filename}
|
|
163
|
+
</span>
|
|
164
|
+
</div>
|
|
165
|
+
</td>
|
|
166
|
+
<td className="hidden px-4 py-3 text-sm text-text-secondary sm:table-cell">
|
|
167
|
+
{formatFileSize(file.size)}
|
|
168
|
+
</td>
|
|
169
|
+
<td className="hidden px-4 py-3 text-sm text-text-secondary md:table-cell">
|
|
170
|
+
{formatDate(file.createdAt)}
|
|
171
|
+
</td>
|
|
172
|
+
<td className="hidden px-4 py-3 sm:table-cell">
|
|
173
|
+
<Badge variant={file.access === 'public' ? 'info' : 'neutral'}>
|
|
174
|
+
{file.access}
|
|
175
|
+
</Badge>
|
|
176
|
+
</td>
|
|
177
|
+
<td className="px-4 py-3">
|
|
178
|
+
<div className="flex items-center justify-end gap-2">
|
|
179
|
+
<a
|
|
180
|
+
href={`/api/protected/files/${file.id}`}
|
|
181
|
+
download
|
|
182
|
+
className="text-sm font-medium text-text-link hover:text-text-link-hover"
|
|
183
|
+
>
|
|
184
|
+
Download
|
|
185
|
+
</a>
|
|
186
|
+
<Button
|
|
187
|
+
variant="danger"
|
|
188
|
+
size="sm"
|
|
189
|
+
onClick={() => handleDelete(file.id)}
|
|
190
|
+
disabled={deletingIds.has(file.id)}
|
|
191
|
+
>
|
|
192
|
+
Delete
|
|
193
|
+
</Button>
|
|
194
|
+
</div>
|
|
195
|
+
</td>
|
|
196
|
+
</tr>
|
|
197
|
+
))}
|
|
198
|
+
</tbody>
|
|
199
|
+
</table>
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|