Allow users to edit their own order sets

This commit is contained in:
Johnny Gear 2026-03-04 15:14:18 -06:00
parent 06002c579b
commit fd654153a4
10 changed files with 273 additions and 161 deletions

View file

@ -6,6 +6,13 @@ from db.queries import user_get, domsubusers_list, orders_pool_list, orders_pool
api = Blueprint('api', __name__)
@api.route("/me")
@login_required
def me():
return jsonify({
"username": current_user.db_user.telegram_username
})
@api.route('/subs')
@login_required
def subs():
@ -29,7 +36,8 @@ def authorized_sub(func):
abort(403)
return
if sub.telegram_username not in [dsu.sub.telegram_username for dsu in domsubusers_list(current_user.db_user)]:
if (sub.id != current_user.db_user.id and
sub.telegram_username not in [dsu.sub.telegram_username for dsu in domsubusers_list(current_user.db_user)]):
abort(403)
return
@ -37,7 +45,26 @@ def authorized_sub(func):
return func(*args, **kwargs)
return wrapper
@api.route('/subs/<username>/sets')
@api.route('/orders/')
@login_required
def my_order_sets():
return jsonify([
{
'id': op.id,
'name': op.name,
'scheduled': op.scheduled,
'time': op.time,
'weekends': op.weekends,
'weekdays': op.weekdays,
'orders': [{
'id': order.id,
} for order in op.orders]
}
for op
in orders_pool_list(current_user.db_user)
])
@api.route('/orders/<username>/sets')
@login_required
@authorized_sub
def sub_order_sets(username, sub):
@ -57,7 +84,7 @@ def sub_order_sets(username, sub):
in orders_pool_list(sub.id)
])
@api.route('/subs/<username>/sets/', methods=['POST'])
@api.route('/orders/<username>/sets/', methods=['POST'])
@login_required
@authorized_sub
def sub_order_set_create(username, sub):
@ -95,7 +122,7 @@ def sub_order_set_create(username, sub):
return jsonify(new_order_pool.to_dict())
@api.route('/subs/<username>/sets/<set_id>', methods = ['GET', 'POST', 'DELETE'])
@api.route('/orders/<username>/sets/<set_id>', methods = ['GET', 'POST', 'DELETE'])
@login_required
@authorized_sub
def sub_order_set(username, set_id, sub):

View file

@ -49,6 +49,7 @@ def index():
return redirect('/dashboard/')
@app.route('/dashboard/', defaults={'path': ''})
@app.route('/orders/<path:path>')
@app.route('/dashboard/<path:path>')
@login_required
def dashboard(path):

View file

@ -0,0 +1,71 @@
import React from "react";
import { Container, Text, Title, Flex, Card, Image, Box } from "@mantine/core";
import { useLoaderData } from "react-router";
import { IconPencil } from "@tabler/icons-react";
import { NavigateButton } from "./NavigateButton";
import { OrderSetProps, OrderSets } from "./OrderSets";
import { useUserContext } from "./UserContext";
export const subsListLoader = () =>
Promise.all([
fetch("/api/orders/").then((response) => response.json()),
fetch("/api/subs").then((response) => response.json()),
]);
interface SubsListProps {
subs: { sub_username: string; telegram_photo_url: string }[];
}
const SubsList: React.FC<SubsListProps> = ({ subs }) => (
<>
<Title order={1} mb="lg">
Subs
</Title>
<Flex gap="md" wrap="wrap">
{subs.map(({ sub_username, telegram_photo_url }) => (
<Card
key={sub_username}
shadow="sm"
padding="lg"
radius="md"
withBorder
bg="gray.2"
w="320px"
>
{telegram_photo_url ? (
<Card.Section>
<Image
src={telegram_photo_url}
height={280}
alt={`Profile picture for ${sub_username}`}
/>
</Card.Section>
) : null}
<Flex direction="column" gap="md" mt="md" h="100%">
<Text fw={500}>{sub_username}</Text>
<Flex gap="md" justify="end">
<NavigateButton to={`/orders/${sub_username}`}>
<IconPencil style={{ marginRight: "0.5rem" }} />
Edit
</NavigateButton>
</Flex>
</Flex>
</Card>
))}
</Flex>
</>
);
export const Dashboard: React.FC = () => {
const [orderSets, subs] =
useLoaderData<[OrderSetProps["orderSets"], SubsListProps["subs"]]>();
const { username } = useUserContext();
return (
<Container>
<OrderSets orderSets={orderSets} username={username} />
{subs.length > 0 ? <SubsList subs={subs} /> : null}
</Container>
);
};

