mirror of
https://github.com/Nezumi-2711/react-training.git
synced 2026-09-22 20:01:59 +00:00
Upload sample project
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "react-app"
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"supabase"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Sono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<title>The Wild Oasis</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+6990
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "the-wild-oasis",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.38.4",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@tanstack/react-query-devtools": "^4.36.1",
|
||||
"date-fns": "^2.30.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-icons": "^4.8.0",
|
||||
"react-router-dom": "^6.11.1",
|
||||
"styled-components": "^5.3.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.28",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"@vitejs/plugin-react": "^3.1.0",
|
||||
"eslint": "^8.39.0",
|
||||
"eslint-config-react-app": "^7.0.1",
|
||||
"vite": "^4.2.0",
|
||||
"vite-plugin-eslint": "^1.8.1"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,57 @@
|
||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
|
||||
import GlobalStyles from "./styles/GlobalStyles";
|
||||
import Cabins from "./pages/Cabins";
|
||||
import AppLayout from "./ui/AppLayout";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
|
||||
<GlobalStyles />
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<Navigate replace to="cabins" />} />
|
||||
<Route path="cabins" element={<Cabins />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
<Toaster
|
||||
position="top-center"
|
||||
gutter={12}
|
||||
containerStyle={{ margin: "8px" }}
|
||||
toastOptions={{
|
||||
success: {
|
||||
duration: 3000,
|
||||
},
|
||||
error: {
|
||||
duration: 5000,
|
||||
},
|
||||
style: {
|
||||
fontSize: "16px",
|
||||
maxWidth: "500px",
|
||||
padding: "16px 24px",
|
||||
backgroundColor: "var(--color-grey-o)",
|
||||
color: "var(--color-grey-700)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,18 @@
|
||||
import Button from 'ui/Button';
|
||||
import Modal from 'ui/Modal';
|
||||
import CreateCabinForm from './CreateCabinForm';
|
||||
|
||||
function AddCabin() {
|
||||
return (
|
||||
<Modal>
|
||||
<Modal.Toggle opens='new-cabin'>
|
||||
<Button>Add new cabin</Button>
|
||||
</Modal.Toggle>
|
||||
<Modal.Window name='new-cabin'>
|
||||
<CreateCabinForm />
|
||||
</Modal.Window>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddCabin;
|
||||
@@ -0,0 +1,96 @@
|
||||
import styled from "styled-components";
|
||||
import { formatCurrency } from "../../utils/helpers";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { deleteCabin } from "../../services/apiCabins";
|
||||
import toast from "react-hot-toast";
|
||||
import { useState } from "react";
|
||||
import CreateCabinForm from "./CreateCabinForm";
|
||||
|
||||
// v1
|
||||
const TableRow = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 0.6fr 1.8fr 2.2fr 1fr 1fr 1fr;
|
||||
column-gap: 2.4rem;
|
||||
align-items: center;
|
||||
padding: 1.4rem 2.4rem;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid var(--color-grey-100);
|
||||
}
|
||||
`;
|
||||
|
||||
const Img = styled.img`
|
||||
display: block;
|
||||
width: 6.4rem;
|
||||
aspect-ratio: 3 / 2;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
/* transform: scale(1.66666) translateX(-2px); */
|
||||
transform: scale(1.5) translateX(-7px);
|
||||
`;
|
||||
|
||||
const Cabin = styled.div`
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-grey-600);
|
||||
font-family: "Sono";
|
||||
`;
|
||||
|
||||
const Price = styled.div`
|
||||
font-family: "Sono";
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const Discount = styled.div`
|
||||
font-family: "Sono";
|
||||
font-weight: 500;
|
||||
color: var(--color-green-700);
|
||||
`;
|
||||
|
||||
function CabinRow({ cabin }) {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
const {
|
||||
id: cabinId,
|
||||
name,
|
||||
maxCapacity,
|
||||
regularPrice,
|
||||
discount,
|
||||
image,
|
||||
} = cabin;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { isLoading: isDeleting, mutate } = useMutation({
|
||||
mutationFn: deleteCabin,
|
||||
onSuccess: () => {
|
||||
toast.success("Cabin successfully deleted!");
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["cabins"],
|
||||
});
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow>
|
||||
<Img src={image} />
|
||||
<Cabin>{name}</Cabin>
|
||||
<div>Fits up to {maxCapacity} guests</div>
|
||||
<Price>{formatCurrency(regularPrice)}</Price>
|
||||
<Discount>{formatCurrency(discount)}</Discount>
|
||||
<div>
|
||||
<button onClick={() => setShowForm((show) => !show)}>Edit</button>
|
||||
<button onClick={() => mutate(cabinId)} disabled={isDeleting}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</TableRow>
|
||||
{showForm && <CreateCabinForm cabinToEdit={cabin} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default CabinRow;
|
||||
@@ -0,0 +1,65 @@
|
||||
import styled from "styled-components";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Spinner from "../../ui/Spinner";
|
||||
|
||||
import { getCabins } from "../../services/apiCabins";
|
||||
import CabinRow from "./CabinRow";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
const Table = styled.div`
|
||||
border: 1px solid var(--color-grey-200);
|
||||
|
||||
font-size: 1.4rem;
|
||||
background-color: var(--color-grey-0);
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const TableHeader = styled.header`
|
||||
display: grid;
|
||||
grid-template-columns: 0.6fr 1.8fr 2.2fr 1fr 1fr 1fr;
|
||||
column-gap: 2.4rem;
|
||||
align-items: center;
|
||||
|
||||
background-color: var(--color-grey-50);
|
||||
border-bottom: 1px solid var(--color-grey-100);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
font-weight: 600;
|
||||
color: var(--color-grey-600);
|
||||
padding: 1.6rem 2.4rem;
|
||||
`;
|
||||
|
||||
function CabinTable() {
|
||||
const {
|
||||
isLoading,
|
||||
data: cabins,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["cabins"],
|
||||
queryFn: getCabins,
|
||||
});
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
|
||||
if(error) toast.error(error);
|
||||
|
||||
return (
|
||||
<Table role="table">
|
||||
<TableHeader role="row">
|
||||
<div></div>
|
||||
<div>Cabin</div>
|
||||
<div>Capacity</div>
|
||||
<div>Price</div>
|
||||
<div>Discount</div>
|
||||
<div></div>
|
||||
</TableHeader>
|
||||
|
||||
{cabins.map((cabin) => (
|
||||
<CabinRow cabin={cabin} key={cabin.id} />
|
||||
))}
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
export default CabinTable;
|
||||
@@ -0,0 +1,159 @@
|
||||
import FormRow from "../../ui/FormRow";
|
||||
import Input from "../../ui/Input";
|
||||
import Form from "../../ui/Form";
|
||||
import Button from "../../ui/Button";
|
||||
import FileInput from "../../ui/FileInput";
|
||||
import { Textarea } from "../../ui/Textarea";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { createEditCabin } from "../../services/apiCabins";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
function CreateCabinForm({ cabinToEdit = {} }) {
|
||||
const { id: editId, ...editValues } = cabinToEdit;
|
||||
const isEditSession = Boolean(editId);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
getValues,
|
||||
formState: { errors },
|
||||
} = useForm({
|
||||
defaultValues: isEditSession ? editValues : {},
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate: createCabin, isLoading: isCreating } = useMutation({
|
||||
mutationFn: createEditCabin,
|
||||
onSuccess: () => {
|
||||
toast.success("New cabin successfully created");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["cabins"],
|
||||
});
|
||||
reset();
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const { mutate: editCabin, isLoading: isEditing } = useMutation({
|
||||
mutationFn: ({ newCabinData, id }) => createEditCabin(newCabinData, id),
|
||||
onSuccess: () => {
|
||||
toast.success("Cabin successfully edited");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["cabins"],
|
||||
});
|
||||
reset();
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const isWorking = isCreating | isEditing;
|
||||
|
||||
function onSubmit(data) {
|
||||
const image = typeof data.image === "string" ? data.image : data.image[0];
|
||||
|
||||
if (isEditSession) {
|
||||
editCabin({ newCabinData: { ...data, image }, id: editId });
|
||||
} else {
|
||||
createCabin({ ...data, image: image });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form type="modal" onSubmit={handleSubmit(onSubmit)}>
|
||||
<FormRow label="Cabin name" error={errors?.name?.message}>
|
||||
<Input
|
||||
type="text"
|
||||
id="name"
|
||||
disabled={isCreating}
|
||||
{...register("name", {
|
||||
required: "This field is required!",
|
||||
})}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow label="Maximum capacity" error={errors?.maxCapacity?.message}>
|
||||
<Input
|
||||
type="number"
|
||||
id="maxCapacity"
|
||||
disabled={isCreating}
|
||||
{...register("maxCapacity", {
|
||||
required: "This field is required!",
|
||||
min: {
|
||||
value: 1,
|
||||
message: "Capacity should be at least 1",
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow label="Regular price" error={errors?.regularPrice?.message}>
|
||||
<Input
|
||||
type="number"
|
||||
id="regularPrice"
|
||||
disabled={isCreating}
|
||||
{...register("regularPrice", {
|
||||
required: "This field is required!",
|
||||
min: {
|
||||
value: 1,
|
||||
message: "Capacity should be at least 1",
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow label="Discount" error={errors?.discount?.message}>
|
||||
<Input
|
||||
type="number"
|
||||
id="discount"
|
||||
defaultValue={0}
|
||||
disabled={isCreating}
|
||||
{...register("discount", {
|
||||
required: "This field is required!",
|
||||
validate: (value) =>
|
||||
+value <= +getValues().regularPrice ||
|
||||
"Discount should be less than regular price",
|
||||
})}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow
|
||||
label="Description for website"
|
||||
error={errors?.description?.message}
|
||||
>
|
||||
<Textarea
|
||||
type="number"
|
||||
id="description"
|
||||
disabled={isCreating}
|
||||
defaultValue=""
|
||||
{...register("description", {
|
||||
required: "This field is required",
|
||||
})}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow label="Cabin photo">
|
||||
<FileInput
|
||||
id="image"
|
||||
accept="image/*"
|
||||
{...register("image", {
|
||||
required: isEditSession ? false : "This field is required",
|
||||
})}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow>
|
||||
{/* type is an HTML attribute! */}
|
||||
<Button variation="secondary" type="reset">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={isWorking}>
|
||||
{isEditSession ? "Edit cabin" : "Create new cabin"}
|
||||
</Button>
|
||||
</FormRow>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateCabinForm;
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
import Heading from "../ui/Heading";
|
||||
import Row from "../ui/Row";
|
||||
import CabinTable from "../features/cabins/CabinTable";
|
||||
import { useState } from "react";
|
||||
import Button from '../ui/Button';
|
||||
import CreateCabinForm from '../features/cabins/CreateCabinForm';
|
||||
|
||||
function Cabins() {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row type="horizontal">
|
||||
<Heading as="h1">All cabins</Heading>
|
||||
<p>Filter / Sort</p>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<CabinTable />
|
||||
|
||||
<Button onClick={() => setShowForm((show) => !show)}>
|
||||
Add new cabin
|
||||
</Button>
|
||||
{showForm && <CreateCabinForm />}
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Cabins;
|
||||
@@ -0,0 +1,66 @@
|
||||
import toast from "react-hot-toast";
|
||||
import supabase, { supabaseUrl } from "./supabase";
|
||||
|
||||
export async function getCabins() {
|
||||
const { data, error } = await supabase.from("cabins").select("*");
|
||||
|
||||
if (error) {
|
||||
throw new Error("Cabins could not be loaded!");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createEditCabin(newCabin, id) {
|
||||
const hasImagePath = newCabin.image?.startsWith?.(supabaseUrl);
|
||||
const imageName = `${Math.random()}-${newCabin.image.name}`.replaceAll(
|
||||
"/",
|
||||
""
|
||||
);
|
||||
const imagePath = hasImagePath
|
||||
? newCabin.image
|
||||
: `${supabaseUrl}/storage/v1/object/public/cabin-images/${imageName}`;
|
||||
|
||||
// 1. Create cabin
|
||||
let query = supabase.from("cabins");
|
||||
|
||||
// Create
|
||||
if (!id) {
|
||||
query = query.insert([{ ...newCabin, image: imagePath }]);
|
||||
} else {
|
||||
query = query.update({ ...newCabin, image: imagePath }).eq("id", id);
|
||||
}
|
||||
|
||||
const { data, error } = await query.select().single();
|
||||
|
||||
if (error) {
|
||||
toast.error(error);
|
||||
throw new Error("Cabin could not be create!");
|
||||
}
|
||||
|
||||
// 2. Upload image
|
||||
const { error: storageError } = await supabase.storage
|
||||
.from("cabin-images")
|
||||
.upload(imageName, newCabin.image);
|
||||
|
||||
// 3. Delete the cabin IF there was an error uploading image
|
||||
if (storageError) {
|
||||
await supabase.from("cabins").delete().eq("id", data.id);
|
||||
console.error(storageError);
|
||||
throw new Error(
|
||||
"Cabin image could not be uploaded and the cabin was not created"
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteCabin(id) {
|
||||
const { data, error } = await supabase.from("cabins").delete().eq("id", id);
|
||||
|
||||
if (error) {
|
||||
throw new Error("Cabins could not be deleted!");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
export const supabaseUrl = "https://nalwpbdvpgqubwapcjze.supabase.co";
|
||||
const supabaseKey =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im5hbHdwYmR2cGdxdWJ3YXBjanplIiwicm9sZSI6ImFub24iLCJpYXQiOjE2OTk4NDExMjgsImV4cCI6MjAxNTQxNzEyOH0.RABKXbgQwfmfc8pYYVF6WpSevqZ2ISG0izh3Tkt_QHA";
|
||||
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
|
||||
export default supabase;
|
||||
@@ -0,0 +1,148 @@
|
||||
import { createGlobalStyle } from "styled-components";
|
||||
|
||||
const GlobalStyles = createGlobalStyle`
|
||||
:root {
|
||||
/* Indigo */
|
||||
--color-brand-50: #eef2ff;
|
||||
--color-brand-100: #e0e7ff;
|
||||
--color-brand-200: #c7d2fe;
|
||||
--color-brand-500: #6366f1;
|
||||
--color-brand-600: #4f46e5;
|
||||
--color-brand-700: #4338ca;
|
||||
--color-brand-800: #3730a3;
|
||||
--color-brand-900: #312e81;
|
||||
|
||||
/* Grey */
|
||||
--color-grey-0: #fff;
|
||||
--color-grey-50: #f9fafb;
|
||||
--color-grey-100: #f3f4f6;
|
||||
--color-grey-200: #e5e7eb;
|
||||
--color-grey-300: #d1d5db;
|
||||
--color-grey-400: #9ca3af;
|
||||
--color-grey-500: #6b7280;
|
||||
--color-grey-600: #4b5563;
|
||||
--color-grey-700: #374151;
|
||||
--color-grey-800: #1f2937;
|
||||
--color-grey-900: #111827;
|
||||
|
||||
--color-blue-100: #e0f2fe;
|
||||
--color-blue-700: #0369a1;
|
||||
--color-green-100: #dcfce7;
|
||||
--color-green-700: #15803d;
|
||||
--color-yellow-100: #fef9c3;
|
||||
--color-yellow-700: #a16207;
|
||||
--color-silver-100: #e5e7eb;
|
||||
--color-silver-700: #374151;
|
||||
--color-indigo-100: #e0e7ff;
|
||||
--color-indigo-700: #4338ca;
|
||||
|
||||
--color-red-100: #fee2e2;
|
||||
--color-red-700: #b91c1c;
|
||||
--color-red-800: #991b1b;
|
||||
|
||||
--backdrop-color: rgba(255, 255, 255, 0.1);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-md: 0px 0.6rem 2.4rem rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 2.4rem 3.2rem rgba(0, 0, 0, 0.12);
|
||||
|
||||
--border-radius-tiny: 3px;
|
||||
--border-radius-sm: 5px;
|
||||
--border-radius-md: 7px;
|
||||
--border-radius-lg: 9px;
|
||||
|
||||
/* For dark mode */
|
||||
--image-grayscale: 0;
|
||||
--image-opacity: 100%;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
/* Creating animations for dark mode */
|
||||
transition: background-color 0.3s, border 0.3s;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 62.5%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Poppins", sans-serif;
|
||||
color: var(--color-grey-700);
|
||||
|
||||
transition: color 0.3s, background-color 0.3s;
|
||||
min-height: 100vh;
|
||||
line-height: 1.5;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
*:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
select:disabled,
|
||||
input:disabled {
|
||||
background-color: var(--color-grey-200);
|
||||
color: var(--color-grey-500);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
button:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
outline: 2px solid var(--color-brand-600);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
/* Parent selector, finally 😃 */
|
||||
button:has(svg) {
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
p,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
|
||||
/* For dark mode */
|
||||
filter: grayscale(var(--image-grayscale)) opacity(var(--image-opacity));
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
export default GlobalStyles;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
|
||||
const Main = styled.main`
|
||||
background-color: var(--color-grey-50);
|
||||
padding: 4rem 4.8rem 6.4rem;
|
||||
`;
|
||||
|
||||
const Container = styled.div`
|
||||
max-width: 120rem;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3.2rem;
|
||||
`;
|
||||
|
||||
function AppLayout() {
|
||||
return (
|
||||
<Main>
|
||||
<Container>
|
||||
<Outlet />
|
||||
</Container>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppLayout;
|
||||
@@ -0,0 +1,65 @@
|
||||
import styled, { css } from "styled-components";
|
||||
|
||||
const sizes = {
|
||||
small: css`
|
||||
font-size: 1.2rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
`,
|
||||
medium: css`
|
||||
font-size: 1.4rem;
|
||||
padding: 1.2rem 1.6rem;
|
||||
font-weight: 500;
|
||||
`,
|
||||
large: css`
|
||||
font-size: 1.6rem;
|
||||
padding: 1.2rem 2.4rem;
|
||||
font-weight: 500;
|
||||
`,
|
||||
};
|
||||
|
||||
const variations = {
|
||||
primary: css`
|
||||
color: var(--color-brand-50);
|
||||
background-color: var(--color-brand-600);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-brand-700);
|
||||
}
|
||||
`,
|
||||
secondary: css`
|
||||
color: var(--color-grey-600);
|
||||
background: var(--color-grey-0);
|
||||
border: 1px solid var(--color-grey-200);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-grey-50);
|
||||
}
|
||||
`,
|
||||
danger: css`
|
||||
color: var(--color-red-100);
|
||||
background-color: var(--color-red-700);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-red-800);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
const Button = styled.button`
|
||||
border: none;
|
||||
border-radius: var(--border-radius-sm);
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
||||
${(props) => sizes[props.size]}
|
||||
${(props) => variations[props.variation]}
|
||||
`;
|
||||
|
||||
Button.defaultProps = {
|
||||
variation: "primary",
|
||||
size: "medium",
|
||||
};
|
||||
|
||||
export default Button;
|
||||
@@ -0,0 +1,25 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
const FileInput = styled.input.attrs({type: 'file'})`
|
||||
font-size: 1.4rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
|
||||
&::file-selector-button {
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
padding: 0.8rem 1.2rem;
|
||||
margin-right: 1.2rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: none;
|
||||
color: var(--color-brand-50);
|
||||
background-color: var(--color-brand-600);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s, background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-brand-700);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default FileInput;
|
||||
@@ -0,0 +1,25 @@
|
||||
import styled, { css } from "styled-components";
|
||||
|
||||
const Form = styled.form`
|
||||
${(props) =>
|
||||
props.type !== "modal" &&
|
||||
css`
|
||||
padding: 2.4rem 4rem;
|
||||
|
||||
/* Box */
|
||||
background-color: var(--color-grey-0);
|
||||
border: 1px solid var(--color-grey-100);
|
||||
border-radius: var(--border-radius-md);
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.type === "modal" &&
|
||||
css`
|
||||
width: 80rem;
|
||||
`}
|
||||
|
||||
overflow: hidden;
|
||||
font-size: 1.4rem;
|
||||
`;
|
||||
|
||||
export default Form;
|
||||
@@ -0,0 +1,59 @@
|
||||
import styled, { css } from "styled-components";
|
||||
|
||||
const StyledFormRow = styled.div`
|
||||
display: grid;
|
||||
align-items: center;
|
||||
|
||||
grid-template-columns: ${(props) =>
|
||||
props.orientation === "vertical" ? "1fr" : "24rem 1fr 1.2fr"};
|
||||
gap: ${(props) => (props.orientation === "vertical" ? "0.8rem" : "2.4rem")};
|
||||
|
||||
padding: 1.2rem 0;
|
||||
|
||||
&:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: ${(props) =>
|
||||
props.orientation === "vertical"
|
||||
? "none"
|
||||
: "1px solid var(--color-grey-100)"};
|
||||
}
|
||||
|
||||
/* Special treatment if the row contains buttons, and if it's NOT a vertical row */
|
||||
${(props) =>
|
||||
props.orientation !== "vertical" &&
|
||||
css`
|
||||
&:has(button) {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const Label = styled.label`
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
const Error = styled.span`
|
||||
font-size: 1.4rem;
|
||||
color: var(--color-red-700);
|
||||
`;
|
||||
|
||||
function FormRow({ label, error, children, orientation }) {
|
||||
return (
|
||||
<StyledFormRow orientation={orientation}>
|
||||
{label && <Label htmlFor={children.props.id}>{label}</Label>}
|
||||
{children}
|
||||
{error && <Error>{error}</Error>}
|
||||
</StyledFormRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormRow;
|
||||
@@ -0,0 +1,33 @@
|
||||
import styled, { css } from "styled-components";
|
||||
|
||||
// const test = css`
|
||||
// text-align: center;
|
||||
// ${10 > 5 && "background-color: yellow"}
|
||||
// `;
|
||||
|
||||
const Heading = styled.h1`
|
||||
${(props) =>
|
||||
props.as === "h1" &&
|
||||
css`
|
||||
font-size: 3rem;
|
||||
font-weight: 600;
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.as === "h2" &&
|
||||
css`
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.as === "h3" &&
|
||||
css`
|
||||
font-size: 2rem;
|
||||
font-weight: 500;
|
||||
`}
|
||||
|
||||
line-height: 1.4;
|
||||
`;
|
||||
|
||||
export default Heading;
|
||||
@@ -0,0 +1,11 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
const Input = styled.input`
|
||||
border: 1px solid var(--color-grey-300);
|
||||
background-color: var(--color-grey-0);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: 0.8rem 1.2rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
`;
|
||||
|
||||
export default Input;
|
||||
@@ -0,0 +1,25 @@
|
||||
import styled, { css } from "styled-components";
|
||||
|
||||
const Row = styled.div`
|
||||
display: flex;
|
||||
|
||||
${(props) =>
|
||||
props.type === "horizontal" &&
|
||||
css`
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.type === "vertical" &&
|
||||
css`
|
||||
flex-direction: column;
|
||||
gap: 1.6rem;
|
||||
`}
|
||||
`;
|
||||
|
||||
Row.defaultProps = {
|
||||
type: "vertical",
|
||||
};
|
||||
|
||||
export default Row;
|
||||
@@ -0,0 +1,22 @@
|
||||
import styled, { keyframes } from "styled-components";
|
||||
|
||||
const rotate = keyframes`
|
||||
to {
|
||||
transform: rotate(1turn)
|
||||
}
|
||||
`;
|
||||
|
||||
const Spinner = styled.div`
|
||||
margin: 4.8rem auto;
|
||||
|
||||
width: 6.4rem;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(farthest-side, var(--color-brand-600) 94%, #0000)
|
||||
top/10px 10px no-repeat,
|
||||
conic-gradient(#0000 30%, var(--color-brand-600));
|
||||
-webkit-mask: radial-gradient(farthest-side, #0000 calc(100% - 10px), #000 0);
|
||||
animation: ${rotate} 1.5s infinite linear;
|
||||
`;
|
||||
|
||||
export default Spinner;
|
||||
@@ -0,0 +1,59 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
const StyledTable = styled.div`
|
||||
border: 1px solid var(--color-grey-200);
|
||||
|
||||
font-size: 1.4rem;
|
||||
background-color: var(--color-grey-0);
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const CommonRow = styled.header`
|
||||
display: grid;
|
||||
grid-template-columns: ${(props) => props.columns};
|
||||
column-gap: 2.4rem;
|
||||
align-items: center;
|
||||
transition: none;
|
||||
`;
|
||||
|
||||
const StyledHeader = styled(CommonRow)`
|
||||
padding: 1.6rem 2.4rem;
|
||||
|
||||
background-color: var(--color-grey-50);
|
||||
border-bottom: 1px solid var(--color-grey-100);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
font-weight: 600;
|
||||
color: var(--color-grey-600);
|
||||
`;
|
||||
|
||||
const StyledBody = styled.section`
|
||||
margin: 0.4rem 0;
|
||||
`;
|
||||
|
||||
const StyledRow = styled(CommonRow)`
|
||||
padding: 1.2rem 2.4rem;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid var(--color-grey-100);
|
||||
}
|
||||
`;
|
||||
|
||||
const Footer = styled.footer`
|
||||
background-color: var(--color-grey-50);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 1.2rem;
|
||||
|
||||
&:not(:has(*)) {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const Empty = styled.p`
|
||||
font-size: 1.6rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
margin: 2.4rem;
|
||||
`;
|
||||
@@ -0,0 +1,11 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Textarea = styled.textarea`
|
||||
padding: 0.8rem 1.2rem;
|
||||
border: 1px solid var(--color-grey-300);
|
||||
border-radius: 5px;
|
||||
background-color: var(--color-grey-0);
|
||||
box-shadow: var(--shadow-sm);
|
||||
width: 100%;
|
||||
height: 8rem;
|
||||
`;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { formatDistance, parseISO } from 'date-fns';
|
||||
import { differenceInDays } from 'date-fns/esm';
|
||||
|
||||
// We want to make this function work for both Date objects and strings (which come from Supabase)
|
||||
export const subtractDates = (dateStr1, dateStr2) =>
|
||||
differenceInDays(parseISO(String(dateStr1)), parseISO(String(dateStr2)));
|
||||
|
||||
export const formatDistanceFromNow = (dateStr) =>
|
||||
formatDistance(parseISO(dateStr), new Date(), {
|
||||
addSuffix: true,
|
||||
})
|
||||
.replace('about ', '')
|
||||
.replace('in', 'In');
|
||||
|
||||
// Supabase needs an ISO date string. However, that string will be different on every render because the MS or SEC have changed, which isn't good. So we use this trick to remove any time
|
||||
export const getToday = function (options = {}) {
|
||||
const today = new Date();
|
||||
|
||||
// This is necessary to compare with created_at from Supabase, because it it not at 0.0.0.0, so we need to set the date to be END of the day when we compare it with earlier dates
|
||||
if (options?.end)
|
||||
// Set to the last second of the day
|
||||
today.setUTCHours(23, 59, 59, 999);
|
||||
else today.setUTCHours(0, 0, 0, 0);
|
||||
return today.toISOString();
|
||||
};
|
||||
|
||||
export const formatCurrency = (value) =>
|
||||
new Intl.NumberFormat('en', { style: 'currency', currency: 'USD' }).format(
|
||||
value
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import eslint from "vite-plugin-eslint";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), eslint()],
|
||||
});
|
||||
Reference in New Issue
Block a user