mirror of
https://github.com/spliit-app/spliit.git
synced 2026-02-17 13:06:13 +01:00
First version
This commit is contained in:
@@ -2,26 +2,75 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-start-rgb: 214, 219, 220;
|
||||
--background-end-rgb: 255, 255, 255;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@layer base {
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-start-rgb: 0, 0, 0;
|
||||
--background-end-rgb: 0, 0, 0;
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgb(var(--background-end-rgb))
|
||||
)
|
||||
rgb(var(--background-start-rgb));
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
36
src/app/groups/[groupId]/edit/page.tsx
Normal file
36
src/app/groups/[groupId]/edit/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { GroupForm } from '@/components/group-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getGroup, updateGroup } from '@/lib/api'
|
||||
import { groupFormSchema } from '@/lib/schemas'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
|
||||
export default async function EditGroupPage({
|
||||
params: { groupId },
|
||||
}: {
|
||||
params: { groupId: string }
|
||||
}) {
|
||||
const group = await getGroup(groupId)
|
||||
if (!group) notFound()
|
||||
|
||||
async function updateGroupAction(values: unknown) {
|
||||
'use server'
|
||||
const groupFormValues = groupFormSchema.parse(values)
|
||||
const group = await updateGroup(groupId, groupFormValues)
|
||||
redirect(`/groups/${group.id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="mb-4">
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href={`/groups/${groupId}`}>
|
||||
<ChevronLeft className="w-4 h-4 mr-2" /> Back to group
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<GroupForm group={group} onSubmit={updateGroupAction} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
42
src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx
Normal file
42
src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { ExpenseForm } from '@/components/expense-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getExpense, getGroup, updateExpense } from '@/lib/api'
|
||||
import { expenseFormSchema } from '@/lib/schemas'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
|
||||
export default async function EditExpensePage({
|
||||
params: { groupId, expenseId },
|
||||
}: {
|
||||
params: { groupId: string; expenseId: string }
|
||||
}) {
|
||||
const group = await getGroup(groupId)
|
||||
if (!group) notFound()
|
||||
const expense = await getExpense(groupId, expenseId)
|
||||
if (!expense) notFound()
|
||||
|
||||
async function updateExpenseAction(values: unknown) {
|
||||
'use server'
|
||||
const expenseFormValues = expenseFormSchema.parse(values)
|
||||
await updateExpense(groupId, expenseId, expenseFormValues)
|
||||
redirect(`/groups/${groupId}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="mb-4">
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href={`/groups/${groupId}`}>
|
||||
<ChevronLeft className="w-4 h-4 mr-2" /> Back to group
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<ExpenseForm
|
||||
group={group}
|
||||
expense={expense}
|
||||
onSubmit={updateExpenseAction}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
37
src/app/groups/[groupId]/expenses/create/page.tsx
Normal file
37
src/app/groups/[groupId]/expenses/create/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ExpenseForm } from '@/components/expense-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { createExpense, getGroup } from '@/lib/api'
|
||||
import { expenseFormSchema } from '@/lib/schemas'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
|
||||
export default async function ExpensePage({
|
||||
params: { groupId },
|
||||
}: {
|
||||
params: { groupId: string }
|
||||
}) {
|
||||
const group = await getGroup(groupId)
|
||||
if (!group) notFound()
|
||||
|
||||
async function createExpenseAction(values: unknown) {
|
||||
'use server'
|
||||
const expenseFormValues = expenseFormSchema.parse(values)
|
||||
await createExpense(expenseFormValues, groupId)
|
||||
redirect(`/groups/${groupId}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="mb-4">
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href={`/groups/${groupId}`}>
|
||||
<ChevronLeft className="w-4 h-4 mr-2" /> Back to group
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ExpenseForm group={group} onSubmit={createExpenseAction} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
141
src/app/groups/[groupId]/page.tsx
Normal file
141
src/app/groups/[groupId]/page.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { SaveGroupLocally } from '@/app/groups/[groupId]/save-recent-group'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { getGroup, getGroupExpenses } from '@/lib/api'
|
||||
import { ChevronLeft, ChevronRight, Edit, Plus } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
export default async function GroupPage({
|
||||
params: { groupId },
|
||||
}: {
|
||||
params: { groupId: string }
|
||||
}) {
|
||||
const group = await getGroup(groupId)
|
||||
if (!group) notFound()
|
||||
|
||||
const expenses = await getGroupExpenses(groupId)
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="mb-4 flex justify-between">
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href="/groups">
|
||||
<ChevronLeft className="w-4 h-4 mr-2" /> Back to recent groups
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="secondary" asChild>
|
||||
<Link href={`/groups/${groupId}/edit`}>
|
||||
<Edit className="w-4 h-4 mr-2" /> Edit group settings
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<h1 className="font-bold mb-4">{group.name}</h1>
|
||||
|
||||
<Card className="mb-4">
|
||||
<div className="flex flex-1">
|
||||
<CardHeader className="flex-1">
|
||||
<CardTitle>Expenses</CardTitle>
|
||||
<CardDescription>
|
||||
Here are the expenses that you created for your group.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardHeader>
|
||||
<Button asChild size="icon">
|
||||
<Link href={`/groups/${groupId}/expenses/create`}>
|
||||
<Plus />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
</div>
|
||||
<CardContent className="p-0">
|
||||
<Table className="">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Paid by</TableHead>
|
||||
<TableHead>Paid for</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
<TableHead className="w-0"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{expenses.map((expense) => (
|
||||
<TableRow key={expense.id}>
|
||||
<TableCell>{expense.title}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{
|
||||
group.participants.find(
|
||||
(p) => p.id === expense.paidById,
|
||||
)?.name
|
||||
}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="flex flex-wrap gap-1">
|
||||
{expense.paidFor.map((paidFor, index) => (
|
||||
<Badge variant="secondary" key={index}>
|
||||
{
|
||||
group.participants.find(
|
||||
(p) => p.id === paidFor.participantId,
|
||||
)?.name
|
||||
}
|
||||
</Badge>
|
||||
))}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
$ {expense.amount.toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="link"
|
||||
className="-my-2"
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={`/groups/${groupId}/expenses/${expense.id}/edit`}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Participants</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul>
|
||||
{group.participants.map((participant) => (
|
||||
<li key={participant.id}>{participant.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<SaveGroupLocally group={{ id: group.id, name: group.name }} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
18
src/app/groups/[groupId]/save-recent-group.tsx
Normal file
18
src/app/groups/[groupId]/save-recent-group.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
'use client'
|
||||
import {
|
||||
RecentGroup,
|
||||
saveRecentGroup,
|
||||
} from '@/app/groups/recent-groups-helpers'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
type Props = {
|
||||
group: RecentGroup
|
||||
}
|
||||
|
||||
export function SaveGroupLocally({ group }: Props) {
|
||||
useEffect(() => {
|
||||
saveRecentGroup(group)
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
29
src/app/groups/create/page.tsx
Normal file
29
src/app/groups/create/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { GroupForm } from '@/components/group-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { createGroup } from '@/lib/api'
|
||||
import { groupFormSchema } from '@/lib/schemas'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function CreateGroupPage() {
|
||||
async function createGroupAction(values: unknown) {
|
||||
'use server'
|
||||
const groupFormValues = groupFormSchema.parse(values)
|
||||
const group = await createGroup(groupFormValues)
|
||||
redirect(`/groups/${group.id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="mb-4">
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href="/groups">
|
||||
<ChevronLeft className="w-4 h-4 mr-2" /> Back to recent groups
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<GroupForm onSubmit={createGroupAction} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
14
src/app/groups/page.tsx
Normal file
14
src/app/groups/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { RecentGroupList } from '@/app/groups/recent-group-list'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default async function GroupsPage() {
|
||||
return (
|
||||
<main>
|
||||
<Button asChild>
|
||||
<Link href="/groups/create">New group</Link>
|
||||
</Button>
|
||||
<RecentGroupList />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
43
src/app/groups/recent-group-list.tsx
Normal file
43
src/app/groups/recent-group-list.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
import { getRecentGroups } from '@/app/groups/recent-groups-helpers'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
|
||||
const recentGroupsSchema = z.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string(),
|
||||
}),
|
||||
)
|
||||
type RecentGroups = z.infer<typeof recentGroupsSchema>
|
||||
|
||||
type State = { status: 'pending' } | { status: 'success'; groups: RecentGroups }
|
||||
|
||||
export function RecentGroupList() {
|
||||
const [state, setState] = useState<State>({ status: 'pending' })
|
||||
|
||||
useEffect(() => {
|
||||
const groupsInStorage = getRecentGroups()
|
||||
setState({ status: 'success', groups: groupsInStorage })
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col gap-2 mt-2">
|
||||
{state.status === 'pending' ? (
|
||||
<li>
|
||||
<em>Loading recent groups…</em>
|
||||
</li>
|
||||
) : (
|
||||
state.groups.map(({ id, name }) => (
|
||||
<li key={id}>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/groups/${id}`}>{name}</Link>
|
||||
</Button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
30
src/app/groups/recent-groups-helpers.ts
Normal file
30
src/app/groups/recent-groups-helpers.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const recentGroupsSchema = z.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export type RecentGroups = z.infer<typeof recentGroupsSchema>
|
||||
export type RecentGroup = RecentGroups[number]
|
||||
|
||||
const STORAGE_KEY = 'recentGroups'
|
||||
|
||||
export function getRecentGroups() {
|
||||
const groupsInStorageJson = localStorage.getItem(STORAGE_KEY)
|
||||
const groupsInStorageRaw = groupsInStorageJson
|
||||
? JSON.parse(groupsInStorageJson)
|
||||
: []
|
||||
const parseResult = recentGroupsSchema.safeParse(groupsInStorageRaw)
|
||||
return parseResult.success ? parseResult.data : []
|
||||
}
|
||||
|
||||
export function saveRecentGroup(group: RecentGroup) {
|
||||
const recentGroups = getRecentGroups()
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([group, ...recentGroups.filter((rg) => rg.id !== group.id)]),
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Create Next App',
|
||||
description: 'Generated by create next app',
|
||||
@@ -16,7 +13,9 @@ export default function RootLayout({
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>{children}</body>
|
||||
<body>
|
||||
<div className="max-w-screen-md mx-auto p-4">{children}</div>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
115
src/app/page.tsx
115
src/app/page.tsx
@@ -1,113 +1,12 @@
|
||||
import Image from 'next/image'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function Home() {
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-between p-24">
|
||||
<div className="z-10 max-w-5xl w-full items-center justify-between font-mono text-sm lg:flex">
|
||||
<p className="fixed left-0 top-0 flex w-full justify-center border-b border-gray-300 bg-gradient-to-b from-zinc-200 pb-6 pt-8 backdrop-blur-2xl dark:border-neutral-800 dark:bg-zinc-800/30 dark:from-inherit lg:static lg:w-auto lg:rounded-xl lg:border lg:bg-gray-200 lg:p-4 lg:dark:bg-zinc-800/30">
|
||||
Get started by editing
|
||||
<code className="font-mono font-bold">src/app/page.tsx</code>
|
||||
</p>
|
||||
<div className="fixed bottom-0 left-0 flex h-48 w-full items-end justify-center bg-gradient-to-t from-white via-white dark:from-black dark:via-black lg:static lg:h-auto lg:w-auto lg:bg-none">
|
||||
<a
|
||||
className="pointer-events-none flex place-items-center gap-2 p-8 lg:pointer-events-auto lg:p-0"
|
||||
href="https://vercel.com?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
By{' '}
|
||||
<Image
|
||||
src="/vercel.svg"
|
||||
alt="Vercel Logo"
|
||||
className="dark:invert"
|
||||
width={100}
|
||||
height={24}
|
||||
priority
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex place-items-center before:absolute before:h-[300px] before:w-[480px] before:-translate-x-1/2 before:rounded-full before:bg-gradient-radial before:from-white before:to-transparent before:blur-2xl before:content-[''] after:absolute after:-z-20 after:h-[180px] after:w-[240px] after:translate-x-1/3 after:bg-gradient-conic after:from-sky-200 after:via-blue-200 after:blur-2xl after:content-[''] before:dark:bg-gradient-to-br before:dark:from-transparent before:dark:to-blue-700 before:dark:opacity-10 after:dark:from-sky-900 after:dark:via-[#0141ff] after:dark:opacity-40 before:lg:h-[360px] z-[-1]">
|
||||
<Image
|
||||
className="relative dark:drop-shadow-[0_0_0.3rem_#ffffff70] dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js Logo"
|
||||
width={180}
|
||||
height={37}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-32 grid text-center lg:max-w-5xl lg:w-full lg:mb-0 lg:grid-cols-4 lg:text-left">
|
||||
<a
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<h2 className={`mb-3 text-2xl font-semibold`}>
|
||||
Docs{' '}
|
||||
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
|
||||
->
|
||||
</span>
|
||||
</h2>
|
||||
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
|
||||
Find in-depth information about Next.js features and API.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<h2 className={`mb-3 text-2xl font-semibold`}>
|
||||
Learn{' '}
|
||||
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
|
||||
->
|
||||
</span>
|
||||
</h2>
|
||||
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
|
||||
Learn about Next.js in an interactive course with quizzes!
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<h2 className={`mb-3 text-2xl font-semibold`}>
|
||||
Templates{' '}
|
||||
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
|
||||
->
|
||||
</span>
|
||||
</h2>
|
||||
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
|
||||
Explore the Next.js 13 playground.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<h2 className={`mb-3 text-2xl font-semibold`}>
|
||||
Deploy{' '}
|
||||
<span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
|
||||
->
|
||||
</span>
|
||||
</h2>
|
||||
<p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
|
||||
Instantly deploy your Next.js site to a shareable URL with Vercel.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
<main>
|
||||
<Button asChild variant="link">
|
||||
<Link href="/groups">My groups</Link>
|
||||
</Button>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user