Compare commits
59 Commits
Author | SHA1 | Date | |
---|---|---|---|
93c0348564 | |||
14fa130ce6 | |||
a609300688 | |||
7304ce1023 | |||
4b554cdb37 | |||
a507fe84a4 | |||
54454aad6d | |||
325a561b1c | |||
533398cdb3 | |||
29b09848b0 | |||
013c80791e | |||
753f09da32 | |||
72156c0778 | |||
f52d6d6004 | |||
aa209bc0a8 | |||
a065e6b592 | |||
ec47559cd2 | |||
6271e5e033 | |||
067c31d745 | |||
c90eedcabd | |||
14b14f9f26 | |||
2c5fcbcbdb | |||
7590e54392 | |||
f0125fd472 | |||
4f5a9c429c | |||
762ce3c90d | |||
d7a835987e | |||
8a3d62a43d | |||
0bab29653a | |||
dbdb5356e2 | |||
e70ff89833 | |||
2f158c49d6 | |||
ef08c000ec | |||
a61a65aab0 | |||
f266c590f5 | |||
fb35d90d9e | |||
0bb5cdc760 | |||
1a9c96381e | |||
e7b6bb9d9c | |||
4914e314c9 | |||
cf33077aa0 | |||
2149cda2e4 | |||
0df891fc3b | |||
0a4f5017ea | |||
0d7b8a3db7 | |||
be98abb776 | |||
3faa9fe73b | |||
d4e7ee8cf8 | |||
890595f24c | |||
dded3e821b | |||
fcfc848719 | |||
02769e3e15 | |||
5209923a5e | |||
8b98ff75cf | |||
a3586f3059 | |||
d7ebcb8558 | |||
a04ceb616c | |||
f85c15f380 | |||
466b89e7af |
22
README.md
22
README.md
|
@ -1,18 +1,34 @@
|
|||
This is my attempt at a ecommerce site selling wine.
|
||||
|
||||
Created with t3-app using:
|
||||
Stack:
|
||||
|
||||
- nextjs
|
||||
- tailwind
|
||||
- drizzle
|
||||
- postgres
|
||||
- zod
|
||||
- zustand
|
||||
- clerk
|
||||
|
||||
TODO (in no particular order for now):
|
||||
|
||||
- [] Top nav
|
||||
- [] State management
|
||||
- [] Filter Menu
|
||||
|
||||
- [] Populate filter menues
|
||||
- [] Search bar
|
||||
- [] Filter logic
|
||||
|
||||
- [x] define (and draw?) db schema (initial)
|
||||
- [] side product nav
|
||||
- [] define final schema
|
||||
|
||||
- [] region
|
||||
- [] country
|
||||
- [] price
|
||||
|
||||
- [] company burger/accordian
|
||||
- [] product cards
|
||||
- [] cart
|
||||
- [] auth & login
|
||||
- [] user profile page
|
||||
- [] admin dashboad
|
||||
|
|
17
components.json
Normal file
17
components.json
Normal file
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": false,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "~/components",
|
||||
"utils": "~/lib/utils"
|
||||
}
|
||||
}
|
1868
package-lock.json
generated
1868
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
|
@ -12,15 +12,25 @@
|
|||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-navigation-menu": "^1.1.4",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@t3-oss/env-nextjs": "^0.10.1",
|
||||
"@types/react": "^18.2.57",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@vercel/postgres": "^0.8.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.29.4",
|
||||
"geist": "^1.3.0",
|
||||
"next": "^15.0.0-rc.0",
|
||||
"immer": "^10.1.1",
|
||||
"lucide-react": "^0.379.0",
|
||||
"next": "^14.2.3",
|
||||
"postgres": "^3.4.3",
|
||||
"react": "^19.0.0-rc-935180c7e0-20240524",
|
||||
"react-dom": "^19.0.0-rc-935180c7e0-20240524",
|
||||
"zod": "^3.23.8"
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/eslint": "^8.56.2",
|
||||
|
|
27
src/app/App.tsx
Normal file
27
src/app/App.tsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
import WineList from "./_components/WineList";
|
||||
import FilterStatus from "./_components/FilterState";
|
||||
import CreateCountry from "./_components/admin/CreateCountry";
|
||||
import AllCountries from "./_components/AllCountries";
|
||||
import CreateRegion from "./_components/admin/CreateRegion";
|
||||
import AllRegions from "./_components/AllRegions";
|
||||
import CreateSubRegion from "./_components/admin/CreateSubRegion";
|
||||
import AllSubRegions from "./_components/AllSubRegions";
|
||||
import CreateProducer from "./_components/admin/CreateProducer";
|
||||
import AllProducers from "./_components/allProducers";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="container flex w-full flex-col justify-center">
|
||||
<FilterStatus />
|
||||
<WineList />
|
||||
<CreateCountry />
|
||||
<AllCountries />
|
||||
<CreateRegion />
|
||||
<AllRegions />
|
||||
<CreateSubRegion />
|
||||
<AllSubRegions />
|
||||
<CreateProducer />
|
||||
<AllProducers />
|
||||
</div>
|
||||
);
|
||||
}
|
22
src/app/_components/AllCountries.tsx
Normal file
22
src/app/_components/AllCountries.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { Delete } from "lucide-react";
|
||||
import { deleteCountry } from "~/server/actions/deleteCountry";
|
||||
import getAllCountries from "~/server/actions/getAllCountries";
|
||||
|
||||
export default async function AllCountries() {
|
||||
const countries = await getAllCountries();
|
||||
return (
|
||||
<div className="pt-4">
|
||||
<h1 className="text-2xl">All Countries:</h1>
|
||||
{countries.map((country) => (
|
||||
<div key={country.id} className="flex gap-1">
|
||||
<p>{country.name}</p>
|
||||
<form action={deleteCountry.bind(null, country.id)}>
|
||||
<button>
|
||||
<Delete />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
24
src/app/_components/AllRegions.tsx
Normal file
24
src/app/_components/AllRegions.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { Delete } from "lucide-react";
|
||||
import deleteRegion from "~/server/actions/deleteRegion";
|
||||
import getAllCountries from "~/server/actions/getAllCountries";
|
||||
import getAllRegions from "~/server/actions/getAllRegions";
|
||||
|
||||
export default async function AllRegions() {
|
||||
const allRegions = await getAllRegions();
|
||||
const allCountries = await getAllCountries();
|
||||
return (
|
||||
<div className="pt-4">
|
||||
<h1 className="text-2xl">All Regions:</h1>
|
||||
{allRegions.map((region) => (
|
||||
<div key={region.id} className="flex gap-1">
|
||||
<p>{region.name}</p>
|
||||
<form action={deleteRegion.bind(null, region.id)}>
|
||||
<button>
|
||||
<Delete />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
22
src/app/_components/AllSubRegions.tsx
Normal file
22
src/app/_components/AllSubRegions.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { Delete } from "lucide-react";
|
||||
import deleteSubRegion from "~/server/actions/deleteSubRegion";
|
||||
import getAllSubRegions from "~/server/actions/getAllSubRegions";
|
||||
|
||||
export default async function AllSubRegions() {
|
||||
const allSubRegions = await getAllSubRegions();
|
||||
return (
|
||||
<div className="pt-4">
|
||||
<h1 className="text-2xl">All Sub Regions:</h1>
|
||||
{allSubRegions.map((subRegion) => (
|
||||
<div key={subRegion.id} className="flex gap-1">
|
||||
<p>{subRegion.name}</p>
|
||||
<form action={deleteSubRegion.bind(null, subRegion.id)}>
|
||||
<button>
|
||||
<Delete />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
import FormCard from "./FormCard";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="container flex w-full flex-col justify-center">
|
||||
<h1>Yes hello</h1>
|
||||
<FormCard />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
"use client";
|
||||
import { useActionState, useEffect, useState } from "react";
|
||||
import { addWine } from "~/server/actions/addWine";
|
||||
import { getProducers } from "~/server/actions/allProducers";
|
||||
|
||||
async function fetchProducers() {
|
||||
const producers = await getProducers();
|
||||
return producers;
|
||||
}
|
||||
|
||||
type ProducersData = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
country: string;
|
||||
region: string;
|
||||
email: string;
|
||||
}[];
|
||||
|
||||
export default function CreateWine() {
|
||||
const [formState, formAction] = useActionState(addWine, {
|
||||
message: "",
|
||||
errors: undefined,
|
||||
fieldValues: {
|
||||
name: "",
|
||||
producer: "",
|
||||
},
|
||||
});
|
||||
const [producers, setProducers] = useState<ProducersData>([]);
|
||||
useEffect(() => {
|
||||
async function loadProducers() {
|
||||
const producersData = await fetchProducers();
|
||||
setProducers(producersData);
|
||||
}
|
||||
loadProducers();
|
||||
}, []);
|
||||
return (
|
||||
<div className="container flex flex-col gap-4">
|
||||
<form action={formAction}>
|
||||
<input className="border-2" type="text" name="name" />
|
||||
<select name="producer">
|
||||
{producers.map((producer, i) => (
|
||||
<option key={i} value={producer.id}>
|
||||
{producer.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit">Add wine</button>
|
||||
</form>
|
||||
{JSON.stringify(formState)}
|
||||
</div>
|
||||
);
|
||||
}
|
135
src/app/_components/FilterMenu.tsx
Normal file
135
src/app/_components/FilterMenu.tsx
Normal file
|
@ -0,0 +1,135 @@
|
|||
"use client";
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuList,
|
||||
NavigationMenuTrigger,
|
||||
} from "~/components/ui/navigation-menu";
|
||||
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { SliderTwoThumbs } from "~/components/ui/slider-two-thumbs";
|
||||
import { ChangeEvent, useState } from "react";
|
||||
import useFilterStore from "../store";
|
||||
|
||||
export default function Filtermenu() {
|
||||
const [minPrice, setMinPrice] = useState<number>(0);
|
||||
const [maxPrice, setMaxPrice] = useState<number>(9999);
|
||||
const [sliderValues, setSliderValues] = useState<[number, number]>([0, 9999]);
|
||||
const setStorePrice = useFilterStore((state) => state.setPrice);
|
||||
|
||||
const handleMinPriceChange = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
const value = Math.min(Number(e.target.value), maxPrice - 1);
|
||||
setMinPrice(value);
|
||||
setSliderValues([value, sliderValues[1]]);
|
||||
setStorePrice(minPrice, maxPrice);
|
||||
};
|
||||
|
||||
const handleMaxPriceChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = Math.max(Number(e.target.value), minPrice + 1);
|
||||
setMaxPrice(value);
|
||||
setSliderValues([sliderValues[0], value]);
|
||||
setStorePrice(minPrice, maxPrice);
|
||||
};
|
||||
|
||||
const handleSliderChange = (values: [number, number]) => {
|
||||
setSliderValues(values);
|
||||
setMinPrice(values[0]);
|
||||
setMaxPrice(values[1]);
|
||||
setStorePrice(values[0], values[1]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container flex justify-center gap-2">
|
||||
{/* TODO:
|
||||
Checkboxes instead of menuitems. Check boxes are added as badges under the search bar */}
|
||||
{/* Price filter */}
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger>Price</NavigationMenuTrigger>
|
||||
<NavigationMenuContent className="min-w- flex overflow-auto p-2">
|
||||
<Input
|
||||
value={minPrice}
|
||||
onChange={handleMinPriceChange}
|
||||
className="min-w-20"
|
||||
type="number"
|
||||
defaultValue={0}
|
||||
min={0}
|
||||
max={maxPrice - 1}
|
||||
/>
|
||||
<SliderTwoThumbs
|
||||
value={sliderValues}
|
||||
onValueChange={handleSliderChange}
|
||||
className="min-w-36"
|
||||
min={0}
|
||||
max={9999}
|
||||
defaultValue={[0, 9999]}
|
||||
/>
|
||||
<Input
|
||||
value={maxPrice}
|
||||
onChange={handleMaxPriceChange}
|
||||
className="min-w-20"
|
||||
type="number"
|
||||
defaultValue={9999}
|
||||
min={minPrice + 1}
|
||||
max={9999}
|
||||
/>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger>Type</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<p>Anything goes?</p>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
{/* Producer filter */}
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger>Producer</NavigationMenuTrigger>
|
||||
<NavigationMenuContent className="w-fit p-2">
|
||||
<Input placeholder="producer filter" />
|
||||
<ul>
|
||||
<li>checkboxes for producer filter</li>
|
||||
</ul>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
{/* Country Filter */}
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger>Producer</NavigationMenuTrigger>
|
||||
<NavigationMenuContent className="w-fit p-2">
|
||||
<ul>
|
||||
<li>ccountry filter</li>
|
||||
</ul>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
{/* Region Filter */}
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger>Region</NavigationMenuTrigger>
|
||||
<NavigationMenuContent className="w-fit p-2">
|
||||
<Input placeholder="producer filter" />
|
||||
<ul>
|
||||
<li>checkboxes for producer filter</li>
|
||||
</ul>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
12
src/app/_components/FilterState.tsx
Normal file
12
src/app/_components/FilterState.tsx
Normal file
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
import useFilterStore, { FilterState } from "../store";
|
||||
|
||||
export default function FilterStatus() {
|
||||
const filters = useFilterStore((state) => state.filters);
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-2xl">Filter state:</h1>
|
||||
{JSON.stringify(filters, null, 2)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
import CreateWine from "./CreateWine";
|
||||
|
||||
export default function FormCard() {
|
||||
return (
|
||||
<div className="mx-auto p-4 shadow-md">
|
||||
<h1>Add a new wine</h1>
|
||||
<CreateWine />
|
||||
</div>
|
||||
);
|
||||
}
|
22
src/app/_components/SearchBar.tsx
Normal file
22
src/app/_components/SearchBar.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
"use client";
|
||||
import { ChangeEvent } from "react";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import useFilterStore from "../store";
|
||||
|
||||
export default function SearchBar() {
|
||||
const setStoreSearchQuery = useFilterStore((state) => state.setSearchQuery);
|
||||
const { searchQuery } = useFilterStore((state) => state.filters);
|
||||
|
||||
function handleInput(e: ChangeEvent<HTMLInputElement>) {
|
||||
e.preventDefault();
|
||||
const newValue = e.target.value;
|
||||
setStoreSearchQuery(newValue);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onInput={handleInput}
|
||||
className="mx-auto max-w-xl focus-visible:ring-0"
|
||||
/>
|
||||
);
|
||||
}
|
12
src/app/_components/SubmitButton.tsx
Normal file
12
src/app/_components/SubmitButton.tsx
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { useFormStatus } from "react-dom";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
export default function SubmitButton(props: { text: string }) {
|
||||
const { text } = props;
|
||||
const { pending } = useFormStatus();
|
||||
return (
|
||||
<Button disabled={pending}>
|
||||
{pending ? `Adding ${text}` : `Add ${text}`}
|
||||
</Button>
|
||||
);
|
||||
}
|
23
src/app/_components/WineList.tsx
Normal file
23
src/app/_components/WineList.tsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
"use server";
|
||||
import { deleteCountry } from "~/server/actions/deleteCountry";
|
||||
import { db } from "~/server/db";
|
||||
|
||||
export default async function WineList() {
|
||||
const wines = await db.query.wines.findMany();
|
||||
return (
|
||||
<div className="pt-4">
|
||||
<h1 className="text-2xl">All wines:</h1>
|
||||
{wines ? (
|
||||
<>
|
||||
<ul>
|
||||
{wines.map((wine) => (
|
||||
<li key={wine.id}>{wine.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<p>There are no wines in the db.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
export default function WineName(props: { name: string }) {
|
||||
return <p>{props.name}</p>;
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
import getProducer from "~/server/actions/getProducer";
|
||||
|
||||
export default async function WineProducer(props: { id: string }) {
|
||||
const { id } = props;
|
||||
|
||||
// Validate the id before making the database call
|
||||
if (!id) {
|
||||
return <p>Invalid producer ID</p>;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getProducer(id);
|
||||
return !result ? <p>Unable to retrieve producer</p> : <p>{result?.name}</p>;
|
||||
} catch (error) {
|
||||
console.error("Error fetching producer:", error);
|
||||
return <p>Error fetching producer</p>;
|
||||
}
|
||||
}
|
||||
|
||||
// Utility function to validate UUID
|
||||
function isValidUUID(uuid: string) {
|
||||
const uuidRegex =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
return uuidRegex.test(uuid);
|
||||
}
|
10
src/app/_components/admin/CreateCountry.tsx
Normal file
10
src/app/_components/admin/CreateCountry.tsx
Normal file
|
@ -0,0 +1,10 @@
|
|||
import CreateCountryForm from "./CreateCountryForm";
|
||||
|
||||
export default function CreateCountry() {
|
||||
return (
|
||||
<div className="container flex w-full flex-col justify-center py-4">
|
||||
<h1 className="pt-4 text-2xl">Fill the form to create a new country</h1>
|
||||
<CreateCountryForm />
|
||||
</div>
|
||||
);
|
||||
}
|
49
src/app/_components/admin/CreateCountryForm.tsx
Normal file
49
src/app/_components/admin/CreateCountryForm.tsx
Normal file
|
@ -0,0 +1,49 @@
|
|||
"use client";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { addCountry } from "~/server/actions/addCountry";
|
||||
import SubmitButton from "../SubmitButton";
|
||||
import { useFormState } from "react-dom";
|
||||
import { useEffect, useRef } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Check, CircleX } from "lucide-react";
|
||||
|
||||
export default function CreateCountryForm() {
|
||||
const [formState, formAction] = useFormState(addCountry, {
|
||||
message: "",
|
||||
errors: undefined,
|
||||
fieldValues: {
|
||||
name: "",
|
||||
},
|
||||
});
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
useEffect(() => {
|
||||
if (formState.message === "success") {
|
||||
formRef.current?.reset();
|
||||
}
|
||||
}, [formState.message]);
|
||||
return (
|
||||
<form ref={formRef} action={formAction} className="flex flex-col gap-2">
|
||||
<div className="flex max-w-3xl items-center gap-2">
|
||||
<Input
|
||||
name="name"
|
||||
id="name"
|
||||
className={clsx({ "border-red-500": formState.errors?.name })}
|
||||
/>
|
||||
{formState.message !== "" && !formState.errors?.name ? (
|
||||
<Check className="text-green-500" />
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{formState.errors?.name ? (
|
||||
<div className="flex min-w-96 items-center gap-1 text-red-500">
|
||||
<CircleX />
|
||||
<span className="text-sm">{formState.errors?.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
<SubmitButton text={"Country"} />
|
||||
</form>
|
||||
);
|
||||
}
|
13
src/app/_components/admin/CreateProducer.tsx
Normal file
13
src/app/_components/admin/CreateProducer.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
"use server";
|
||||
import getAllCountries from "~/server/actions/getAllCountries";
|
||||
import CreateProducerForm from "./CreateProducerForm";
|
||||
|
||||
export default async function CreateProducer() {
|
||||
const allCountries = await getAllCountries();
|
||||
return (
|
||||
<div className="container flex w-full flex-col justify-center py-4">
|
||||
<h1 className="pt-4 text-2xl">Fill the form to create a new producer</h1>
|
||||
<CreateProducerForm allCountries={allCountries} />
|
||||
</div>
|
||||
);
|
||||
}
|
108
src/app/_components/admin/CreateProducerForm.tsx
Normal file
108
src/app/_components/admin/CreateProducerForm.tsx
Normal file
|
@ -0,0 +1,108 @@
|
|||
"use client";
|
||||
import clsx from "clsx";
|
||||
import { Check, CircleX } from "lucide-react";
|
||||
import { useFormState } from "react-dom";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { addProducer } from "~/server/actions/addProducer";
|
||||
import SubmitButton from "../SubmitButton";
|
||||
|
||||
type Country = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export default function CreateProducerForm(props: { allCountries: Country[] }) {
|
||||
const { allCountries } = props;
|
||||
const [formState, formAction] = useFormState(addProducer, {
|
||||
message: "",
|
||||
errors: undefined,
|
||||
fieldValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
imageUrl: "",
|
||||
countryId: "",
|
||||
},
|
||||
});
|
||||
return (
|
||||
<form action={formAction}>
|
||||
<Input
|
||||
name="name"
|
||||
id="name"
|
||||
placeholder="Enter the name of the producer..."
|
||||
className={clsx({ "border-red-500": formState.errors?.name })}
|
||||
/>
|
||||
{formState.message !== "" && !formState.errors?.name ? (
|
||||
<Check className="text-green-500" />
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{formState.errors?.name ? (
|
||||
<div className="flex min-w-96 items-center gap-1 text-red-500">
|
||||
<CircleX />
|
||||
<span className="text-sm">{formState.errors?.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<Textarea
|
||||
name="description"
|
||||
id="description"
|
||||
placeholder="Enter a description of the producer..."
|
||||
className={clsx({ "border-red-500": formState.errors?.description })}
|
||||
/>
|
||||
{formState.message !== "" && !formState.errors?.description ? (
|
||||
<Check className="text-green-500" />
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{formState.errors?.description ? (
|
||||
<div className="flex min-w-96 items-center gap-1 text-red-500">
|
||||
<CircleX />
|
||||
<span className="text-sm">{formState.errors?.description}</span>
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<Input
|
||||
name="imageUrl"
|
||||
id="imageUrl"
|
||||
placeholder="Image URL"
|
||||
className={clsx({ "border-red-500": formState.errors?.imageUrl })}
|
||||
/>
|
||||
{formState.message !== "" && !formState.errors?.imageUrl ? (
|
||||
<Check className="text-green-500" />
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{formState.errors?.imageUrl ? (
|
||||
<div className="flex min-w-96 items-center gap-1 text-red-500">
|
||||
<CircleX />
|
||||
<span className="text-sm">{formState.errors?.imageUrl}</span>
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<Select name="countryId">
|
||||
<SelectTrigger className={`w-[180px] `}>
|
||||
<SelectValue placeholder="select country" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allCountries.map((country) => (
|
||||
<SelectItem key={country.id} value={country.id}>
|
||||
{country.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SubmitButton text={"producer"} />
|
||||
</form>
|
||||
);
|
||||
}
|
13
src/app/_components/admin/CreateRegion.tsx
Normal file
13
src/app/_components/admin/CreateRegion.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
"use server";
|
||||
import CreateRegionForm from "./CreateRegionForm";
|
||||
import getAllCountries from "~/server/actions/getAllCountries";
|
||||
|
||||
export default async function CreateRegion() {
|
||||
const allCountries = await getAllCountries();
|
||||
return (
|
||||
<div className="container flex w-full flex-col justify-center py-4">
|
||||
<h1 className="pt-4 text-2xl">Fill the form to create a new region</h1>
|
||||
<CreateRegionForm countries={allCountries} />
|
||||
</div>
|
||||
);
|
||||
}
|
91
src/app/_components/admin/CreateRegionForm.tsx
Normal file
91
src/app/_components/admin/CreateRegionForm.tsx
Normal file
|
@ -0,0 +1,91 @@
|
|||
"use client";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import SubmitButton from "../SubmitButton";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { addRegion } from "~/server/actions/addRegion";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { useFormState } from "react-dom";
|
||||
import clsx from "clsx";
|
||||
import { Check, CircleX } from "lucide-react";
|
||||
|
||||
interface Country {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default function CreateRegionForm(props: { countries: Country[] }) {
|
||||
const [formState, formAction] = useFormState(addRegion, {
|
||||
message: "",
|
||||
errors: undefined,
|
||||
fieldValues: {
|
||||
name: "",
|
||||
countryId: "",
|
||||
},
|
||||
});
|
||||
const { countries } = props;
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
useEffect(() => {
|
||||
if (formState.message === "success") {
|
||||
formRef.current?.reset();
|
||||
}
|
||||
}, [formState.message]);
|
||||
return (
|
||||
<form ref={formRef} action={formAction} className="flex flex-col gap-2">
|
||||
<div className="flex max-w-3xl items-center gap-2">
|
||||
<Input
|
||||
name="name"
|
||||
id="name"
|
||||
placeholder="Name"
|
||||
className={`${clsx({ "border-red-500": formState.errors?.name })}`}
|
||||
/>
|
||||
{formState.message === "success" && !formState.errors?.name ? (
|
||||
<Check className="text-green-500" />
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{formState.errors?.name ? (
|
||||
<div className="flex min-w-40 items-center gap-1 text-red-500">
|
||||
<CircleX className="text-red-500" />
|
||||
<span className="text-sm">{formState.errors?.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
<div className="flex max-w-3xl items-center gap-2">
|
||||
<Select name="country">
|
||||
<SelectTrigger
|
||||
className={`w-[180px] ${clsx({ "border-red-500": formState.errors?.countryId })}`}
|
||||
>
|
||||
<SelectValue placeholder="Country" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{countries.map((country) => (
|
||||
<SelectItem key={country.id} value={country.id}>
|
||||
{country.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formState.message !== "" && !formState.errors?.countryId ? (
|
||||
<Check className="text-green-500" />
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{formState.errors?.countryId ? (
|
||||
<CircleX className="text-red-500" />
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SubmitButton text={"Region"} />
|
||||
</form>
|
||||
);
|
||||
}
|
21
src/app/_components/admin/CreateSubRegion.tsx
Normal file
21
src/app/_components/admin/CreateSubRegion.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
"use server";
|
||||
import getAllRegions from "~/server/actions/getAllRegions";
|
||||
import CreateSubRegionForm from "./CreateSubRegionForm";
|
||||
import getAllCountries from "~/server/actions/getAllCountries";
|
||||
|
||||
const allRegions = await getAllRegions();
|
||||
const allCountries = await getAllCountries();
|
||||
|
||||
export default async function CreateSubRegion() {
|
||||
return (
|
||||
<div className="container flex w-full flex-col justify-center py-4">
|
||||
<h1 className="pt-4 text-2xl">
|
||||
Fill the form to create a new Sub Region
|
||||
</h1>
|
||||
<CreateSubRegionForm
|
||||
allRegions={allRegions}
|
||||
allCountries={allCountries}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
136
src/app/_components/admin/CreateSubRegionForm.tsx
Normal file
136
src/app/_components/admin/CreateSubRegionForm.tsx
Normal file
|
@ -0,0 +1,136 @@
|
|||
"use client";
|
||||
import clsx from "clsx";
|
||||
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
||||
import { useFormState } from "react-dom";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { addSubRegion } from "~/server/actions/addSubRegion";
|
||||
import SubmitButton from "../SubmitButton";
|
||||
import { Check, CircleX } from "lucide-react";
|
||||
|
||||
type Region = {
|
||||
id: string;
|
||||
name: string;
|
||||
countryId: string;
|
||||
};
|
||||
type Country = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export default function CreateSubRegionForm(props: {
|
||||
allRegions: Region[];
|
||||
allCountries: Country[];
|
||||
}) {
|
||||
const { allRegions, allCountries } = props;
|
||||
const [selectCountryId, setSelectCountryId] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [selectCountryRegions, setSelectCountryRegions] = useState<Region[]>(
|
||||
[],
|
||||
);
|
||||
const [formState, formActions] = useFormState(addSubRegion, {
|
||||
message: "",
|
||||
errors: undefined,
|
||||
fieldValues: {
|
||||
name: "",
|
||||
regionId: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (selectCountryId) {
|
||||
const regions = allRegions.filter(
|
||||
(region) => region.countryId === selectCountryId,
|
||||
);
|
||||
setSelectCountryRegions(regions);
|
||||
}
|
||||
}, [selectCountryId]);
|
||||
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
useEffect(() => {
|
||||
if (formState.message === "success") {
|
||||
formRef.current?.reset();
|
||||
}
|
||||
}, [formState.message]);
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* country selector */}
|
||||
<Select
|
||||
name="country"
|
||||
onValueChange={(value) => setSelectCountryId(value)}
|
||||
>
|
||||
<SelectTrigger className={`w-[180px] `}>
|
||||
<SelectValue placeholder="select country" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allCountries.map((country) => (
|
||||
<SelectItem key={country.id} value={country.id}>
|
||||
{country.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<form ref={formRef} action={formActions}>
|
||||
{/* region selector */}
|
||||
<div className="flex max-w-3xl items-center gap-2">
|
||||
<Select name="regionId">
|
||||
<SelectTrigger
|
||||
className={`w-[180px] ${clsx({ "border-red-500": formState.errors?.regionId })}`}
|
||||
>
|
||||
<SelectValue placeholder="select region" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectCountryRegions.map((region) => (
|
||||
<SelectItem key={region.id} value={region.id}>
|
||||
{region.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formState.message === "success" && !formState.errors?.regionId ? (
|
||||
<Check className="text-green-500" />
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{formState.errors?.regionId ? (
|
||||
<div className="flex min-w-40 items-center gap-1 text-red-500">
|
||||
<CircleX className="text-red-500" />
|
||||
<span className="text-sm">{formState.errors?.regionId}</span>
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
<div className="flex max-w-3xl items-center gap-2">
|
||||
<Input
|
||||
name="name"
|
||||
id="name"
|
||||
placeholder="Name the sub region"
|
||||
className={`${clsx({ "border-red-500": formState.errors?.name })}`}
|
||||
/>
|
||||
{formState.message === "success" && !formState.errors?.name ? (
|
||||
<Check className="text-green-500" />
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{formState.errors?.name ? (
|
||||
<div className="flex min-w-40 items-center gap-1 text-red-500">
|
||||
<CircleX className="text-red-500" />
|
||||
<span className="text-sm">{formState.errors?.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
<SubmitButton text={"sub region"} />
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
22
src/app/_components/allProducers.tsx
Normal file
22
src/app/_components/allProducers.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { Delete } from "lucide-react";
|
||||
import deleteProducer from "~/server/actions/deleteProducer";
|
||||
import getAllProducers from "~/server/actions/getAllProducers";
|
||||
|
||||
export default async function AllProducers() {
|
||||
const allProducers = await getAllProducers();
|
||||
return (
|
||||
<div className="pt-4">
|
||||
<h1 className="text-2xl">All Producers:</h1>
|
||||
{allProducers.map((producer) => (
|
||||
<div key={producer.id} className="flex gap-1">
|
||||
<p>{producer.name}</p>
|
||||
<form action={deleteProducer.bind(null, producer.id)}>
|
||||
<button>
|
||||
<Delete />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,6 +1,15 @@
|
|||
import "~/styles/globals.css";
|
||||
|
||||
import { GeistSans } from "geist/font/sans";
|
||||
import { Inter as FontSans } from "next/font/google";
|
||||
import { cn } from "~/lib/utils";
|
||||
import TopNav from "./_components/FilterMenu";
|
||||
import Filtermenu from "./_components/FilterMenu";
|
||||
import SearchBar from "./_components/SearchBar";
|
||||
|
||||
const fontSans = FontSans({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
});
|
||||
|
||||
export const metadata = {
|
||||
title: "Create T3 App",
|
||||
|
@ -14,8 +23,19 @@ export default function RootLayout({
|
|||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className={`${GeistSans.variable}`}>
|
||||
<body>{children}</body>
|
||||
<html lang="en">
|
||||
<body
|
||||
className={cn(
|
||||
"bg-background min-h-screen font-sans antialiased",
|
||||
fontSans.variable,
|
||||
)}
|
||||
>
|
||||
<div className="container pt-12">
|
||||
<SearchBar />
|
||||
<Filtermenu />
|
||||
</div>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import App from "./_components/App";
|
||||
import App from "./App";
|
||||
|
||||
export default async function HomePage() {
|
||||
return (
|
||||
<main>
|
||||
<main className="w-full">
|
||||
<App />
|
||||
</main>
|
||||
);
|
||||
|
|
163
src/app/store.ts
Normal file
163
src/app/store.ts
Normal file
|
@ -0,0 +1,163 @@
|
|||
import { create } from "zustand";
|
||||
import { produce } from "immer";
|
||||
import { persist, createJSONStorage } from "zustand/middleware";
|
||||
|
||||
export interface FilterState {
|
||||
filters: Filters;
|
||||
}
|
||||
|
||||
interface Filters {
|
||||
searchQuery: string;
|
||||
regions: string[];
|
||||
countries: string[];
|
||||
producers: string[];
|
||||
price: Price;
|
||||
type: WineType;
|
||||
}
|
||||
|
||||
interface Price {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
interface WineType {
|
||||
sparkling: boolean;
|
||||
white: boolean;
|
||||
red: boolean;
|
||||
sweet: boolean;
|
||||
other: boolean;
|
||||
}
|
||||
|
||||
type FilterActions = {
|
||||
setSearchQuery: (searchQuery: string) => void;
|
||||
addRegion: (region: string) => void;
|
||||
removeRegion: (region: string) => void;
|
||||
addCountry: (country: string) => void;
|
||||
removeCountry: (country: string) => void;
|
||||
addProducer: (producer: string) => void;
|
||||
removeProducer: (producer: string) => void;
|
||||
setPrice: (min: number, max: number) => void;
|
||||
setType: (type: keyof WineType, value: boolean) => void;
|
||||
resetFilters: () => void;
|
||||
};
|
||||
|
||||
const initialFilters: Filters = {
|
||||
searchQuery: "",
|
||||
regions: [],
|
||||
countries: [],
|
||||
producers: [],
|
||||
price: {
|
||||
min: 0,
|
||||
max: 9999,
|
||||
},
|
||||
type: {
|
||||
sparkling: false,
|
||||
white: false,
|
||||
red: false,
|
||||
sweet: false,
|
||||
other: false,
|
||||
},
|
||||
};
|
||||
|
||||
const useFilterStore = create<FilterState & FilterActions>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
filters: {
|
||||
searchQuery: "",
|
||||
regions: [],
|
||||
countries: [],
|
||||
producers: [],
|
||||
price: {
|
||||
min: 0,
|
||||
max: 9999,
|
||||
},
|
||||
type: {
|
||||
sparkling: false,
|
||||
white: false,
|
||||
red: false,
|
||||
sweet: false,
|
||||
other: false,
|
||||
},
|
||||
},
|
||||
setSearchQuery: (searchQuery: string) =>
|
||||
set(
|
||||
produce((state: FilterState) => {
|
||||
state.filters.searchQuery = searchQuery;
|
||||
}),
|
||||
),
|
||||
addRegion: (region) =>
|
||||
set(
|
||||
produce((state: FilterState) => {
|
||||
if (!state.filters.regions.includes(region)) {
|
||||
state.filters.regions.push(region);
|
||||
}
|
||||
}),
|
||||
),
|
||||
removeRegion: (region) =>
|
||||
set(
|
||||
produce((state: FilterState) => {
|
||||
state.filters.regions = state.filters.regions.filter(
|
||||
(r) => r !== region,
|
||||
);
|
||||
}),
|
||||
),
|
||||
addCountry: (country) =>
|
||||
set(
|
||||
produce((state: FilterState) => {
|
||||
if (!state.filters.countries.includes(country)) {
|
||||
state.filters.countries.push(country);
|
||||
}
|
||||
}),
|
||||
),
|
||||
removeCountry: (country) =>
|
||||
set(
|
||||
produce((state: FilterState) => {
|
||||
state.filters.countries = state.filters.countries.filter(
|
||||
(c) => c !== country,
|
||||
);
|
||||
}),
|
||||
),
|
||||
addProducer: (producer) =>
|
||||
set(
|
||||
produce((state: FilterState) => {
|
||||
if (!state.filters.producers.includes(producer)) {
|
||||
state.filters.producers.push(producer);
|
||||
}
|
||||
}),
|
||||
),
|
||||
removeProducer: (producer) =>
|
||||
set(
|
||||
produce((state: FilterState) => {
|
||||
state.filters.producers = state.filters.producers.filter(
|
||||
(p) => p !== producer,
|
||||
);
|
||||
}),
|
||||
),
|
||||
setPrice: (min, max) =>
|
||||
set(
|
||||
produce((state: FilterState) => {
|
||||
state.filters.price.min = min;
|
||||
state.filters.price.max = max;
|
||||
}),
|
||||
),
|
||||
setType: (type, value) =>
|
||||
set(
|
||||
produce((state: FilterState) => {
|
||||
state.filters.type[type] = value;
|
||||
}),
|
||||
),
|
||||
resetFilters: () =>
|
||||
set(
|
||||
produce((state: FilterState) => {
|
||||
state.filters = initialFilters;
|
||||
}),
|
||||
),
|
||||
}),
|
||||
{
|
||||
name: "filter-storage", // name of the item in the storage (must be unique)
|
||||
storage: createJSONStorage(() => sessionStorage), // (optional) by default, 'localStorage' is used
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export default useFilterStore;
|
59
src/components/ui/alert.tsx
Normal file
59
src/components/ui/alert.tsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border border-slate-200 p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-slate-950 dark:border-slate-800 dark:[&>svg]:text-slate-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-white text-slate-950 dark:bg-slate-950 dark:text-slate-50",
|
||||
destructive:
|
||||
"border-red-500/50 text-red-500 dark:border-red-500 [&>svg]:text-red-500 dark:border-red-900/50 dark:text-red-900 dark:dark:border-red-900 dark:[&>svg]:text-red-900",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
56
src/components/ui/button.tsx
Normal file
56
src/components/ui/button.tsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-slate-950 dark:focus-visible:ring-slate-300",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-slate-900 text-slate-50 hover:bg-slate-900/90 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-50/90",
|
||||
destructive:
|
||||
"bg-red-500 text-slate-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-slate-50 dark:hover:bg-red-900/90",
|
||||
outline:
|
||||
"border border-slate-200 bg-white hover:bg-slate-100 hover:text-slate-900 dark:border-slate-800 dark:bg-slate-950 dark:hover:bg-slate-800 dark:hover:text-slate-50",
|
||||
secondary:
|
||||
"bg-slate-100 text-slate-900 hover:bg-slate-100/80 dark:bg-slate-800 dark:text-slate-50 dark:hover:bg-slate-800/80",
|
||||
ghost: "hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-slate-50",
|
||||
link: "text-slate-900 underline-offset-4 hover:underline dark:text-slate-50",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
25
src/components/ui/input.tsx
Normal file
25
src/components/ui/input.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-800 dark:bg-slate-950 dark:ring-offset-slate-950 dark:placeholder:text-slate-400 dark:focus-visible:ring-slate-300",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
128
src/components/ui/navigation-menu.tsx
Normal file
128
src/components/ui/navigation-menu.tsx
Normal file
|
@ -0,0 +1,128 @@
|
|||
import * as React from "react"
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const NavigationMenu = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-10 flex max-w-max flex-1 items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<NavigationMenuViewport />
|
||||
</NavigationMenuPrimitive.Root>
|
||||
))
|
||||
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
|
||||
|
||||
const NavigationMenuList = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center space-x-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
|
||||
|
||||
const NavigationMenuItem = NavigationMenuPrimitive.Item
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-white px-4 py-2 text-sm font-medium transition-colors hover:bg-slate-100 hover:text-slate-900 focus:bg-slate-100 focus:text-slate-900 focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-slate-100/50 data-[state=open]:bg-slate-100/50 dark:bg-slate-950 dark:hover:bg-slate-800 dark:hover:text-slate-50 dark:focus:bg-slate-800 dark:focus:text-slate-50 dark:data-[active]:bg-slate-800/50 dark:data-[state=open]:bg-slate-800/50"
|
||||
)
|
||||
|
||||
const NavigationMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{""}
|
||||
<ChevronDown
|
||||
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
))
|
||||
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
|
||||
|
||||
const NavigationMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
|
||||
|
||||
const NavigationMenuLink = NavigationMenuPrimitive.Link
|
||||
|
||||
const NavigationMenuViewport = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className={cn("absolute left-0 top-full flex justify-center")}>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
className={cn(
|
||||
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border border-slate-200 bg-white text-slate-950 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)] dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
NavigationMenuViewport.displayName =
|
||||
NavigationMenuPrimitive.Viewport.displayName
|
||||
|
||||
const NavigationMenuIndicator = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-slate-200 shadow-md dark:bg-slate-800" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
))
|
||||
NavigationMenuIndicator.displayName =
|
||||
NavigationMenuPrimitive.Indicator.displayName
|
||||
|
||||
export {
|
||||
navigationMenuTriggerStyle,
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
}
|
160
src/components/ui/select.tsx
Normal file
160
src/components/ui/select.tsx
Normal file
|
@ -0,0 +1,160 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-slate-200 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-slate-950 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 dark:border-slate-800 dark:bg-slate-950 dark:ring-offset-slate-950 dark:placeholder:text-slate-400 dark:focus:ring-slate-300",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-slate-200 bg-white text-slate-950 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-slate-100 focus:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-slate-800 dark:focus:text-slate-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-slate-100 dark:bg-slate-800", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
29
src/components/ui/slider-two-thumbs.tsx
Normal file
29
src/components/ui/slider-two-thumbs.tsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const SliderTwoThumbs = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-slate-900 dark:bg-slate-50" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-slate-900 bg-white ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:border-slate-50 dark:bg-slate-950 dark:ring-offset-slate-950 dark:focus-visible:ring-slate-300" />
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-slate-900 bg-white ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:border-slate-50 dark:bg-slate-950 dark:ring-offset-slate-950 dark:focus-visible:ring-slate-300" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
SliderTwoThumbs.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { SliderTwoThumbs };
|
24
src/components/ui/textarea.tsx
Normal file
24
src/components/ui/textarea.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-800 dark:bg-slate-950 dark:ring-offset-slate-950 dark:placeholder:text-slate-400 dark:focus-visible:ring-slate-300",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
55
src/server/actions/addCountry.ts
Normal file
55
src/server/actions/addCountry.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { db } from "../db";
|
||||
import { countries } from "../db/schema";
|
||||
import { ZodError, z } from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export async function addCountry(prevstate: any, formData: FormData) {
|
||||
//assign formdaata to variables.
|
||||
const name = (formData.get("name") as string).toLowerCase();
|
||||
//check if country already exists
|
||||
const exists = await db
|
||||
.select({ name: countries.name })
|
||||
.from(countries)
|
||||
.where(eq(countries.name, name));
|
||||
|
||||
//Define the schema for the form data
|
||||
const schema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, { message: "Name is required" })
|
||||
.refine(() => !exists[0], { message: `${name} already exists` }),
|
||||
});
|
||||
//Parse the form data using the schema for validation, and check if the name already exists
|
||||
try {
|
||||
schema.parse({
|
||||
name,
|
||||
});
|
||||
//If the name doesn't exist, add the country to the database abd revalidate the page
|
||||
await db.insert(countries).values({ name });
|
||||
revalidatePath("/");
|
||||
//Return a success message
|
||||
return {
|
||||
message: "success",
|
||||
errors: undefined,
|
||||
fieldValues: {
|
||||
name: "",
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const zodError = error as ZodError;
|
||||
const errorMap = zodError.flatten().fieldErrors;
|
||||
//Return an error object with the field values and errors.
|
||||
return {
|
||||
message: "error",
|
||||
errors: {
|
||||
name: errorMap["name"]?.[0] ?? "",
|
||||
},
|
||||
fieldValues: {
|
||||
name,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
77
src/server/actions/addProducer.ts
Normal file
77
src/server/actions/addProducer.ts
Normal file
|
@ -0,0 +1,77 @@
|
|||
"use server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../db";
|
||||
import { producers } from "../db/schema";
|
||||
import { ZodError, z } from "zod";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export const addProducer = async (prevstate: any, formData: FormData) => {
|
||||
//assign formdaata to variables.
|
||||
const name = (formData.get("name") as string).toLowerCase();
|
||||
const description = (formData.get("description") as string).toLowerCase();
|
||||
const imageUrl = (formData.get("imageUrl") as string).toLowerCase();
|
||||
const countryId = formData.get("countryId") as string;
|
||||
|
||||
//check if producer already exists in country
|
||||
const exists = await db
|
||||
.select({ name: producers.name })
|
||||
.from(producers)
|
||||
.where(eq(producers.name, name));
|
||||
|
||||
//Define the schema for the form data
|
||||
const schema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, "Name is required")
|
||||
.refine(() => !exists[0], {
|
||||
message: `${name} already exists in selected country`,
|
||||
}),
|
||||
description: z.string(),
|
||||
imageUrl: z.string(),
|
||||
countryId: z.string().min(1, "No country selected"),
|
||||
});
|
||||
//Parse the form data using the schema for validation, and check if the name already exists
|
||||
try {
|
||||
schema.parse({
|
||||
name,
|
||||
description,
|
||||
imageUrl,
|
||||
countryId,
|
||||
});
|
||||
//If the name doesn't exist, add the country to the database abd revalidate the page
|
||||
await db
|
||||
.insert(producers)
|
||||
.values({ name, description, imageUrl, countryId });
|
||||
revalidatePath("/");
|
||||
//Return a success message
|
||||
return {
|
||||
message: "success",
|
||||
errors: undefined,
|
||||
fieldValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
imageUrl: "",
|
||||
countryId: "",
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const zodError = error as ZodError;
|
||||
const errorMap = zodError.flatten().fieldErrors;
|
||||
//Return an error object with the field values and errors.
|
||||
return {
|
||||
message: "error",
|
||||
errors: {
|
||||
name: errorMap["name"]?.[0] ?? "",
|
||||
description: errorMap["description"]?.[0] ?? "",
|
||||
imageUrl: errorMap["imageUrl"]?.[0] ?? "",
|
||||
countryId: errorMap["countryId"]?.[0] ?? "",
|
||||
},
|
||||
fieldValues: {
|
||||
name,
|
||||
description,
|
||||
imageUrl,
|
||||
countryId,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
65
src/server/actions/addRegion.ts
Normal file
65
src/server/actions/addRegion.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { db } from "../db";
|
||||
import { regions } from "../db/schema";
|
||||
import { ZodError, z } from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export const addRegion = async (prevstate: any, formData: FormData) => {
|
||||
//assign formdaata to variables.
|
||||
const name = (formData.get("name") as string).toLowerCase();
|
||||
const countryId = formData.get("country") as string;
|
||||
|
||||
//check if region already exists in country
|
||||
const exists = await db
|
||||
.select({ name: regions.name })
|
||||
.from(regions)
|
||||
.where(eq(regions.countryId, countryId) && eq(regions.name, name));
|
||||
|
||||
//Define the schema for the form data
|
||||
const schema = z.object({
|
||||
countryId: z.string().min(1, "No country selected"),
|
||||
name: z
|
||||
.string()
|
||||
.min(1, "Name is required")
|
||||
.refine(() => !exists[0], {
|
||||
message: `${name} already exists in selected country`,
|
||||
}),
|
||||
});
|
||||
|
||||
//Parse the form data using the schema for validation, and check if the name already exists
|
||||
try {
|
||||
schema.parse({
|
||||
countryId,
|
||||
name,
|
||||
});
|
||||
//If the name doesn't exist, add the country to the database abd revalidate the page
|
||||
await db.insert(regions).values({ countryId, name });
|
||||
revalidatePath("/");
|
||||
//Return a success message
|
||||
return {
|
||||
message: "success",
|
||||
errors: undefined,
|
||||
fieldValues: {
|
||||
name: "",
|
||||
countryId: "",
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const zodError = error as ZodError;
|
||||
const errorMap = zodError.flatten().fieldErrors;
|
||||
//Return an error object with the field values and errors.
|
||||
return {
|
||||
message: "error",
|
||||
errors: {
|
||||
name: errorMap["name"]?.[0] ?? "",
|
||||
countryId: errorMap["countryId"]?.[0] ?? "",
|
||||
},
|
||||
fieldValues: {
|
||||
name,
|
||||
countryId,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
64
src/server/actions/addSubRegion.ts
Normal file
64
src/server/actions/addSubRegion.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../db";
|
||||
import { subRegions } from "../db/schema";
|
||||
import { ZodError, z } from "zod";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export const addSubRegion = async (prevstate: any, formData: FormData) => {
|
||||
//assign formdaata to variables.
|
||||
const name = (formData.get("name") as string).toLowerCase();
|
||||
const regionId = formData.get("regionId") as string;
|
||||
|
||||
//check if region already exists in country
|
||||
const exists = await db
|
||||
.select({ name: subRegions.name })
|
||||
.from(subRegions)
|
||||
.where(eq(subRegions.regionId, regionId) && eq(subRegions.name, name));
|
||||
|
||||
//Define the schema for the form data
|
||||
const schema = z.object({
|
||||
regionId: z.string().min(1, "No region selected"),
|
||||
name: z
|
||||
.string()
|
||||
.min(1, "Name is required")
|
||||
.refine(() => !exists[0], {
|
||||
message: `${name} already exists in selected region`,
|
||||
}),
|
||||
});
|
||||
//Parse the form data using the schema for validation, and check if the name already exists
|
||||
try {
|
||||
schema.parse({
|
||||
regionId,
|
||||
name,
|
||||
});
|
||||
//If the name doesn't exist, add the country to the database abd revalidate the page
|
||||
await db.insert(subRegions).values({ regionId, name });
|
||||
revalidatePath("/");
|
||||
//Return a success message
|
||||
return {
|
||||
message: "success",
|
||||
errors: undefined,
|
||||
fieldValues: {
|
||||
name: "",
|
||||
regionId: "",
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const zodError = error as ZodError;
|
||||
const errorMap = zodError.flatten().fieldErrors;
|
||||
//Return an error object with the field values and errors.
|
||||
return {
|
||||
message: "error",
|
||||
errors: {
|
||||
name: errorMap["name"]?.[0] ?? "",
|
||||
regionId: errorMap["regionId"]?.[0] ?? "",
|
||||
},
|
||||
fieldValues: {
|
||||
name,
|
||||
regionId,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
|
@ -1,67 +0,0 @@
|
|||
'use server'
|
||||
import { ZodError, z } from 'zod';
|
||||
import { db } from '../db/index'
|
||||
import { wines } from '../db/schema'
|
||||
import { QueryResult } from 'pg';
|
||||
|
||||
type NewWine = typeof wines.$inferInsert;
|
||||
|
||||
export type InsertResult = {
|
||||
name: string;
|
||||
producer: string;
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date | null;
|
||||
|
||||
}[];
|
||||
export type Fields = {
|
||||
name: FormDataEntryValue | null
|
||||
producer: FormDataEntryValue | null;
|
||||
}
|
||||
|
||||
export type FormState = {
|
||||
message: string | QueryResult<never>;
|
||||
errors: Record<keyof Fields, string> | undefined;
|
||||
fieldValues: NewWine;
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string(),
|
||||
producer: z.string().uuid(),
|
||||
})
|
||||
|
||||
export const addWine = async (
|
||||
prevState: FormState,
|
||||
formData: FormData): Promise<FormState> => {
|
||||
const newWine: NewWine = {
|
||||
name: formData.get('name') as string,
|
||||
producer: formData.get('producer') as string
|
||||
}
|
||||
try {
|
||||
schema.parse(newWine)
|
||||
|
||||
await db.insert(wines).values(newWine);
|
||||
return {
|
||||
message: 'success',
|
||||
errors: undefined,
|
||||
fieldValues: {
|
||||
name: "",
|
||||
producer: ""
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const zodError = error as ZodError;
|
||||
const errorMap = zodError.flatten().fieldErrors;
|
||||
return {
|
||||
message: "error",
|
||||
errors: {
|
||||
name: errorMap["name"]?.[0] ?? "",
|
||||
producer: errorMap["producer"]?.[0] ?? ""
|
||||
},
|
||||
fieldValues: {
|
||||
name: newWine.name,
|
||||
producer: newWine.producer
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
'use server'
|
||||
import { db } from "../db/index";
|
||||
|
||||
|
||||
export async function getProducers(){
|
||||
return db.query.producers.findMany();
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
'use server'
|
||||
import { db } from "../db/index";
|
||||
|
||||
|
||||
export async function getWines(){
|
||||
return db.query.wines.findMany();
|
||||
}
|
11
src/server/actions/deleteCountry.ts
Normal file
11
src/server/actions/deleteCountry.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../db";
|
||||
import { countries } from "../db/schema";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function deleteCountry(id: string) {
|
||||
await db.delete(countries).where(eq(countries.id, id));
|
||||
revalidatePath("/");
|
||||
}
|
10
src/server/actions/deleteProducer.ts
Normal file
10
src/server/actions/deleteProducer.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
"use server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../db";
|
||||
import { producers } from "../db/schema";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export default async function deleteProducer(id: string) {
|
||||
await db.delete(producers).where(eq(producers.id, id));
|
||||
revalidatePath("/");
|
||||
}
|
10
src/server/actions/deleteRegion.ts
Normal file
10
src/server/actions/deleteRegion.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
"use server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../db";
|
||||
import { regions } from "../db/schema";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export default async function deleteRegion(id: string) {
|
||||
await db.delete(regions).where(eq(regions.id, id));
|
||||
revalidatePath("/");
|
||||
}
|
10
src/server/actions/deleteSubRegion.ts
Normal file
10
src/server/actions/deleteSubRegion.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
"use server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../db";
|
||||
import { subRegions } from "../db/schema";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export default async function deleteSubRegion(id: string) {
|
||||
await db.delete(subRegions).where(eq(subRegions.id, id));
|
||||
revalidatePath("/");
|
||||
}
|
6
src/server/actions/getAllCountries.ts
Normal file
6
src/server/actions/getAllCountries.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { db } from "../db";
|
||||
|
||||
export default async function getAllCountries() {
|
||||
const allCountries = await db.query.countries.findMany();
|
||||
return allCountries;
|
||||
}
|
7
src/server/actions/getAllProducers.ts
Normal file
7
src/server/actions/getAllProducers.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
"use server";
|
||||
import { db } from "../db";
|
||||
|
||||
export default async function getAllProducers() {
|
||||
const allProducers = await db.query.producers.findMany();
|
||||
return allProducers;
|
||||
}
|
6
src/server/actions/getAllRegions.ts
Normal file
6
src/server/actions/getAllRegions.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { db } from "../db";
|
||||
|
||||
export default async function getAllRegions() {
|
||||
const allRegions = await db.query.regions.findMany();
|
||||
return allRegions;
|
||||
}
|
7
src/server/actions/getAllSubRegions.ts
Normal file
7
src/server/actions/getAllSubRegions.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
"use server";
|
||||
import { db } from "../db";
|
||||
|
||||
export default async function getAllSubRegions() {
|
||||
const allSubRegions = await db.query.subRegions.findMany();
|
||||
return allSubRegions;
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
"use server";
|
||||
import { producers } from "../db/schema";
|
||||
import { db } from "../db";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export default async function getProducer(id: string) {
|
||||
return db.query.producers.findFirst({
|
||||
where: eq(producers.id, id),
|
||||
});
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
"use server";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "../db";
|
||||
import { wines, producers } from "../db/schema";
|
||||
import { UUID } from "crypto";
|
||||
|
||||
export async function getWineDetails(id: string) {
|
||||
const results = await db
|
||||
.select()
|
||||
.from(producers)
|
||||
.where(sql`${producers.id} = ${id}`);
|
||||
return results;
|
||||
}
|
|
@ -1,59 +1,142 @@
|
|||
import {
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
pgEnum,
|
||||
pgTableCreator,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
unique,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { relations } from "drizzle-orm";
|
||||
|
||||
export const createTable = pgTableCreator((name) => `wine-shop_${name}`);
|
||||
|
||||
// Producer Schema
|
||||
export const producers = createTable(
|
||||
"producer",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: text("name").notNull(),
|
||||
name: text("name").notNull().unique(),
|
||||
description: text("description"),
|
||||
imageUrl: text("imageUrl"),
|
||||
countryId: uuid("countryId")
|
||||
.references(() => countries.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
country: text("country").notNull(),
|
||||
region: text("region").notNull(),
|
||||
email: text("email").notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow(),
|
||||
},
|
||||
(producers) => {
|
||||
return {
|
||||
uniqueIdx: uniqueIndex("email").on(producers.email),
|
||||
nameIdx: index("producer_name").on(producers.name),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// one-to-many relation producer -> wines
|
||||
export const producersRelations = relations(producers, ({ many }) => ({
|
||||
wines: many(wines),
|
||||
}));
|
||||
|
||||
export const wines = createTable(
|
||||
"wine",
|
||||
{
|
||||
// Wines schema
|
||||
export const typeEnum = pgEnum("type", [
|
||||
"sparkling",
|
||||
"white",
|
||||
"red",
|
||||
"sweet",
|
||||
"other",
|
||||
]);
|
||||
|
||||
export const wines = createTable("wine", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: text("name").notNull(),
|
||||
type: typeEnum("type").notNull(),
|
||||
description: text("description"),
|
||||
imageUrl: text("imageUrl"),
|
||||
producerId: uuid("producerId")
|
||||
.references(() => producers.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
subRegionId: uuid("subRegionId"),
|
||||
regionId: uuid("regionId").notNull(),
|
||||
countryId: uuid("countryId").notNull(),
|
||||
price: integer("price").notNull(),
|
||||
inStock: boolean("inStock").notNull().default(false),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow(),
|
||||
producer: uuid("producer").notNull(),
|
||||
},
|
||||
(wines) => {
|
||||
return {
|
||||
uniqueIdx: uniqueIndex("unique_idx").on(wines.id),
|
||||
};
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// many-to-one relationship wine -> producer
|
||||
export const winesRelations = relations(wines, ({ one }) => ({
|
||||
producer: one(producers, {
|
||||
fields: [wines.producer],
|
||||
fields: [wines.producerId],
|
||||
references: [producers.id],
|
||||
}),
|
||||
subregion: one(subRegions, {
|
||||
fields: [wines.subRegionId],
|
||||
references: [subRegions.id],
|
||||
}),
|
||||
region: one(regions, {
|
||||
fields: [wines.regionId],
|
||||
references: [regions.id],
|
||||
}),
|
||||
country: one(countries, {
|
||||
fields: [wines.countryId],
|
||||
references: [countries.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type SelectWine = typeof wines.$inferSelect;
|
||||
export const subRegions = createTable(
|
||||
"subRegion",
|
||||
{
|
||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||
name: text("name").notNull().unique(),
|
||||
regionId: uuid("regionId")
|
||||
.references(() => regions.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
uniqueSubRegion: unique().on(t.name, t.regionId),
|
||||
}),
|
||||
);
|
||||
|
||||
export type SelectProducer = typeof producers.$inferSelect;
|
||||
export type InsertProducer = typeof producers.$inferInsert;
|
||||
export const subRegionsRelations = relations(subRegions, ({ many, one }) => ({
|
||||
wines: many(wines),
|
||||
region: one(regions, {
|
||||
fields: [subRegions.regionId],
|
||||
references: [regions.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const regions = createTable(
|
||||
"region",
|
||||
{
|
||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||
name: text("name").notNull(),
|
||||
countryId: uuid("countryId")
|
||||
.references(() => countries.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
uniqueRegion: unique().on(t.name, t.countryId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const regionsRelations = relations(regions, ({ many, one }) => ({
|
||||
wines: many(wines),
|
||||
subRegions: many(subRegions),
|
||||
country: one(countries, {
|
||||
fields: [regions.countryId],
|
||||
references: [countries.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const countries = createTable("country", {
|
||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||
name: text("name").notNull().unique(),
|
||||
});
|
||||
|
||||
export const countriesRelations = relations(countries, ({ many }) => ({
|
||||
wines: many(wines),
|
||||
regions: many(regions),
|
||||
}));
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
export interface Producer {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
country: string;
|
||||
region: string;
|
||||
email: string;
|
||||
}
|
|
@ -1,14 +1,40 @@
|
|||
import { type Config } from "tailwindcss";
|
||||
import { fontFamily } from "tailwindcss/defaultTheme";
|
||||
import type { Config } from "tailwindcss"
|
||||
|
||||
export default {
|
||||
content: ["./src/**/*.tsx"],
|
||||
const config = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
'./pages/**/*.{ts,tsx}',
|
||||
'./components/**/*.{ts,tsx}',
|
||||
'./app/**/*.{ts,tsx}',
|
||||
'./src/**/*.{ts,tsx}',
|
||||
],
|
||||
prefix: "",
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["var(--font-geist-sans)", ...fontFamily.sans],
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config;
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
} satisfies Config
|
||||
|
||||
export default config
|
Loading…
Reference in New Issue
Block a user