Initial commit
This commit is contained in:
145
components/dashboard/payment-methods-form.tsx
Normal file
145
components/dashboard/payment-methods-form.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useMemo, useState, useTransition } from "react";
|
||||
import { createPaymentMethodAction } from "@/actions/payment-methods";
|
||||
import { PAYMENT_METHOD_LABELS, PAYMENT_METHOD_TYPES, USDT_NETWORKS } from "@/lib/constants";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
export function PaymentMethodsForm() {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const [type, setType] = useState<(typeof PAYMENT_METHOD_TYPES)[number]>("PAYPAL");
|
||||
const [label, setLabel] = useState("");
|
||||
const [value, setValue] = useState("");
|
||||
const [network, setNetwork] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
|
||||
const isUsdt = useMemo(() => type === "USDT", [type]);
|
||||
|
||||
function resetForm() {
|
||||
setLabel("");
|
||||
setValue("");
|
||||
setNetwork("");
|
||||
setDescription("");
|
||||
setIsVisible(true);
|
||||
}
|
||||
|
||||
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setMessage(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.set("type", type);
|
||||
formData.set("label", label);
|
||||
formData.set("value", value);
|
||||
formData.set("network", network);
|
||||
formData.set("description", description);
|
||||
if (isVisible) {
|
||||
formData.set("isVisible", "on");
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await createPaymentMethodAction(formData);
|
||||
setMessage(result.message);
|
||||
setIsError(!result.success);
|
||||
|
||||
if (result.success) {
|
||||
resetForm();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="terminal-card space-y-5">
|
||||
<h2 className="terminal-heading">Add Payment Method</h2>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="form-label">Type</span>
|
||||
<Select
|
||||
value={type}
|
||||
onChange={(event) => {
|
||||
const nextType = event.target.value as (typeof PAYMENT_METHOD_TYPES)[number];
|
||||
setType(nextType);
|
||||
if (nextType !== "USDT") {
|
||||
setNetwork("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{PAYMENT_METHOD_TYPES.map((methodType) => (
|
||||
<option key={methodType} value={methodType}>
|
||||
{PAYMENT_METHOD_LABELS[methodType]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="form-label">Label</span>
|
||||
<Input required value={label} onChange={(event) => setLabel(event.target.value)} placeholder="Primary wallet" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="form-label">Value</span>
|
||||
<Input
|
||||
required
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
placeholder="Address, username, IBAN, or link"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="form-label">Network {isUsdt ? "(required for USDT)" : "(optional)"}</span>
|
||||
{isUsdt ? (
|
||||
<Select value={network} onChange={(event) => setNetwork(event.target.value)}>
|
||||
<option value="">Select a network</option>
|
||||
{USDT_NETWORKS.map((networkOption) => (
|
||||
<option key={networkOption} value={networkOption}>
|
||||
{networkOption}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
<Input value={network} onChange={(event) => setNetwork(event.target.value)} placeholder="ERC20, TRC20, ..." />
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="form-label">Description (optional)</span>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
maxLength={180}
|
||||
rows={3}
|
||||
placeholder="Use this for donation only"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 text-sm text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isVisible}
|
||||
onChange={(event) => setIsVisible(event.target.checked)}
|
||||
className="h-4 w-4 rounded border-border bg-panel text-accent focus:ring-accent"
|
||||
/>
|
||||
Visible on public profile
|
||||
</label>
|
||||
|
||||
{message ? <p className={isError ? "text-sm text-red-300" : "text-sm text-accent"}>{message}</p> : null}
|
||||
|
||||
<div className="pt-2">
|
||||
<Button type="submit" variant="primary" className="dashboard-action" disabled={isPending}>
|
||||
{isPending ? "Adding..." : "Add method"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
273
components/dashboard/payment-methods-list.tsx
Normal file
273
components/dashboard/payment-methods-list.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { PaymentMethodType } from "@prisma/client";
|
||||
import {
|
||||
deletePaymentMethodAction,
|
||||
movePaymentMethodAction,
|
||||
togglePaymentMethodVisibilityAction,
|
||||
updatePaymentMethodAction
|
||||
} from "@/actions/payment-methods";
|
||||
import { PAYMENT_METHOD_LABELS, PAYMENT_METHOD_TYPES, USDT_NETWORKS } from "@/lib/constants";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
type PaymentMethodsListProps = {
|
||||
methods: MethodItem[];
|
||||
};
|
||||
|
||||
type MethodItem = {
|
||||
id: string;
|
||||
type: PaymentMethodType;
|
||||
label: string;
|
||||
value: string;
|
||||
network: string | null;
|
||||
description: string | null;
|
||||
sortOrder: number;
|
||||
isVisible: boolean;
|
||||
};
|
||||
|
||||
type EditorState = {
|
||||
id: string;
|
||||
type: PaymentMethodType;
|
||||
label: string;
|
||||
value: string;
|
||||
network: string;
|
||||
description: string;
|
||||
isVisible: boolean;
|
||||
};
|
||||
|
||||
function toEditor(method: MethodItem): EditorState {
|
||||
return {
|
||||
id: method.id,
|
||||
type: method.type,
|
||||
label: method.label,
|
||||
value: method.value,
|
||||
network: method.network ?? "",
|
||||
description: method.description ?? "",
|
||||
isVisible: method.isVisible
|
||||
};
|
||||
}
|
||||
|
||||
export function PaymentMethodsList({ methods }: PaymentMethodsListProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [editor, setEditor] = useState<EditorState | null>(null);
|
||||
|
||||
const indexed = useMemo(() => methods.map((method, index) => ({ method, index })), [methods]);
|
||||
|
||||
function runAction(executor: () => Promise<{ success: boolean; message: string }>) {
|
||||
setMessage(null);
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await executor();
|
||||
setMessage(result.message);
|
||||
setIsError(!result.success);
|
||||
|
||||
if (result.success) {
|
||||
setEditor(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleMove(id: string, direction: "up" | "down") {
|
||||
runAction(async () => {
|
||||
const formData = new FormData();
|
||||
formData.set("id", id);
|
||||
formData.set("direction", direction);
|
||||
return movePaymentMethodAction(formData);
|
||||
});
|
||||
}
|
||||
|
||||
function handleToggle(id: string) {
|
||||
runAction(async () => {
|
||||
const formData = new FormData();
|
||||
formData.set("id", id);
|
||||
return togglePaymentMethodVisibilityAction(formData);
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete(id: string) {
|
||||
runAction(async () => {
|
||||
const formData = new FormData();
|
||||
formData.set("id", id);
|
||||
return deletePaymentMethodAction(formData);
|
||||
});
|
||||
}
|
||||
|
||||
function handleUpdate() {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
runAction(async () => {
|
||||
const formData = new FormData();
|
||||
formData.set("id", editor.id);
|
||||
formData.set("type", editor.type);
|
||||
formData.set("label", editor.label);
|
||||
formData.set("value", editor.value);
|
||||
formData.set("network", editor.network);
|
||||
formData.set("description", editor.description);
|
||||
if (editor.isVisible) {
|
||||
formData.set("isVisible", "on");
|
||||
}
|
||||
return updatePaymentMethodAction(formData);
|
||||
});
|
||||
}
|
||||
|
||||
if (methods.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border p-4 text-sm text-muted">
|
||||
No payment methods yet. Add one above.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="terminal-heading">Current Methods</h2>
|
||||
|
||||
{message ? <p className={isError ? "text-sm text-red-300" : "text-sm text-accent"}>{message}</p> : null}
|
||||
|
||||
{indexed.map(({ method, index }) => {
|
||||
const editing = editor?.id === method.id;
|
||||
|
||||
return (
|
||||
<article key={method.id} className="terminal-card space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-base font-semibold text-text">
|
||||
{method.label} <span className="text-muted">({PAYMENT_METHOD_LABELS[method.type]})</span>
|
||||
</p>
|
||||
<p className="mt-2 break-all text-sm text-muted">{method.value}</p>
|
||||
{method.network ? <p className="mt-1 text-xs text-muted">Network: {method.network}</p> : null}
|
||||
{method.description ? <p className="mt-1 text-xs text-muted">{method.description}</p> : null}
|
||||
{!method.isVisible ? <p className="mt-1 text-xs text-amber-200">Hidden on public profile</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button className="dashboard-action-small" disabled={isPending || index === 0} onClick={() => handleMove(method.id, "up")}>↑</Button>
|
||||
<Button className="dashboard-action-small" disabled={isPending || index === methods.length - 1} onClick={() => handleMove(method.id, "down")}>↓</Button>
|
||||
<Button className="dashboard-action-small" disabled={isPending} onClick={() => handleToggle(method.id)}>
|
||||
{method.isVisible ? "Hide" : "Show"}
|
||||
</Button>
|
||||
<Button className="dashboard-action-small" disabled={isPending} onClick={() => setEditor(editing ? null : toEditor(method))}>
|
||||
{editing ? "Cancel" : "Edit"}
|
||||
</Button>
|
||||
<Button className="dashboard-action-small" variant="danger" disabled={isPending} onClick={() => handleDelete(method.id)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing && editor ? (
|
||||
<div className="space-y-4 border-t border-border/70 pt-4">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="form-label">Type</span>
|
||||
<Select
|
||||
value={editor.type}
|
||||
onChange={(event) =>
|
||||
setEditor((previous) =>
|
||||
previous
|
||||
? {
|
||||
...previous,
|
||||
type: event.target.value as PaymentMethodType,
|
||||
network: event.target.value === "USDT" ? previous.network : ""
|
||||
}
|
||||
: previous
|
||||
)
|
||||
}
|
||||
>
|
||||
{PAYMENT_METHOD_TYPES.map((methodType) => (
|
||||
<option key={methodType} value={methodType}>
|
||||
{PAYMENT_METHOD_LABELS[methodType]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="form-label">Label</span>
|
||||
<Input
|
||||
value={editor.label}
|
||||
onChange={(event) =>
|
||||
setEditor((previous) => (previous ? { ...previous, label: event.target.value } : previous))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="form-label">Value</span>
|
||||
<Input
|
||||
value={editor.value}
|
||||
onChange={(event) =>
|
||||
setEditor((previous) => (previous ? { ...previous, value: event.target.value } : previous))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="form-label">Network</span>
|
||||
{editor.type === "USDT" ? (
|
||||
<Select
|
||||
value={editor.network}
|
||||
onChange={(event) =>
|
||||
setEditor((previous) => (previous ? { ...previous, network: event.target.value } : previous))
|
||||
}
|
||||
>
|
||||
<option value="">Select a network</option>
|
||||
{USDT_NETWORKS.map((network) => (
|
||||
<option key={network} value={network}>
|
||||
{network}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={editor.network}
|
||||
onChange={(event) =>
|
||||
setEditor((previous) => (previous ? { ...previous, network: event.target.value } : previous))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="form-label">Description</span>
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={editor.description}
|
||||
onChange={(event) =>
|
||||
setEditor((previous) => (previous ? { ...previous, description: event.target.value } : previous))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 text-sm text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editor.isVisible}
|
||||
onChange={(event) =>
|
||||
setEditor((previous) => (previous ? { ...previous, isVisible: event.target.checked } : previous))
|
||||
}
|
||||
className="h-4 w-4 rounded border-border bg-panel text-accent focus:ring-accent"
|
||||
/>
|
||||
Visible on public profile
|
||||
</label>
|
||||
|
||||
<Button className="dashboard-action" variant="primary" disabled={isPending} onClick={handleUpdate}>
|
||||
{isPending ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
136
components/dashboard/profile-form.tsx
Normal file
136
components/dashboard/profile-form.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState, useTransition } from "react";
|
||||
import { updateProfileAction } from "@/actions/profile";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
type ThemeOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
};
|
||||
|
||||
type ProfileFormProps = {
|
||||
initialValues: {
|
||||
username: string;
|
||||
displayName: string;
|
||||
bio: string;
|
||||
avatarUrl: string;
|
||||
themeId: string;
|
||||
isPublic: boolean;
|
||||
};
|
||||
themes: ThemeOption[];
|
||||
};
|
||||
|
||||
export function ProfileForm({ initialValues, themes }: ProfileFormProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const [username, setUsername] = useState(initialValues.username);
|
||||
const [displayName, setDisplayName] = useState(initialValues.displayName);
|
||||
const [bio, setBio] = useState(initialValues.bio);
|
||||
const [avatarUrl, setAvatarUrl] = useState(initialValues.avatarUrl);
|
||||
const [themeId, setThemeId] = useState(initialValues.themeId);
|
||||
const [isPublic, setIsPublic] = useState(initialValues.isPublic);
|
||||
|
||||
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setMessage(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.set("username", username);
|
||||
formData.set("displayName", displayName);
|
||||
formData.set("bio", bio);
|
||||
formData.set("avatarUrl", avatarUrl);
|
||||
formData.set("themeId", themeId);
|
||||
if (isPublic) {
|
||||
formData.set("isPublic", "on");
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await updateProfileAction(formData);
|
||||
setMessage(result.message);
|
||||
setIsError(!result.success);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="terminal-card space-y-5">
|
||||
<h2 className="terminal-heading">Edit Profile</h2>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="form-label">Username</span>
|
||||
<Input
|
||||
required
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value.toLowerCase())}
|
||||
minLength={3}
|
||||
maxLength={32}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="form-label">Display Name</span>
|
||||
<Input
|
||||
required
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
minLength={2}
|
||||
maxLength={60}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="form-label">Bio</span>
|
||||
<Textarea value={bio} onChange={(event) => setBio(event.target.value)} maxLength={280} rows={4} />
|
||||
</label>
|
||||
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="form-label">Avatar URL</span>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://..."
|
||||
value={avatarUrl}
|
||||
onChange={(event) => setAvatarUrl(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="form-label">Theme</span>
|
||||
<Select value={themeId} onChange={(event) => setThemeId(event.target.value)}>
|
||||
{themes.map((theme) => (
|
||||
<option key={theme.id} value={theme.id}>
|
||||
{theme.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 text-sm text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPublic}
|
||||
onChange={(event) => setIsPublic(event.target.checked)}
|
||||
className="h-4 w-4 rounded border-border bg-panel text-accent focus:ring-accent"
|
||||
/>
|
||||
Public profile visible
|
||||
</label>
|
||||
|
||||
<p className="text-xs text-muted">
|
||||
Validation is basic format checking and does not guarantee account or chain-level correctness.
|
||||
</p>
|
||||
|
||||
{message ? <p className={isError ? "text-sm text-red-300" : "text-sm text-accent"}>{message}</p> : null}
|
||||
|
||||
<Button type="submit" variant="primary" className="dashboard-action" disabled={isPending}>
|
||||
{isPending ? "Saving..." : "Save profile"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
49
components/dashboard/sidebar.tsx
Normal file
49
components/dashboard/sidebar.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/dashboard", label: "Overview" },
|
||||
{ href: "/dashboard/profile", label: "Profile" },
|
||||
{ href: "/dashboard/payment-methods", label: "Payment Methods" },
|
||||
{ href: "/dashboard/social-links", label: "Social Links" }
|
||||
];
|
||||
|
||||
type SidebarProps = {
|
||||
username?: string;
|
||||
};
|
||||
|
||||
export function Sidebar({ username }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<aside className="w-full border-b border-border/80 pb-5 md:w-64 md:self-start md:border-b-0 md:border-r md:pb-0 md:pr-6">
|
||||
<div className="mb-6 space-y-1">
|
||||
<p className="terminal-heading">PayMe Dashboard</p>
|
||||
{username ? <p className="text-sm text-muted">Public profile: /u/{username}</p> : null}
|
||||
</div>
|
||||
|
||||
<nav aria-label="Dashboard navigation" className="space-y-2">
|
||||
{navItems.map((item) => {
|
||||
const active = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"block rounded-md px-3 py-2.5 text-sm transition-colors",
|
||||
active
|
||||
? "bg-panel text-text"
|
||||
: "text-muted hover:bg-panel/70 hover:text-text"
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
165
components/dashboard/social-links-form.tsx
Normal file
165
components/dashboard/social-links-form.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState, useTransition } from "react";
|
||||
import {
|
||||
createSocialLinkAction,
|
||||
deleteSocialLinkAction,
|
||||
moveSocialLinkAction,
|
||||
toggleSocialLinkAction
|
||||
} from "@/actions/social-links";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
type SocialLinksFormProps = {
|
||||
links: SocialLinkItem[];
|
||||
};
|
||||
|
||||
type SocialLinkItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
sortOrder: number;
|
||||
isVisible: boolean;
|
||||
};
|
||||
|
||||
export function SocialLinksForm({ links }: SocialLinksFormProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const [label, setLabel] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
|
||||
function runAction(executor: () => Promise<{ success: boolean; message: string }>) {
|
||||
setMessage(null);
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await executor();
|
||||
setMessage(result.message);
|
||||
setIsError(!result.success);
|
||||
|
||||
if (result.success) {
|
||||
setLabel("");
|
||||
setUrl("");
|
||||
setIsVisible(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleCreate(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
runAction(async () => {
|
||||
const formData = new FormData();
|
||||
formData.set("label", label);
|
||||
formData.set("url", url);
|
||||
if (isVisible) {
|
||||
formData.set("isVisible", "on");
|
||||
}
|
||||
return createSocialLinkAction(formData);
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete(id: string) {
|
||||
runAction(async () => {
|
||||
const formData = new FormData();
|
||||
formData.set("id", id);
|
||||
return deleteSocialLinkAction(formData);
|
||||
});
|
||||
}
|
||||
|
||||
function handleToggle(id: string) {
|
||||
runAction(async () => {
|
||||
const formData = new FormData();
|
||||
formData.set("id", id);
|
||||
return toggleSocialLinkAction(formData);
|
||||
});
|
||||
}
|
||||
|
||||
function handleMove(id: string, direction: "up" | "down") {
|
||||
runAction(async () => {
|
||||
const formData = new FormData();
|
||||
formData.set("id", id);
|
||||
formData.set("direction", direction);
|
||||
return moveSocialLinkAction(formData);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<form className="terminal-card space-y-5" onSubmit={handleCreate}>
|
||||
<h2 className="terminal-heading">Add Social/Contact Link</h2>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="form-label">Label</span>
|
||||
<Input required value={label} onChange={(event) => setLabel(event.target.value)} placeholder="GitHub" />
|
||||
</label>
|
||||
|
||||
<label className="space-y-2 text-sm">
|
||||
<span className="form-label">URL</span>
|
||||
<Input
|
||||
required
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
placeholder="https://github.com/username"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 text-sm text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isVisible}
|
||||
onChange={(event) => setIsVisible(event.target.checked)}
|
||||
className="h-4 w-4 rounded border-border bg-panel text-accent focus:ring-accent"
|
||||
/>
|
||||
Visible on public profile
|
||||
</label>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button type="submit" variant="primary" className="dashboard-action" disabled={isPending}>
|
||||
{isPending ? "Saving..." : "Add link"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{message ? <p className={isError ? "text-sm text-red-300" : "text-sm text-accent"}>{message}</p> : null}
|
||||
|
||||
{links.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border p-4 text-sm text-muted">No social links yet.</div>
|
||||
) : (
|
||||
<section className="space-y-3">
|
||||
{links.map((link, index) => (
|
||||
<article key={link.id} className="terminal-card">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{link.label}</p>
|
||||
<p className="text-xs text-muted">{link.url}</p>
|
||||
{!link.isVisible ? <p className="text-xs text-amber-200">Hidden on public profile</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button className="dashboard-action-small" disabled={isPending || index === 0} onClick={() => handleMove(link.id, "up")}>
|
||||
↑
|
||||
</Button>
|
||||
<Button className="dashboard-action-small" disabled={isPending || index === links.length - 1} onClick={() => handleMove(link.id, "down")}>
|
||||
↓
|
||||
</Button>
|
||||
<Button className="dashboard-action-small" disabled={isPending} onClick={() => handleToggle(link.id)}>
|
||||
{link.isVisible ? "Hide" : "Show"}
|
||||
</Button>
|
||||
<Button className="dashboard-action-small" variant="danger" disabled={isPending} onClick={() => handleDelete(link.id)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user