Add delete button for series/movies/collections (#1386)

This commit is contained in:
Zoe Roux 2026-03-22 00:59:27 +01:00 committed by GitHub
commit 59a20a627c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 291 additions and 65 deletions

View File

@ -1,8 +1,11 @@
import Elysia from "elysia";
import { and, eq } from "drizzle-orm";
import Elysia, { t } from "elysia";
import { db } from "~/db";
import { shows } from "~/db/schema";
import { KError } from "~/models/error";
import { SeedMovie } from "~/models/movie";
import { SeedSerie } from "~/models/serie";
import { Resource } from "~/models/utils";
import { isUuid, Resource } from "~/models/utils";
import { comment } from "~/utils";
import { SeedMovieResponse, seedMovie } from "./movies";
import { SeedSerieResponse, seedSerie } from "./series";
@ -77,4 +80,121 @@ export const seed = new Elysia()
422: KError,
},
},
)
.delete(
"/movies/:id",
async ({ params: { id }, status }) => {
const [deleted] = await db
.delete(shows)
.where(
and(
eq(shows.kind, "movie"),
isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id),
),
)
.returning({ id: shows.id, slug: shows.slug });
if (!deleted) {
return status(404, {
status: 404,
message: "No movie found with the given id or slug.",
});
}
return deleted;
},
{
detail: {
tags: ["movies"],
description: "Delete a movie by id or slug.",
},
params: t.Object({
id: t.String({
description: "The id or slug of the movie to delete.",
}),
}),
response: {
200: Resource(),
404: {
...KError,
description: "No movie found with the given id or slug.",
},
},
},
)
.delete(
"/series/:id",
async ({ params: { id }, status }) => {
const [deleted] = await db
.delete(shows)
.where(
and(
eq(shows.kind, "serie"),
isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id),
),
)
.returning({ id: shows.id, slug: shows.slug });
if (!deleted) {
return status(404, {
status: 404,
message: "No serie found with the given id or slug.",
});
}
return deleted;
},
{
detail: {
tags: ["series"],
description: "Delete a serie by id or slug.",
},
params: t.Object({
id: t.String({
description: "The id or slug of the serie to delete.",
}),
}),
response: {
200: Resource(),
404: {
...KError,
description: "No serie found with the given id or slug.",
},
},
},
)
.delete(
"/collections/:id",
async ({ params: { id }, status }) => {
const [deleted] = await db
.delete(shows)
.where(
and(
eq(shows.kind, "collection"),
isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id),
),
)
.returning({ id: shows.id, slug: shows.slug });
if (!deleted) {
return status(404, {
status: 404,
message: "No collection found with the given id or slug.",
});
}
return deleted;
},
{
detail: {
tags: ["collections"],
description: "Delete a collection by id or slug.",
},
params: t.Object({
id: t.String({
description: "The id or slug of the collection to delete.",
}),
}),
response: {
200: Resource(),
404: {
...KError,
description: "No collection found with the given id or slug.",
},
},
},
);

View File