View file

@ -31,16 +31,16 @@ import {
import { IconMinus, IconPlus } from "@tabler/icons-react";
import { fetchHeaders } from "./fetch";
export const subOrderSetLoader = async ({
export const orderSetLoader = async ({
params: { username, set_id },
}: {
params: Params<string>;
}) =>
fetch(`/api/subs/${username}/sets/${set_id}`).then((response) =>
fetch(`/api/orders/${username}/sets/${set_id}`).then((response) =>
response.json(),
);
export const subOrderSetAction = async ({
export const orderSetAction = async ({
request,
params: { username, set_id: id },
}: {
@ -48,7 +48,7 @@ export const subOrderSetAction = async ({
params: Params<string>;
}) => {
if (request.method == "DELETE") {
const response = await fetch(`/api/subs/${username}/sets/${id}`, {
const response = await fetch(`/api/orders/${username}/sets/${id}`, {
method: "DELETE",
headers: fetchHeaders(),
});
@ -72,7 +72,7 @@ type FormOrderSet = Omit<OrderSet, "orders"> & {
orders: FormOrderSetOrder[];
};
export const SubOrderSet: React.FC = () => {
export const OrderSet: React.FC = () => {
const navigate = useNavigate();
const { username, set_id } = useParams();
const orderSet = useLoaderData<OrderSet>();
@ -80,7 +80,7 @@ export const SubOrderSet: React.FC = () => {
orderSet?.scheduled,
);
const form = useForm<FormOrderSet>({
const form = useForm<Partial<FormOrderSet>>({
mode: "uncontrolled",
initialValues: orderSet ?? {
scheduled: false,
@ -115,7 +115,7 @@ export const SubOrderSet: React.FC = () => {
const handleSubmit = React.useCallback(
form.onSubmit((values) => {
fetch(`/api/subs/${username}/sets/${set_id || ""}`, {
fetch(`/api/orders/${username}/sets/${set_id || ""}`, {
method: "POST",
headers: fetchHeaders(),
body: JSON.stringify(values),
@ -126,7 +126,7 @@ export const SubOrderSet: React.FC = () => {
message: `Updates to ${values.name} were successful`,
color: "green",
});
navigate(`/dashboard/subs/${username}`);
navigate(`/orders/${username}`);
} else {
notifications.show({
title: "Error",
@ -193,7 +193,7 @@ export const SubOrderSet: React.FC = () => {
<Container pb="xl">
<Box mb="lg">
<Title order={1}>{orderSet?.name || "New Order Set"}</Title>
<Link to={`/dashboard/subs/${username}`}>Return to {username}</Link>
<Link to={`/orders/${username}`}>Return to {username}</Link>
</Box>
<form id="order-set" onSubmit={handleSubmit}>
<TextInput {...form.getInputProps("name")} label="Name" />

View file

@ -0,0 +1,95 @@
import React from "react";
import { Title, Card, Text, Flex, Badge, Box } from "@mantine/core";
import { TimeValue } from "@mantine/dates";
import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react";
import { ConfirmDialogButton } from "./ConfirmDialogButton";
import { NavigateButton } from "./NavigateButton";
import { useFetcher } from "react-router";
export interface OrderSetProps {
orderSets: (Pick<
OrderSet,
"id" | "name" | "scheduled" | "time" | "weekends" | "weekdays" | "orders"
> & { orders: Pick<OrderSetOrder, "id" | "name"> })[];
username: string;
linkBack?: React.ReactNode;
}
export const OrderSets: React.FC<OrderSetProps> = ({
orderSets,
username,
linkBack,
}) => {
const fetcher = useFetcher();
const handleDelete = React.useCallback(
(id: number) => {
fetcher.submit(null, {
action: `/orders/${username}/${id}`,
method: "DELETE",
});
},
[fetcher],
);
return (
<>
<Box mb="lg">
<Title order={1}>Order Sets for {username}</Title>
{linkBack ? linkBack : null}
</Box>
<Flex gap="md" wrap="wrap">
{orderSets
? orderSets.map(
({ id, name, scheduled, orders, time, weekdays, weekends }) => (
<Card
key={id}
shadow="sm"
padding="lg"
radius="md"
withBorder
bg="gray.2"
w="400px"
>
<Flex direction="column" gap="md" h="100%">
<Title order={4}>{name}</Title>
{scheduled ? (
<Flex gap="md" align="center">
<Text>
Scheduled: <TimeValue value={time} format="12h" />
</Text>
{weekdays ? <Badge color="blue">Weekdays</Badge> : null}
{weekends ? <Badge color="blue">Weekends</Badge> : null}
</Flex>
) : null}
<Text mb="md" flex={1}>
Orders: {orders.length}
</Text>
<Flex gap="md" justify="end">
<ConfirmDialogButton
buttonColor="red.8"
buttonText="Delete"
text={`Are you sure you want to delete ${name}?`}
onConfirm={() => handleDelete(id)}
>
<IconTrash />
</ConfirmDialogButton>
<NavigateButton to={`/orders/${username}/${id}`}>
<IconPencil style={{ marginRight: "0.5rem" }} />
Edit
</NavigateButton>
</Flex>
</Flex>
</Card>
),
)
: null}
</Flex>
<NavigateButton to={`/orders/${username}/new`}>
<IconPlus style={{ marginRight: "0.5rem" }} />
New
</NavigateButton>
<Box mb="lg"></Box>
</>
);
};

View file

@ -11,95 +11,25 @@ import {
} from "react-router";
import { ConfirmDialogButton } from "./ConfirmDialogButton";
import { NavigateButton } from "./NavigateButton";
import { fetchHeaders } from "./fetch";
import { OrderSetProps, OrderSets } from "./OrderSets";
export const subOrderSetsLoader = async ({
params: { username },
}: {
params: Params<string>;
}) => fetch(`/api/subs/${username}/sets`).then((response) => response.json());
}) => fetch(`/api/orders/${username}/sets`).then((response) => response.json());
export const SubOrderSets: React.FC = () => {
const fetcher = useFetcher();
const { username: sub_username } = useParams();
const orderSets = useLoaderData<
(Pick<
OrderSet,
"id" | "name" | "scheduled" | "time" | "weekends" | "weekdays" | "orders"
> & {
orders: Pick<OrderSetOrder, "id" | "name">[];
})[]
>();
const handleDelete = React.useCallback(
(id: number) => {
fetcher.submit(null, {
action: `/dashboard/subs/${sub_username}/${id}`,
method: "DELETE",
});
},
[fetcher],
);
const orderSets = useLoaderData<OrderSetProps["orderSets"]>();
return (
<Container>
<Box mb="lg">
<Title order={1}>Order Sets for {sub_username}</Title>
<Link to={`/dashboard/`}>Return to all subs</Link>
</Box>
<Flex gap="md" wrap="wrap">
{orderSets
? orderSets.map(
({ id, name, scheduled, orders, time, weekdays, weekends }) => (
<Card
key={id}
shadow="sm"
padding="lg"
radius="md"
withBorder
bg="gray.2"
w="400px"
>
<Flex direction="column" gap="md" h="100%">
<Title order={4}>{name}</Title>
{scheduled ? (
<Flex gap="md" align="center">
<Text>
Scheduled: <TimeValue value={time} format="12h" />
</Text>
{weekdays ? <Badge color="blue">Weekdays</Badge> : null}
{weekends ? <Badge color="blue">Weekends</Badge> : null}
</Flex>
) : null}
<Text mb="md" flex={1}>
Orders: {orders.length}
</Text>
<Flex gap="md" justify="end">
<ConfirmDialogButton
buttonColor="red.8"
buttonText="Delete"
text={`Are you sure you want to delete ${name}?`}
onConfirm={() => handleDelete(id)}
>
<IconTrash />
</ConfirmDialogButton>
<NavigateButton
to={`/dashboard/subs/${sub_username}/${id}`}
>
<IconPencil style={{ marginRight: "0.5rem" }} />
Edit
</NavigateButton>
</Flex>
</Flex>
</Card>
),
)
: null}
</Flex>
<NavigateButton to={`/dashboard/subs/${sub_username}/new`}>
<IconPlus style={{ marginRight: "0.5rem" }} />
New
</NavigateButton>
<OrderSets
username={sub_username}
orderSets={orderSets}
linkBack={<Link to={`/dashboard/`}>Return to dashboard</Link>}
/>
</Container>
);
};

View file

@ -1,54 +0,0 @@
import React from "react";
import { Container, Text, Title, Flex, Card, Image } from "@mantine/core";
import { useLoaderData } from "react-router";
import { IconPencil } from "@tabler/icons-react";
import { NavigateButton } from "./NavigateButton";
export const subsListLoader = () =>
fetch("/api/subs").then((response) => response.json());
export const SubsList: React.FC = () => {
const subs =
useLoaderData<{ sub_username: string; telegram_photo_url: string }[]>();
return (
<Container>
<Title order={1} mb="lg">
Subs
</Title>
<Flex gap="md" wrap="wrap">
{subs.map(({ sub_username, telegram_photo_url }) => (
<Card
key={sub_username}
shadow="sm"
padding="lg"
radius="md"
withBorder
bg="gray.2"
w="320px"
>
{telegram_photo_url ? (
<Card.Section>
<Image
src={telegram_photo_url}
height={280}
alt={`Profile picture for ${sub_username}`}
/>
</Card.Section>
) : null}
<Flex direction="column" gap="md" mt="md" h="100%">
<Text fw={500}>{sub_username}</Text>
<Flex gap="md" justify="end">
<NavigateButton to={`/dashboard/subs/${sub_username}`}>
<IconPencil style={{ marginRight: "0.5rem" }} />
Edit
</NavigateButton>
</Flex>
</Flex>
</Card>
))}
</Flex>
</Container>
);
};

View file

@ -0,0 +1,35 @@
import React from "react";
export interface UserContextData {
username?: string;
}
const UserContext = React.createContext<UserContextData>({});
export const useUserContext = (): UserContextData => {
return React.useContext(UserContext);
};
export const UserContextProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [username, setUsername] = React.useState();
React.useEffect(() => {
fetch(`/api/me`)
.then((response) => response.json())
.then(({ username }) => {
setUsername(username);
});
}, []);
return (
<UserContext.Provider
value={{
username,
}}
>
{children}
</UserContext.Provider>
);
};

View file

@ -15,13 +15,10 @@ import "@mantine/core/styles.css";
import "@mantine/dates/styles.css";
import "@mantine/notifications/styles.css";
import { SubsList, subsListLoader } from "./SubsList";
import { Dashboard, subsListLoader } from "./Dashboard";
import { SubOrderSets, subOrderSetsLoader } from "./SubOrderSets";
import {
SubOrderSet,
subOrderSetLoader,
subOrderSetAction,
} from "./SubOrderSet";
import { OrderSet, orderSetLoader, orderSetAction } from "./OrderSet";
import { UserContextProvider } from "./UserContext";
const theme = createTheme({
components: {
@ -52,31 +49,33 @@ const theme = createTheme({
const router = createBrowserRouter([
{
path: "dashboard",
Component: SubsList,
Component: Dashboard,
loader: subsListLoader,
},
{
path: "dashboard/subs/:username/",
path: "orders/:username",
Component: SubOrderSets,
loader: subOrderSetsLoader,
},
{
path: "dashboard/subs/:username/new",
Component: SubOrderSet,
path: "orders/:username/new",
Component: OrderSet,
},
{
path: "dashboard/subs/:username/:set_id",
Component: SubOrderSet,
loader: subOrderSetLoader,
action: subOrderSetAction,
path: "orders/:username/:set_id",
Component: OrderSet,
loader: orderSetLoader,
action: orderSetAction,
},
]);
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<MantineProvider theme={theme}>
<Notifications />
<RouterProvider router={router} />
<UserContextProvider>
<Notifications />
<RouterProvider router={router} />
</UserContextProvider>
</MantineProvider>
</React.StrictMode>,
);

8
flask/vite/tsconfig.json Normal file
View file

@ -0,0 +1,8 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2015", "DOM"],
"typeRoots": ["src/*.d.ts"],
"esModuleInterop": true
}
}