Upload sample project

This commit is contained in:
2023-11-13 17:56:15 +07:00
parent 8969e659b2
commit 545e7b23ff
32 changed files with 8113 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "react-app"
}
+5
View File
@@ -0,0 +1,5 @@
{
"cSpell.words": [
"supabase"
]
}
+25
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+33
View File
@@ -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

+1
View File
@@ -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

+57
View File
@@ -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;
+9
View File
@@ -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;
+65
View File
@@ -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;
+25
View File
@@ -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;
+59
View File
@@ -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;
+33
View File
@@ -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;
+11
View File
@@ -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;
+25
View File
@@ -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;
+22
View File
@@ -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;
+59
View File
@@ -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;
`;
+11
View File
@@ -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
);
+8
View File
@@ -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()],
});