@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/base64"
"errors"
"fmt"
"log/slog"
"net/http"
@ -38,6 +39,7 @@ func ErrorHandler(c *echo.Context, err error) {
code := http.StatusInternalServerError
var message string
var sc echo.HTTPStatusCoder
if he, ok := err.(*echo.HTTPError); ok {
code = he.Code
@ -46,8 +48,12 @@ func ErrorHandler(c *echo.Context, err error) {
if message == "missing or malformed jwt" {
code = http.StatusUnauthorized
}
} else if errors.As(err, &sc) {
if tmp := sc.StatusCode(); tmp != 0 {
code = tmp
}
} else {
c.Logger().Error(err.Error())
c.Logger().Error("Unhandled error", slog.Any("err", err))
}
c.JSON(code, KError{
@ -171,14 +177,25 @@ func (h *Handler) TokenToJwt(next echo.HandlerFunc) echo.HandlerFunc {
jwt = &token
} else {
auth := c.Request().Header.Get("Authorization")
var token string
if auth == "" || !strings.HasPrefix(auth, "Bearer ") {
if auth == "" {
cookie, _ := c.Request().Cookie("X-Bearer")
if cookie != nil {
token = cookie.Value
}
} else if strings.HasPrefix(auth, "Bearer ") {
token = auth[len("Bearer "):]
} else if auth != "" {
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid bearer format.")
}
if token == "" {
jwt = h.createGuestJwt()
if jwt == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Guests not allowed.")
}
} else {
token := auth[len("Bearer "):]
// this is only used to check if it is a session token or a jwt
_, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {

View File

@ -57,7 +57,6 @@
"react-native-worklets": "0.7.2",
"react-tooltip": "^5.30.0",
"react-use-websocket": "^4.13.0",
"sweetalert2": "^11.26.22",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"tsx": "^4.21.0",
@ -1476,8 +1475,6 @@
"svgo": ["svgo@3.3.3", "", { "dependencies": { "commander": "^7.2.0", "css-select": "^5.1.0", "css-tree": "^2.3.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.0.0", "sax": "^1.5.0" }, "bin": "./bin/svgo" }, "sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng=="],
"sweetalert2": ["sweetalert2@11.26.24", "", {}, "sha512-SLgukW4wicewpW5VOukSXY5Z6DL/z7HCOK2ODSjmQPiSphCN8gJAmh9npoceXOtBRNoDN0xIz+zHYthtfiHmjg=="],
"tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
"tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="],

View File

@ -68,7 +68,7 @@
"react-native-worklets": "0.7.2",
"react-tooltip": "^5.30.0",
"react-use-websocket": "^4.13.0",
"sweetalert2": "^11.26.22",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"tsx": "^4.21.0",

View File

@ -117,6 +117,7 @@
"prev-page": "Previous page",
"next-page": "Next page",
"delete": "Delete",
"delete-name": "Delete {{name}}",
"cancel": "Cancel",
"more": "More",
"expand": "Expand",

View File

@ -5,14 +5,13 @@ import {
type AlertOptions,
Alert as RNAlert,
} from "react-native";
import type { SweetAlertIcon } from "sweetalert2";
export interface ExtendedAlertStatic {
alert: (
title: string,
message?: string,
buttons?: AlertButton[],
options?: AlertOptions & { icon?: SweetAlertIcon },
options?: AlertOptions,
) => void;
}

View File

@ -1,7 +1,72 @@
// Stolen from https://github.com/necolas/react-native-web/issues/1026#issuecomment-1458279681
import { createRoot } from "react-dom/client";
import type { AlertButton, AlertOptions } from "react-native";
import Swal, { type SweetAlertIcon } from "sweetalert2";
import { Pressable, View } from "react-native";
import { cn } from "~/utils";
import { Heading, P } from "./text";
const AlertDialog = ({
title,
message,
buttons,
onDismiss,
}: {
title: string;
message?: string;
buttons: AlertButton[];
onDismiss: () => void;
}) => {
return (
<Pressable
className="absolute inset-0 cursor-default! items-center justify-center bg-black/60"
onPress={onDismiss}
>
<Pressable
className={cn(
"w-full max-w-md rounded-md bg-background",
"cursor-default! overflow-hidden",
)}
onPress={(e) => e.preventDefault()}
>
<View className="items-center gap-2 p-6">
<Heading>{title}</Heading>
{message && <P className="text-center">{message}</P>}
</View>
<View className="flex-row justify-end gap-2 p-4">
{buttons.map((button, i) => (
<Pressable
key={i}
onPress={() => {
onDismiss();
button.onPress?.();
}}
className={cn(
"rounded px-4 py-2",
button.style === "destructive"
? "bg-red-700 hover:bg-red-500 focus:bg-red-500"
: button.style === "cancel"
? "hover:bg-popover focus:bg-popover"
: "bg-accent hover:bg-accent/50 focus:bg-accent/50",
)}
>
<P
className={cn(
button.style === "destructive" &&
"text-slate-800 dark:text-slate-800",
button.style === "cancel" &&
"text-slate-600 dark:text-slate-400",
button.style === "default" &&
"text-slate-200 dark:text-slate-200",
)}
>
{button.text ?? "OK"}
</P>
</Pressable>
))}
</View>
</Pressable>
</Pressable>
);
};
// biome-ignore lint/complexity/noStaticOnlyClass: Compatibility with rn
export class Alert {
@ -9,42 +74,39 @@ export class Alert {
title: string,
message?: string,
buttons?: AlertButton[],
options?: AlertOptions & { icon?: SweetAlertIcon },
options?: AlertOptions,
): void {
const confirmButton = buttons
? buttons.find((button) => button.style === "default")
: undefined;
const denyButton = buttons
? buttons.find((button) => button.style === "destructive")
: undefined;
const cancelButton = buttons
? buttons.find((button) => button.style === "cancel")
: undefined;
const resolvedButtons = buttons ?? [
{ text: "OK", style: "default" as const },
];
Swal.fire({
title: title,
text: message,
showConfirmButton: !!confirmButton,
showDenyButton: !!denyButton,
showCancelButton: !!cancelButton,
confirmButtonText: confirmButton?.text,
denyButtonText: denyButton?.text,
cancelButtonText: cancelButton?.text,
icon: options?.icon,
}).then((result) => {
if (result.isConfirmed) {
if (confirmButton?.onPress !== undefined) {
confirmButton.onPress();
}
} else if (result.isDenied) {
if (denyButton?.onPress !== undefined) {
denyButton.onPress();
}
} else if (result.isDismissed) {
if (cancelButton?.onPress !== undefined) {
cancelButton.onPress();
}
}
});
const container = document.createElement("div");
container.style.position = "fixed";
container.style.inset = "0";
container.style.zIndex = "9999";
document.body.appendChild(container);
const root = createRoot(container);
const dismiss = () => {
root.unmount();
container.remove();
};
const cancelButton = resolvedButtons.find((b) => b.style === "cancel");
root.render(
<AlertDialog
title={title}
message={message}
buttons={resolvedButtons}
onDismiss={() => {
if (options?.cancelable !== false) {
dismiss();
cancelButton?.onPress?.();
}
}}
/>,
);
}
}

View File

@ -3,7 +3,7 @@ import { createMMKV, useMMKVString } from "react-native-mmkv";
import type { ZodType, z } from "zod/v4";
import { getServerData } from "~/utils";
export const storage = createMMKV();
export const storage = createMMKV({ id: "kyoo-v5" });
function toBase64(utf8: string) {
if (typeof window !== "undefined") return window.btoa(utf8);
@ -35,7 +35,6 @@ export const setCookie = (
export const readCookie = <T extends ZodType>(key: string, parser: T) => {
const cookies = getServerData("cookies");
console.log("cookies", cookies);
const decodedCookie = decodeURIComponent(cookies);
const ca = decodedCookie.split(";");

View File

@ -1,7 +1,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { type Entry, FullVideo, type Page } from "~/models";
import { type Entry, type Episode, FullVideo, type Page } from "~/models";
import { Modal, P } from "~/primitives";
import {
InfiniteFetch,
@ -32,17 +32,17 @@ export const useEditLinks = (
body: [
{
id: video,
for: entries.map((x) =>
x.kind === "episode" && !x.slug
? {
serie: slug,
// @ts-expect-error: idk why it couldn't match x as an episode
season: x.seasonNumber,
// @ts-expect-error: idk why it couldn't match x as an episode
episode: x.episodeNumber,
}
: { slug: x.slug },
),
for: entries.map((x) => {
if (x.slug) return { slug: x.slug };
const ep = x as Episode;
if (ep.seasonNumber === 0)
return { serie: slug, special: ep.episodeNumber };
return {
serie: slug,
season: ep.seasonNumber,
episoed: ep.episodeNumber,
};
}),
},
],
}),

View File

@ -1,9 +1,11 @@
import BookmarkAdd from "@material-symbols/svg-400/rounded/bookmark_add.svg";
import Delete from "@material-symbols/svg-400/rounded/delete.svg";
import MoreHoriz from "@material-symbols/svg-400/rounded/more_horiz.svg";
import MovieInfo from "@material-symbols/svg-400/rounded/movie_info.svg";
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
import Theaters from "@material-symbols/svg-400/rounded/theaters-fill.svg";
import VideoLibrary from "@material-symbols/svg-400/rounded/video_library-fill.svg";
import { useRouter } from "expo-router";
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
@ -22,6 +24,7 @@ import {
import type { Metadata } from "~/models/utils/metadata";
import {
A,
Alert,
Chip,
Container,
capitalize,
@ -45,7 +48,7 @@ import {
usePopup,
} from "~/primitives";
import { useAccount } from "~/providers/account-context";
import { Fetch, type QueryIdentifier } from "~/query";
import { Fetch, type QueryIdentifier, useMutation } from "~/query";
import { cn, displayRuntime, getDisplayDate } from "~/utils";
import { PartOf } from "./part-of";
@ -72,7 +75,13 @@ const ButtonList = ({
}) => {
const account = useAccount();
const { t } = useTranslation();
const router = useRouter();
const [setPopup, closePopup] = usePopup();
const deleteMutation = useMutation({
method: "DELETE",
path: ["api", `${kind}s`, slug],
invalidate: ["api", "shows"],
});
// const metadataRefreshMutation = useMutation({
// method: "POST",
@ -154,6 +163,29 @@ const ButtonList = ({
icon={VideoLibrary}
href={`/${kind === "movie" ? "movies" : "series"}/${slug}/videos`}
/>
{kind !== "collection" && <HR />}
<Menu.Item
label={t("misc.delete")}
icon={Delete}
onSelect={() => {
Alert.alert(
t("misc.delete-name", { name }),
t("login.delete-confirmation"),
[
{ text: t("misc.cancel"), style: "cancel" },
{
text: t("misc.delete"),
style: "destructive",
onPress: async () => {
await deleteMutation.mutateAsync();
router.back();
},
},
],
{ cancelable: true },
);
}}
/>
{/* <Menu.Item */}
{/* label={t("home.refreshMetadata")} */}
{/* icon={Refresh} */}

View File

@ -91,7 +91,6 @@ export const AccountSettings = () => {
{
cancelable: true,
userInterfaceStyle: theme as "light" | "dark",
icon: "warning",
},
);
}}