Initial commit: Setup Bun, Elysia, Vite, React, TanStack Router, Mantine, and Biome
This commit is contained in:
19
src/routes/__root.tsx
Normal file
19
src/routes/__root.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
/** biome-ignore-all lint/suspicious/noExplicitAny: <explanation */
|
||||
import { protectedRouteMiddleware } from "@/middleware/authMiddleware";
|
||||
import { authStore } from "@/store/auth";
|
||||
import "@mantine/core/styles.css";
|
||||
import "@mantine/dates/styles.css";
|
||||
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: RootComponent,
|
||||
beforeLoad: protectedRouteMiddleware,
|
||||
onEnter({ context }) {
|
||||
authStore.user = context?.user as any;
|
||||
authStore.session = context?.session as any;
|
||||
},
|
||||
});
|
||||
|
||||
function RootComponent() {
|
||||
return <Outlet />;
|
||||
}
|
||||
485
src/routes/dashboard/apikey.tsx
Normal file
485
src/routes/dashboard/apikey.tsx
Normal file
@@ -0,0 +1,485 @@
|
||||
/** biome-ignore-all lint/suspicious/noExplicitAny: <explanation */
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
CopyButton,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Modal,
|
||||
Stack,
|
||||
Switch,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { DatePicker, type DatePickerValue } from "@mantine/dates";
|
||||
import {
|
||||
IconCalendar,
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconClock,
|
||||
IconCopy,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconInfoCircle,
|
||||
IconKey,
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import dayjs from "dayjs";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { protectedRouteMiddleware } from "../../middleware/authMiddleware";
|
||||
import { apiClient } from "../../utils/api-client";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/apikey")({
|
||||
beforeLoad: protectedRouteMiddleware,
|
||||
component: DashboardApikeyComponent,
|
||||
});
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
isActive: boolean;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function DashboardApikeyComponent() {
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [newKeyExpiresAt, setNewKeyExpiresAt] = useState<DatePickerValue>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [showKey, setShowKey] = useState<{ [key: string]: boolean }>({});
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [keyToDelete, setKeyToDelete] = useState<string | null>(null);
|
||||
|
||||
const fetchApiKeys = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await apiClient.api.apikey.get();
|
||||
if (response.data) {
|
||||
setApiKeys((response.data.apiKeys as any) || []);
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to load API keys");
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchApiKeys();
|
||||
}, [fetchApiKeys]);
|
||||
|
||||
const handleCreateApiKey = async () => {
|
||||
if (!newKeyName.trim()) {
|
||||
setError("API key name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setCreating(true);
|
||||
const response = await apiClient.api.apikey.post({
|
||||
name: newKeyName,
|
||||
expiresAt: newKeyExpiresAt ? dayjs(newKeyExpiresAt).toISOString() : undefined,
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setApiKeys([...apiKeys, response.data.apiKey as any]);
|
||||
setNewKeyName("");
|
||||
setNewKeyExpiresAt(null);
|
||||
setCreateModalOpen(false);
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to create API key");
|
||||
console.error(err);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleApiKey = async (id: string, currentStatus: boolean) => {
|
||||
try {
|
||||
if (!id) {
|
||||
setError("API key ID is required");
|
||||
return;
|
||||
}
|
||||
const response = await apiClient.api.apikey.update.post({
|
||||
id,
|
||||
isActive: !currentStatus,
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setApiKeys(
|
||||
apiKeys.map((key) =>
|
||||
key.id === id ? { ...key, isActive: !currentStatus } : key,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to update API key status");
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteApiKey = async (id: string) => {
|
||||
// Store the key ID and open the confirmation modal
|
||||
setKeyToDelete(id);
|
||||
setDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmDeleteApiKey = async () => {
|
||||
if (!keyToDelete) return;
|
||||
|
||||
try {
|
||||
await apiClient.api.apikey.delete.post({
|
||||
id: keyToDelete,
|
||||
});
|
||||
setApiKeys(apiKeys.filter((key: ApiKey) => key.id !== keyToDelete));
|
||||
setDeleteModalOpen(false);
|
||||
setKeyToDelete(null);
|
||||
} catch (err) {
|
||||
setError("Failed to delete API key");
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleShowKey = (id: string) => {
|
||||
setShowKey((prev) => ({
|
||||
...prev,
|
||||
[id]: !prev[id],
|
||||
}));
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
};
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<Title order={1} mb="lg" ta="center">
|
||||
API Keys Management
|
||||
</Title>
|
||||
|
||||
{error && (
|
||||
<Alert title="Error" color="red" mb="md">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card
|
||||
withBorder
|
||||
p="xl"
|
||||
radius="md"
|
||||
bg="rgba(251, 240, 223, 0.05)"
|
||||
style={{ border: "1px solid rgba(251, 240, 223, 0.1)" }}
|
||||
>
|
||||
<Group justify="space-between" mb="md">
|
||||
<Stack gap={0}>
|
||||
<Title order={3}>Your API Keys</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Manage your API keys for secure access to our services
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
variant="light"
|
||||
color="blue"
|
||||
>
|
||||
Create New API Key
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Table striped highlightOnHover mt="md" verticalSpacing="md">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>
|
||||
<Group gap={6}>
|
||||
<IconKey size={16} stroke={1.5} /> Name
|
||||
</Group>
|
||||
</Table.Th>
|
||||
<Table.Th>
|
||||
<Group gap={6}>
|
||||
<IconKey size={16} stroke={1.5} /> Key
|
||||
</Group>
|
||||
</Table.Th>
|
||||
<Table.Th>
|
||||
<Group gap={6}>
|
||||
<IconCircleCheck size={16} stroke={1.5} /> Status
|
||||
</Group>
|
||||
</Table.Th>
|
||||
<Table.Th>
|
||||
<Group gap={6}>
|
||||
<IconCalendar size={16} stroke={1.5} /> Expiration
|
||||
</Group>
|
||||
</Table.Th>
|
||||
<Table.Th>
|
||||
<Group gap={6}>
|
||||
<IconClock size={16} stroke={1.5} /> Created
|
||||
</Group>
|
||||
</Table.Th>
|
||||
<Table.Th>
|
||||
<Group gap={6}>
|
||||
<IconInfoCircle size={16} stroke={1.5} /> Actions
|
||||
</Group>
|
||||
</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{apiKeys.map((apiKey) => (
|
||||
<Table.Tr
|
||||
key={apiKey.id}
|
||||
style={{ backgroundColor: "rgba(251, 240, 223, 0.02)" }}
|
||||
>
|
||||
<Table.Td>
|
||||
<Text fw={500} c="#fbf0df">
|
||||
{apiKey.name}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={6}>
|
||||
{showKey[apiKey.id] ? (
|
||||
<Text
|
||||
c="#f3d5a3"
|
||||
style={{ fontFamily: "monospace", fontSize: "0.85rem" }}
|
||||
>
|
||||
{apiKey.key}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
c="dimmed"
|
||||
style={{ fontFamily: "monospace", fontSize: "0.85rem" }}
|
||||
>
|
||||
••••••••••••••••••••••••••••••••
|
||||
</Text>
|
||||
)}
|
||||
<CopyButton value={apiKey.key}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip label={copied ? "Copied" : "Copy"}>
|
||||
<ActionIcon
|
||||
color={copied ? "green" : "gray"}
|
||||
onClick={copy}
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
>
|
||||
<IconCopy size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
<Tooltip
|
||||
label={showKey[apiKey.id] ? "Hide key" : "Show key"}
|
||||
>
|
||||
<ActionIcon
|
||||
color="gray"
|
||||
onClick={() => toggleShowKey(apiKey.id)}
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
>
|
||||
{showKey[apiKey.id] ? (
|
||||
<IconEyeOff size={16} />
|
||||
) : (
|
||||
<IconEye size={16} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group>
|
||||
<Tooltip
|
||||
label={`API Key is ${apiKey.isActive ? "Active" : "Inactive"}`}
|
||||
>
|
||||
<Switch
|
||||
checked={apiKey.isActive}
|
||||
onChange={() =>
|
||||
handleToggleApiKey(apiKey.id, apiKey.isActive)
|
||||
}
|
||||
size="md"
|
||||
color={apiKey.isActive ? "green" : "gray"}
|
||||
onLabel={<IconCircleCheck size={12} stroke={1.5} />}
|
||||
offLabel={<IconCircleX size={12} stroke={1.5} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{apiKey.expiresAt ? (
|
||||
<Group>
|
||||
<Text>{formatDate(apiKey.expiresAt)}</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
{formatTime(apiKey.expiresAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Badge variant="outline" color="blue">
|
||||
Never Expires
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group>
|
||||
<Text>{formatDate(apiKey.createdAt)}</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
{formatTime(apiKey.createdAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group>
|
||||
<Tooltip label="Delete API Key">
|
||||
<ActionIcon
|
||||
color="red"
|
||||
onClick={() => handleDeleteApiKey(apiKey.id)}
|
||||
variant="light"
|
||||
size="lg"
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
{apiKeys.length === 0 && !loading && (
|
||||
<Card
|
||||
p="xl"
|
||||
radius="md"
|
||||
withBorder
|
||||
mt="xl"
|
||||
bg="rgba(251, 240, 223, 0.03)"
|
||||
>
|
||||
<Group justify="center" align="center">
|
||||
<Stack align="center" gap="md">
|
||||
<IconKey
|
||||
size={48}
|
||||
stroke={1.2}
|
||||
color="rgba(251, 240, 223, 0.3)"
|
||||
/>
|
||||
<Text ta="center" c="dimmed" fz="lg">
|
||||
No API keys created yet
|
||||
</Text>
|
||||
<Text ta="center" c="dimmed" size="sm">
|
||||
Get started by creating your first API key
|
||||
</Text>
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
variant="light"
|
||||
color="blue"
|
||||
mt="md"
|
||||
>
|
||||
Create New API Key
|
||||
</Button>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
opened={createModalOpen}
|
||||
onClose={() => {
|
||||
setCreateModalOpen(false);
|
||||
setError(null);
|
||||
}}
|
||||
title="Create New API Key"
|
||||
centered
|
||||
size="md"
|
||||
>
|
||||
<LoadingOverlay
|
||||
visible={creating}
|
||||
zIndex={1000}
|
||||
overlayProps={{ radius: "sm", blur: 2 }}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="API Key Name"
|
||||
placeholder="Enter a descriptive name for your API key"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.currentTarget.value)}
|
||||
mb="md"
|
||||
description="Choose a name that identifies the purpose of this API key"
|
||||
/>
|
||||
|
||||
<DatePicker
|
||||
value={newKeyExpiresAt}
|
||||
onChange={setNewKeyExpiresAt}
|
||||
mb="md"
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
setCreateModalOpen(false);
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={handleCreateApiKey}
|
||||
color="blue"
|
||||
>
|
||||
Create API Key
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={deleteModalOpen}
|
||||
onClose={() => setDeleteModalOpen(false)}
|
||||
title="Confirm Delete"
|
||||
centered
|
||||
size="md"
|
||||
>
|
||||
<Stack>
|
||||
<Text>Are you sure you want to delete this API key?</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
This action cannot be undone.
|
||||
</Text>
|
||||
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setDeleteModalOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" onClick={confirmDeleteApiKey}>
|
||||
Delete API Key
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
224
src/routes/dashboard/index.tsx
Normal file
224
src/routes/dashboard/index.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Grid,
|
||||
Group,
|
||||
Modal,
|
||||
Progress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconClock,
|
||||
IconDatabase,
|
||||
IconServer,
|
||||
IconUserCheck,
|
||||
} from "@tabler/icons-react";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useSnapshot } from "valtio";
|
||||
import { authClient } from "@/utils/auth-client";
|
||||
import { authStore } from "../../store/auth";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/")({
|
||||
component: DashboardComponent,
|
||||
});
|
||||
|
||||
function DashboardComponent() {
|
||||
const snap = useSnapshot(authStore);
|
||||
const navigate = useNavigate();
|
||||
const [logoutModalOpen, setLogoutModalOpen] = useState(false);
|
||||
|
||||
// Mock data for dashboard stats
|
||||
const statsData = [
|
||||
{ title: "Total Users", value: "1,234", icon: <IconUserCheck size={24} /> },
|
||||
{ title: "Server Uptime", value: "99.9%", icon: <IconClock size={24} /> },
|
||||
{ title: "Database Load", value: "42%", icon: <IconDatabase size={24} /> },
|
||||
{ title: "Active Sessions", value: "128", icon: <IconServer size={24} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<Title order={1} mb="lg" ta="center">
|
||||
Dashboard Overview
|
||||
</Title>
|
||||
|
||||
{/* User Profile Card */}
|
||||
<Card
|
||||
withBorder
|
||||
p="xl"
|
||||
radius="md"
|
||||
mb="xl"
|
||||
bg="rgba(251, 240, 223, 0.05)"
|
||||
style={{ border: "1px solid rgba(251, 240, 223, 0.1)" }}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Group>
|
||||
<Avatar
|
||||
src={snap.user?.image}
|
||||
size={80}
|
||||
radius="xl"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
border: "2px solid rgba(251, 240, 223, 0.3)",
|
||||
}}
|
||||
onClick={() => navigate({ to: "/profile" })}
|
||||
>
|
||||
{snap.user?.name?.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text size="lg" fw={600} c="#fbf0df">
|
||||
{snap.user?.name}
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
{snap.user?.email}
|
||||
</Text>
|
||||
<Badge mt="xs" variant="light" color="green">
|
||||
Verified Account
|
||||
</Badge>
|
||||
</div>
|
||||
</Group>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
onClick={() => setLogoutModalOpen(true)}
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg" mb="xl">
|
||||
{statsData.map((stat, index) => (
|
||||
<Card
|
||||
key={index.toString()}
|
||||
withBorder
|
||||
p="lg"
|
||||
radius="md"
|
||||
bg="rgba(251, 240, 223, 0.05)"
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Box>
|
||||
<Text size="sm" c="dimmed">
|
||||
{stat.title}
|
||||
</Text>
|
||||
<Text size="lg" fw={700} c="#fbf0df">
|
||||
{stat.value}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box c="#f3d5a3">{stat.icon}</Box>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Grid gutter="lg">
|
||||
<Grid.Col span={{ base: 12, md: 8 }}>
|
||||
<Card
|
||||
withBorder
|
||||
p="lg"
|
||||
radius="md"
|
||||
mb="lg"
|
||||
bg="rgba(251, 240, 223, 0.05)"
|
||||
>
|
||||
<Title order={3} mb="md">
|
||||
System Performance
|
||||
</Title>
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text size="sm">CPU Usage</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
32%
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={32} color="green" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text size="sm">Memory Usage</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
64%
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={64} color="blue" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text size="sm">Disk Usage</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
45%
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={45} color="yellow" />
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<Card withBorder p="lg" radius="md" bg="rgba(251, 240, 223, 0.05)">
|
||||
<Title order={3} mb="md">
|
||||
Server Status
|
||||
</Title>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">Main Server</Text>
|
||||
<Badge color="green" variant="light">
|
||||
Online
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">Database</Text>
|
||||
<Badge color="green" variant="light">
|
||||
Connected
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">Cache</Text>
|
||||
<Badge color="green" variant="light">
|
||||
Running
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">Backup</Text>
|
||||
<Badge color="orange" variant="light">
|
||||
Pending
|
||||
</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Modal
|
||||
opened={logoutModalOpen}
|
||||
onClose={() => setLogoutModalOpen(false)}
|
||||
title="Confirm Logout"
|
||||
centered
|
||||
>
|
||||
<Text mb="md">Are you sure you want to log out?</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="outline" onClick={() => setLogoutModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
authClient.signOut();
|
||||
setLogoutModalOpen(false);
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
122
src/routes/dashboard/route.tsx
Normal file
122
src/routes/dashboard/route.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
AppShell,
|
||||
Burger,
|
||||
Group,
|
||||
NavLink,
|
||||
rem,
|
||||
ScrollArea,
|
||||
Text,
|
||||
Title
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
IconHome,
|
||||
IconKey,
|
||||
IconSettings,
|
||||
IconUsers
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
createFileRoute,
|
||||
Outlet,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
} from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/dashboard")({
|
||||
component: DashboardLayout,
|
||||
});
|
||||
|
||||
function DashboardLayout() {
|
||||
const location = useLocation();
|
||||
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure();
|
||||
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const navItems = [
|
||||
{ icon: IconHome, label: "Beranda", to: "/dashboard" },
|
||||
{ icon: IconUsers, label: "Pengguna", to: "/dashboard/users" },
|
||||
{ icon: IconKey, label: "API Key", to: "/dashboard/apikey" },
|
||||
{ icon: IconSettings, label: "Pengaturan", to: "/dashboard/settings" },
|
||||
];
|
||||
|
||||
const isActive = (path: string) => {
|
||||
const current = location.pathname;
|
||||
|
||||
if (path === "/dashboard") {
|
||||
return current === "/dashboard";
|
||||
}
|
||||
|
||||
return current === path || current.startsWith(`${path}/`);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
navbar={{
|
||||
width: 300,
|
||||
breakpoint: "sm",
|
||||
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
|
||||
}}
|
||||
padding="md"
|
||||
header={{ height: 60 }}
|
||||
>
|
||||
<AppShell.Header>
|
||||
<Group h="100%" px="md" justify="space-between">
|
||||
<Group>
|
||||
<Burger
|
||||
opened={mobileOpened}
|
||||
onClick={toggleMobile}
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
<Burger
|
||||
opened={desktopOpened}
|
||||
onClick={toggleDesktop}
|
||||
visibleFrom="sm"
|
||||
size="sm"
|
||||
/>
|
||||
<Title order={3}>Dashboard</Title>
|
||||
</Group>
|
||||
<Group>
|
||||
<ActionIcon variant="subtle" size="lg">
|
||||
<IconSettings
|
||||
style={{ width: rem(20), height: rem(20) }}
|
||||
stroke={1.5}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar p="md">
|
||||
<ScrollArea h="calc(100vh - 120px)">
|
||||
<Group mb="lg">
|
||||
<Text fw={500} size="lg">
|
||||
Navigasi Utama
|
||||
</Text>
|
||||
</Group>
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
onClick={() => {
|
||||
navigate({ to: item.to });
|
||||
}}
|
||||
leftSection={
|
||||
<item.icon
|
||||
style={{ width: rem(18), height: rem(18) }}
|
||||
stroke={1.5}
|
||||
/>
|
||||
}
|
||||
label={item.label}
|
||||
active={isActive(item.to)}
|
||||
/>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>
|
||||
<Outlet />
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
11
src/routes/dashboard/settings.tsx
Normal file
11
src/routes/dashboard/settings.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { protectedRouteMiddleware } from "../../middleware/authMiddleware";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/settings")({
|
||||
beforeLoad: protectedRouteMiddleware,
|
||||
component: DashboardSettingsComponent,
|
||||
});
|
||||
|
||||
function DashboardSettingsComponent() {
|
||||
return <div>Hello from /dashboard/settings!</div>;
|
||||
}
|
||||
11
src/routes/dashboard/users.tsx
Normal file
11
src/routes/dashboard/users.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { protectedRouteMiddleware } from "../../middleware/authMiddleware";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/users")({
|
||||
beforeLoad: protectedRouteMiddleware,
|
||||
component: DashboardUsersComponent,
|
||||
});
|
||||
|
||||
function DashboardUsersComponent() {
|
||||
return <div>Hello from /dashboard/users!</div>;
|
||||
}
|
||||
788
src/routes/index.tsx
Normal file
788
src/routes/index.tsx
Normal file
@@ -0,0 +1,788 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Grid,
|
||||
Group,
|
||||
Image,
|
||||
Paper,
|
||||
rem,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Transition,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconApi,
|
||||
IconBolt,
|
||||
IconBrandGithub,
|
||||
IconBrandLinkedin,
|
||||
IconBrandTwitter,
|
||||
IconChevronRight,
|
||||
IconLock,
|
||||
IconMoon,
|
||||
IconRocket,
|
||||
IconShield,
|
||||
IconStack2,
|
||||
IconSun,
|
||||
} from "@tabler/icons-react";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: HomePage,
|
||||
});
|
||||
|
||||
// Navigation items
|
||||
const NAV_ITEMS = [
|
||||
{ label: "Home", link: "/" },
|
||||
{ label: "Features", link: "#features" },
|
||||
{ label: "Testimonials", link: "#testimonials" },
|
||||
{ label: "Pricing", link: "/pricing" },
|
||||
{ label: "Contact", link: "/contact" },
|
||||
];
|
||||
|
||||
// Features data
|
||||
const FEATURES = [
|
||||
{
|
||||
icon: IconBolt,
|
||||
title: "Lightning Fast",
|
||||
description: "Built on Bun runtime for exceptional performance and speed.",
|
||||
},
|
||||
{
|
||||
icon: IconShield,
|
||||
title: "Secure by Design",
|
||||
description:
|
||||
"Enterprise-grade authentication with Better Auth integration.",
|
||||
},
|
||||
{
|
||||
icon: IconApi,
|
||||
title: "RESTful API",
|
||||
description:
|
||||
"Full-featured API with Elysia.js for seamless backend operations.",
|
||||
},
|
||||
{
|
||||
icon: IconStack2,
|
||||
title: "Modern Stack",
|
||||
description: "React 19, TanStack Router, and Mantine UI for the best DX.",
|
||||
},
|
||||
{
|
||||
icon: IconLock,
|
||||
title: "API Key Auth",
|
||||
description: "Secure API key management for external integrations.",
|
||||
},
|
||||
{
|
||||
icon: IconRocket,
|
||||
title: "Production Ready",
|
||||
description: "Type-safe, tested, and optimized for production deployment.",
|
||||
},
|
||||
];
|
||||
|
||||
// Testimonials data
|
||||
const TESTIMONIALS = [
|
||||
{
|
||||
id: "testimonial-1",
|
||||
name: "Alex Johnson",
|
||||
role: "Lead Developer",
|
||||
content:
|
||||
"This template saved us weeks of setup time. The architecture is clean and well-thought-out.",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=200&q=80",
|
||||
},
|
||||
{
|
||||
id: "testimonial-2",
|
||||
name: "Sarah Williams",
|
||||
role: "CTO",
|
||||
content:
|
||||
"The performance improvements we saw after switching to this stack were remarkable. Highly recommended!",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=200&q=80",
|
||||
},
|
||||
{
|
||||
id: "testimonial-3",
|
||||
name: "Michael Chen",
|
||||
role: "Product Manager",
|
||||
content:
|
||||
"The developer experience is top-notch. Everything is well-documented and easy to extend.",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=200&q=80",
|
||||
},
|
||||
];
|
||||
|
||||
function NavigationBar() {
|
||||
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setScrolled(window.scrollY > 20);
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
h={70}
|
||||
px="md"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
||||
transition: "all 0.3s ease",
|
||||
boxShadow: scrolled ? "0 2px 10px rgba(0,0,0,0.1)" : "none",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Group h="100%" justify="space-between">
|
||||
<Group>
|
||||
<Link to="/" style={{ textDecoration: "none" }}>
|
||||
<Title order={3} c="blue">
|
||||
BunStack
|
||||
</Title>
|
||||
</Link>
|
||||
<Group ml={50} visibleFrom="sm" gap="lg">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isActive = window.location.pathname === item.link;
|
||||
return (
|
||||
<Box
|
||||
key={item.label}
|
||||
component={Link}
|
||||
to={item.link}
|
||||
style={{
|
||||
textDecoration: "none",
|
||||
fontSize: rem(16),
|
||||
padding: `${rem(8)} ${rem(12)}`,
|
||||
borderRadius: rem(6),
|
||||
transition: "all 0.2s ease",
|
||||
color: isActive
|
||||
? "var(--mantine-color-blue-6)"
|
||||
: "var(--mantine-color-dimmed)",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
display: "block",
|
||||
}}
|
||||
className="nav-item"
|
||||
>
|
||||
{item.label}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
onClick={() => toggleColorScheme()}
|
||||
size="lg"
|
||||
>
|
||||
{colorScheme === "dark" ? (
|
||||
<IconSun size={18} />
|
||||
) : (
|
||||
<IconMoon size={18} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
<Button component={Link} to="/signin" variant="light" size="sm">
|
||||
Sign In
|
||||
</Button>
|
||||
<Button component={Link} to="/signup" size="sm">
|
||||
Get Started
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroSection() {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoaded(true);
|
||||
}, []);
|
||||
|
||||
// Simulate delay for image transition
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setImageLoaded(true);
|
||||
}, 200);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
pt={rem(140)} // Adjusted padding for simpler header
|
||||
pb={rem(60)}
|
||||
>
|
||||
<Container size="lg">
|
||||
<Grid gutter={{ base: rem(40), md: rem(80) }} align="center">
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Transition
|
||||
mounted={loaded}
|
||||
transition="slide-up"
|
||||
duration={600}
|
||||
timingFunction="ease"
|
||||
>
|
||||
{(styles) => (
|
||||
<Stack gap="xl" style={styles}>
|
||||
<Title
|
||||
order={1}
|
||||
style={{
|
||||
fontSize: rem(48),
|
||||
fontWeight: 900,
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
Build Faster with{" "}
|
||||
<Text span c="blue" inherit>
|
||||
Bun Stack
|
||||
</Text>
|
||||
</Title>
|
||||
<Text size="xl" c="dimmed">
|
||||
A modern, full-stack React template powered by Bun,
|
||||
Elysia.js, and TanStack Router. Ship your ideas faster than
|
||||
ever.
|
||||
</Text>
|
||||
<Group gap="md">
|
||||
<Button
|
||||
component={Link}
|
||||
to="/dashboard"
|
||||
size="lg"
|
||||
variant="filled"
|
||||
rightSection={<IconRocket size="1.25rem" />}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
to="/docs"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
>
|
||||
Learn More
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Transition>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Transition
|
||||
mounted={imageLoaded}
|
||||
transition="slide-left"
|
||||
duration={800}
|
||||
timingFunction="ease"
|
||||
>
|
||||
{(styles) => (
|
||||
<Paper shadow="xl" radius="lg" p="md" withBorder style={styles}>
|
||||
<Image
|
||||
src="https://images.unsplash.com/photo-1555066931-4365d14bab8c?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80"
|
||||
alt="Code editor showing Bun Stack code"
|
||||
radius="md"
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
</Transition>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function AnimatedFeatureCard({
|
||||
feature,
|
||||
index,
|
||||
isVisible,
|
||||
}: {
|
||||
feature: (typeof FEATURES)[number];
|
||||
index: number;
|
||||
isVisible: boolean;
|
||||
}) {
|
||||
const [isDelayedVisible, setIsDelayedVisible] = useState(isVisible);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsDelayedVisible(true);
|
||||
}, index * 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isVisible, index]);
|
||||
|
||||
return (
|
||||
<Transition
|
||||
mounted={isDelayedVisible}
|
||||
transition="slide-up"
|
||||
duration={500}
|
||||
timingFunction="ease"
|
||||
>
|
||||
{(styles) => (
|
||||
<Card
|
||||
className="feature-card"
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
shadow="sm"
|
||||
style={styles}
|
||||
>
|
||||
<ThemeIcon variant="light" color="blue" size={60} radius="md">
|
||||
<feature.icon size="1.75rem" />
|
||||
</ThemeIcon>
|
||||
<Stack gap={8} mt="md">
|
||||
<Title order={4}>{feature.title}</Title>
|
||||
<Text size="sm" c="dimmed" lh={1.5}>
|
||||
{feature.description}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturesSection() {
|
||||
const [visibleFeatures, setVisibleFeatures] = useState(
|
||||
Array(FEATURES.length).fill(false),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry, index) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisibleFeatures((prev) => {
|
||||
const newVisible = [...prev];
|
||||
newVisible[index] = true;
|
||||
return newVisible;
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
|
||||
const elements = document.querySelectorAll(".feature-card");
|
||||
elements.forEach((el) => {
|
||||
observer.observe(el);
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Container size="lg" py={rem(80)}>
|
||||
<Stack gap="xl" align="center" mb={rem(50)}>
|
||||
<Transition
|
||||
mounted={true}
|
||||
transition="fade"
|
||||
duration={600}
|
||||
timingFunction="ease"
|
||||
>
|
||||
{(styles) => (
|
||||
<div style={styles}>
|
||||
<Title order={2} ta="center">
|
||||
Everything You Need
|
||||
</Title>
|
||||
<Text c="dimmed" size="lg" ta="center" maw={600}>
|
||||
A complete toolkit for building modern web applications with
|
||||
best practices built-in.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Transition>
|
||||
</Stack>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
{FEATURES.map((feature, index) => (
|
||||
<AnimatedFeatureCard
|
||||
key={feature.title}
|
||||
feature={feature}
|
||||
index={index}
|
||||
isVisible={visibleFeatures[index]}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function AnimatedTestimonialCard({
|
||||
testimonial,
|
||||
index,
|
||||
isVisible,
|
||||
}: {
|
||||
testimonial: (typeof TESTIMONIALS)[number];
|
||||
index: number;
|
||||
isVisible: boolean;
|
||||
}) {
|
||||
const [isDelayedVisible, setIsDelayedVisible] = useState(isVisible);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsDelayedVisible(true);
|
||||
}, index * 150);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isVisible, index]);
|
||||
|
||||
return (
|
||||
<Transition
|
||||
mounted={isDelayedVisible}
|
||||
transition="slide-up"
|
||||
duration={500}
|
||||
timingFunction="ease"
|
||||
>
|
||||
{(styles) => (
|
||||
<Card
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
shadow="sm"
|
||||
className="testimonial-card"
|
||||
style={styles}
|
||||
>
|
||||
<Text c="dimmed" mb="md">
|
||||
"{testimonial.content}"
|
||||
</Text>
|
||||
<Group>
|
||||
<Avatar src={testimonial.avatar} size="md" radius="xl" />
|
||||
<Stack gap={0}>
|
||||
<Text fw={600}>{testimonial.name}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{testimonial.role}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
||||
function TestimonialsSection() {
|
||||
const [visibleTestimonials, setVisibleTestimonials] = useState(
|
||||
Array(TESTIMONIALS.length).fill(false),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry, index) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisibleTestimonials((prev) => {
|
||||
const newVisible = [...prev];
|
||||
newVisible[index] = true;
|
||||
return newVisible;
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
|
||||
const elements = document.querySelectorAll(".testimonial-card");
|
||||
elements.forEach((el) => {
|
||||
observer.observe(el);
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box py={rem(80)}>
|
||||
<Container size="lg">
|
||||
<Stack gap="xl" align="center" mb={rem(50)}>
|
||||
<Transition
|
||||
mounted={true}
|
||||
transition="fade"
|
||||
duration={600}
|
||||
timingFunction="ease"
|
||||
>
|
||||
{(styles) => (
|
||||
<div style={styles}>
|
||||
<Title order={2} ta="center">
|
||||
Loved by Developers
|
||||
</Title>
|
||||
<Text c="dimmed" size="lg" ta="center" maw={600}>
|
||||
Join thousands of satisfied developers who have accelerated
|
||||
their projects with Bun Stack.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Transition>
|
||||
</Stack>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
{TESTIMONIALS.map((testimonial, index) => (
|
||||
<AnimatedTestimonialCard
|
||||
key={testimonial.id}
|
||||
testimonial={testimonial}
|
||||
index={index}
|
||||
isVisible={visibleTestimonials[index]}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function CtaSection() {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoaded(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Container size="lg" py={rem(80)}>
|
||||
<Transition
|
||||
mounted={loaded}
|
||||
transition="slide-up"
|
||||
duration={600}
|
||||
timingFunction="ease"
|
||||
>
|
||||
{(styles) => (
|
||||
<Paper
|
||||
radius="lg"
|
||||
p={rem(60)}
|
||||
bg="blue"
|
||||
style={{
|
||||
...styles,
|
||||
background:
|
||||
"linear-gradient(135deg, var(--mantine-color-blue-6), var(--mantine-color-indigo-6))",
|
||||
}}
|
||||
>
|
||||
<Stack align="center" gap="xl" ta="center">
|
||||
<Title c="white" order={2}>
|
||||
Ready to get started?
|
||||
</Title>
|
||||
<Text c="white" size="lg" maw={600}>
|
||||
Join thousands of developers who are building faster and more
|
||||
reliable applications with Bun Stack.
|
||||
</Text>
|
||||
<Group>
|
||||
<Button
|
||||
component={Link}
|
||||
to="/signup"
|
||||
size="lg"
|
||||
variant="white"
|
||||
color="dark"
|
||||
rightSection={<IconChevronRight size="1.125rem" />}
|
||||
>
|
||||
Create Account
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
to="/docs"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
color="white"
|
||||
>
|
||||
View Documentation
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
</Transition>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function Footer() {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setLoaded(true);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Transition
|
||||
mounted={loaded}
|
||||
transition="slide-up"
|
||||
duration={600}
|
||||
timingFunction="ease"
|
||||
>
|
||||
{(styles) => (
|
||||
<Box
|
||||
py={rem(40)}
|
||||
style={{
|
||||
...styles,
|
||||
borderTop: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Container size="lg">
|
||||
<Grid gutter={{ base: rem(40), md: rem(80) }}>
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<Stack gap="md">
|
||||
<Title order={3}>BunStack</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
The ultimate full-stack solution for modern web
|
||||
applications.
|
||||
</Text>
|
||||
<Group>
|
||||
<ActionIcon size="lg" variant="subtle" color="gray">
|
||||
<IconBrandGithub size="1.25rem" />
|
||||
</ActionIcon>
|
||||
<ActionIcon size="lg" variant="subtle" color="gray">
|
||||
<IconBrandTwitter size="1.25rem" />
|
||||
</ActionIcon>
|
||||
<ActionIcon size="lg" variant="subtle" color="gray">
|
||||
<IconBrandLinkedin size="1.25rem" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 2 }}>
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>Product</Title>
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
component={Link}
|
||||
to="/features"
|
||||
td="none"
|
||||
>
|
||||
Features
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
component={Link}
|
||||
to="/pricing"
|
||||
td="none"
|
||||
>
|
||||
Pricing
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
component={Link}
|
||||
to="/docs"
|
||||
td="none"
|
||||
>
|
||||
Documentation
|
||||
</Text>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 2 }}>
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>Company</Title>
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
component={Link}
|
||||
to="/about"
|
||||
td="none"
|
||||
>
|
||||
About
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
component={Link}
|
||||
to="/blog"
|
||||
td="none"
|
||||
>
|
||||
Blog
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
component={Link}
|
||||
to="/careers"
|
||||
td="none"
|
||||
>
|
||||
Careers
|
||||
</Text>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>Subscribe to our newsletter</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Get the latest news and updates
|
||||
</Text>
|
||||
<Group>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Your email"
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
flex: 1,
|
||||
}}
|
||||
/>
|
||||
<Button>Subscribe</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Box
|
||||
pt={rem(40)}
|
||||
style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" c="dimmed">
|
||||
© 2024 Bun Stack. Built with Bun, Elysia, and React.
|
||||
</Text>
|
||||
<Group gap="lg">
|
||||
<Text
|
||||
component={Link}
|
||||
to="/privacy"
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
style={{ textDecoration: "none" }}
|
||||
>
|
||||
Privacy Policy
|
||||
</Text>
|
||||
<Text
|
||||
component={Link}
|
||||
to="/terms"
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
style={{ textDecoration: "none" }}
|
||||
>
|
||||
Terms of Service
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
)}
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
||||
function HomePage() {
|
||||
return (
|
||||
<Box>
|
||||
<NavigationBar />
|
||||
<HeroSection />
|
||||
<FeaturesSection />
|
||||
<TestimonialsSection />
|
||||
<CtaSection />
|
||||
<Footer />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
317
src/routes/profile.tsx
Normal file
317
src/routes/profile.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Code,
|
||||
Container,
|
||||
Group,
|
||||
Modal,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAt,
|
||||
IconCheck,
|
||||
IconCopy,
|
||||
IconDashboard,
|
||||
IconId,
|
||||
IconLogout,
|
||||
IconShield,
|
||||
IconUser,
|
||||
} from "@tabler/icons-react";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useSnapshot } from "valtio";
|
||||
import { authClient } from "@/utils/auth-client";
|
||||
import { authStore } from "../store/auth";
|
||||
|
||||
export const Route = createFileRoute("/profile")({
|
||||
component: Profile,
|
||||
});
|
||||
|
||||
function Profile() {
|
||||
const snap = useSnapshot(authStore);
|
||||
const navigate = useNavigate();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function logout() {
|
||||
await authClient.signOut();
|
||||
navigate({ to: "/signin" });
|
||||
}
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<Title order={1} mb="lg" ta="center">
|
||||
User Profile
|
||||
</Title>
|
||||
|
||||
{/* Profile Header Card */}
|
||||
<Card
|
||||
withBorder
|
||||
p="xl"
|
||||
radius="md"
|
||||
mb="xl"
|
||||
bg="rgba(251, 240, 223, 0.05)"
|
||||
style={{ border: "1px solid rgba(251, 240, 223, 0.1)" }}
|
||||
>
|
||||
<Group justify="center" align="flex-start" gap="xl">
|
||||
<Avatar
|
||||
src={snap.user?.image}
|
||||
size={120}
|
||||
radius="xl"
|
||||
style={{ border: "2px solid rgba(251, 240, 223, 0.3)" }}
|
||||
>
|
||||
{snap.user?.name?.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Stack gap="xs" justify="center">
|
||||
<Text size="xl" fw={700} c="#fbf0df">
|
||||
{snap.user?.name}
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
<IconAt size={16} stroke={1.5} color="rgba(255, 255, 255, 0.6)" />
|
||||
<Text c="dimmed">{snap.user?.email}</Text>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<IconShield
|
||||
size={16}
|
||||
stroke={1.5}
|
||||
color="rgba(255, 255, 255, 0.6)"
|
||||
/>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={snap.user?.role === "admin" ? "green" : "blue"}
|
||||
>
|
||||
{snap.user?.role || "user"}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
<Title order={2} mb="md" ta="center">
|
||||
Account Information
|
||||
</Title>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" mb="xl">
|
||||
<Card withBorder p="lg" radius="md" bg="rgba(251, 240, 223, 0.05)">
|
||||
<Group>
|
||||
<IconId size={24} stroke={1.5} color="#f3d5a3" />
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
User ID
|
||||
</Text>
|
||||
<Group gap="xs" mt="xs">
|
||||
<Text fw={500} truncate="end" miw={0} c="#fbf0df">
|
||||
{snap.user?.id || "N/A"}
|
||||
</Text>
|
||||
<Tooltip
|
||||
label={copied ? "Copied!" : "Copy to clipboard"}
|
||||
position="top"
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
snap.user?.id && copyToClipboard(snap.user.id)
|
||||
}
|
||||
>
|
||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
<Card withBorder p="lg" radius="md" bg="rgba(251, 240, 223, 0.05)">
|
||||
<Group>
|
||||
<IconAt size={24} stroke={1.5} color="#f3d5a3" />
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
Email
|
||||
</Text>
|
||||
<Group gap="xs" mt="xs">
|
||||
<Text fw={500} truncate="end" miw={0} c="#fbf0df">
|
||||
{snap.user?.email || "N/A"}
|
||||
</Text>
|
||||
<Tooltip
|
||||
label={copied ? "Copied!" : "Copy to clipboard"}
|
||||
position="top"
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
snap.user?.email && copyToClipboard(snap.user.email)
|
||||
}
|
||||
>
|
||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
<Card withBorder p="lg" radius="md" bg="rgba(251, 240, 223, 0.05)">
|
||||
<Group>
|
||||
<IconUser size={24} stroke={1.5} color="#f3d5a3" />
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
Name
|
||||
</Text>
|
||||
<Group gap="xs" mt="xs">
|
||||
<Text fw={500} truncate="end" miw={0} c="#fbf0df">
|
||||
{snap.user?.name || "N/A"}
|
||||
</Text>
|
||||
<Tooltip
|
||||
label={copied ? "Copied!" : "Copy to clipboard"}
|
||||
position="top"
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
snap.user?.name && copyToClipboard(snap.user.name)
|
||||
}
|
||||
>
|
||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
<Card withBorder p="lg" radius="md" bg="rgba(251, 240, 223, 0.05)">
|
||||
<Group>
|
||||
<IconShield size={24} stroke={1.5} color="#f3d5a3" />
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
Role
|
||||
</Text>
|
||||
<Text fw={500} mt="xs" c="#fbf0df">
|
||||
{snap.user?.role || "user"}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<Card
|
||||
withBorder
|
||||
p="lg"
|
||||
radius="md"
|
||||
bg="rgba(251, 240, 223, 0.05)"
|
||||
mb="xl"
|
||||
>
|
||||
<Group justify="space-between" align="center">
|
||||
<Title order={3}>Session Information</Title>
|
||||
<Group>
|
||||
{snap.user?.role === "admin" && (
|
||||
<Button
|
||||
leftSection={<IconDashboard size={16} />}
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={() => navigate({ to: "/dashboard" })}
|
||||
>
|
||||
Dashboard
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
leftSection={<IconLogout size={16} />}
|
||||
variant="outline"
|
||||
color="red"
|
||||
onClick={() => setOpened(true)}
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<Group mt="md" justify="space-between">
|
||||
<div>
|
||||
<Text size="sm" c="dimmed" mb="xs">
|
||||
Session Token
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Code
|
||||
block
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
padding: "0.5rem 0.75rem",
|
||||
backgroundColor: "rgba(26, 26, 26, 0.7)",
|
||||
color: "#f3d5a3",
|
||||
}}
|
||||
>
|
||||
{snap.session?.token
|
||||
? `${snap.session.token.substring(0, 30)}...`
|
||||
: "N/A"}
|
||||
</Code>
|
||||
<Tooltip
|
||||
label={copied ? "Copied!" : "Copy to clipboard"}
|
||||
position="top"
|
||||
>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="gray"
|
||||
size="md"
|
||||
onClick={() =>
|
||||
snap.session?.token && copyToClipboard(snap.session.token)
|
||||
}
|
||||
>
|
||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
title="Confirm Sign Out"
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Text mb="md">
|
||||
Are you sure you want to sign out? You will need to sign in again to
|
||||
access your account.
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setOpened(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<IconLogout size={16} />}
|
||||
color="red"
|
||||
onClick={async () => {
|
||||
await logout();
|
||||
setOpened(false);
|
||||
}}
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
125
src/routes/signin.tsx
Normal file
125
src/routes/signin.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
Anchor,
|
||||
Button,
|
||||
Checkbox,
|
||||
Container,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { IconBrandGithub } from "@tabler/icons-react";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { authClient } from "../utils/auth-client";
|
||||
|
||||
export const Route = createFileRoute("/signin")({
|
||||
component: SigninComponent,
|
||||
});
|
||||
|
||||
function SigninComponent() {
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const result = await authClient.signIn.email(
|
||||
{
|
||||
email,
|
||||
password,
|
||||
},
|
||||
{
|
||||
onRequest: () => {
|
||||
console.log("Sign in request started");
|
||||
},
|
||||
onSuccess: async () => {
|
||||
console.log("Sign in successful, navigating to dashboard");
|
||||
navigate({ to: "/profile", replace: true });
|
||||
},
|
||||
onError: (ctx) => {
|
||||
setError(ctx.error.message || "Failed to sign in");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// If using callbacks, result will be undefined
|
||||
if (result?.error) {
|
||||
setError(result.error.message || "Failed to sign in");
|
||||
}
|
||||
} catch {
|
||||
setError("An unexpected error occurred");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size={420} my={40}>
|
||||
<Title ta="center" c="dimmed">
|
||||
Welcome back!
|
||||
</Title>
|
||||
<Text c="dimmed" size="sm" ta="center" mt={5}>
|
||||
Do not have an account yet?{" "}
|
||||
<Anchor
|
||||
size="sm"
|
||||
component="button"
|
||||
onClick={() => navigate({ to: "/signup" })}
|
||||
>
|
||||
Create account
|
||||
</Anchor>
|
||||
</Text>
|
||||
|
||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Your password"
|
||||
required
|
||||
mt="md"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Checkbox label="Remember me" mt="md" />
|
||||
{error && (
|
||||
<Text c="red" size="sm" mt="md">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
<Button fullWidth mt="xl" type="submit" loading={loading}>
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
fullWidth
|
||||
mt="md"
|
||||
leftSection={<IconBrandGithub size={18} />}
|
||||
onClick={async () => {
|
||||
await authClient.signIn.social({
|
||||
provider: "github",
|
||||
callbackURL: "/profile",
|
||||
});
|
||||
}}
|
||||
>
|
||||
Continue with GitHub
|
||||
</Button>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
102
src/routes/signup.tsx
Normal file
102
src/routes/signup.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
Anchor,
|
||||
Button,
|
||||
Container,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { signUp } from "../utils/auth-client";
|
||||
|
||||
export const Route = createFileRoute("/signup")({
|
||||
component: SignupComponent,
|
||||
});
|
||||
|
||||
function SignupComponent() {
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const { error } = await signUp.email({
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setError(error.message || "Failed to sign up");
|
||||
} else {
|
||||
navigate({ to: "/dashboard" });
|
||||
}
|
||||
} catch {
|
||||
setError("An unexpected error occurred");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size={420} my={40}>
|
||||
<Title ta="center">Create an account</Title>
|
||||
<Text c="dimmed" size="sm" ta="center" mt={5}>
|
||||
Already have an account?{" "}
|
||||
<Anchor
|
||||
size="sm"
|
||||
component="button"
|
||||
onClick={() => navigate({ to: "/signin" })}
|
||||
>
|
||||
Sign in
|
||||
</Anchor>
|
||||
</Text>
|
||||
|
||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="Your name"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
mt="md"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Your password"
|
||||
required
|
||||
mt="md"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
{error && (
|
||||
<Text c="red" size="sm" mt="md">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
<Button fullWidth mt="xl" type="submit" loading={loading}>
|
||||
Create account
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
82
src/routes/users/$id.tsx
Normal file
82
src/routes/users/$id.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { createFileRoute, Link, useParams } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/users/$id")({
|
||||
component: UserDetailPage,
|
||||
});
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
function UserDetailPage() {
|
||||
const { id } = useParams({ from: "/users/$id" }) as { id: string };
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Simulate fetching user by ID
|
||||
fetch("/api/users")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const foundUser = data.users.find((u: User) => u.id === Number(id));
|
||||
setUser(foundUser || null);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="page-container">
|
||||
<h1>User Details</h1>
|
||||
<p className="loading">Loading user...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="page-container">
|
||||
<h1>User Not Found</h1>
|
||||
<p>User with ID {id} does not exist.</p>
|
||||
<Link to="/users" className="back-link">
|
||||
← Back to Users
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<Link to="/users" className="back-link">
|
||||
← Back to Users
|
||||
</Link>
|
||||
|
||||
<div className="user-detail-card">
|
||||
<div className="user-detail-avatar">{user.name.charAt(0)}</div>
|
||||
<h1>{user.name}</h1>
|
||||
<p className="user-email">{user.email}</p>
|
||||
<div className="user-meta">
|
||||
<span className="meta-item">
|
||||
<strong>ID:</strong> {user.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="route-info-box">
|
||||
<strong>🛣️ Dynamic Route:</strong> This page uses the route pattern{" "}
|
||||
<code>/users/$id</code>
|
||||
<br />
|
||||
Current parameter: <code>id = {id}</code>
|
||||
<br />
|
||||
<br />
|
||||
Try changing the ID in the URL to see different users!
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
src/routes/users/index.tsx
Normal file
51
src/routes/users/index.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/users/")({
|
||||
component: UsersPage,
|
||||
});
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
function UsersPage() {
|
||||
const [users, _] = useState<User[]>([]);
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<h1>Users</h1>
|
||||
<p className="page-description">
|
||||
This page demonstrates fetching data from the API and using dynamic
|
||||
routes.
|
||||
</p>
|
||||
|
||||
<div className="users-grid">
|
||||
{users.map((user) => (
|
||||
<Link
|
||||
key={user.id}
|
||||
to="/users/$id"
|
||||
params={{ id: String(user.id) }}
|
||||
className="user-card"
|
||||
>
|
||||
<div className="user-avatar">{user.name.charAt(0)}</div>
|
||||
<div className="user-info">
|
||||
<h3>{user.name}</h3>
|
||||
<p>{user.email}</p>
|
||||
</div>
|
||||
<div className="user-arrow">→</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="route-info-box">
|
||||
<strong>💡 Tip:</strong> Click on a user to see dynamic routing in
|
||||
action!
|
||||
<br />
|
||||
Route pattern: <code>/users/$id</code>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user