diff --git a/package.json b/package.json index 7372da7..16bec2b 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ }, "dependencies": { "color": "^4.2.3", - "decky-frontend-lib": "^3.24.3", + "decky-frontend-lib": "^3.24.5", "lodash": "^4.17.21", "react-icons": "^4.12.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af6d5dc..f39b680 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ dependencies: specifier: ^4.2.3 version: 4.2.3 decky-frontend-lib: - specifier: ^3.24.3 - version: 3.24.3 + specifier: ^3.24.5 + version: 3.24.5 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -711,8 +711,8 @@ packages: resolution: {integrity: sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==} dev: true - /decky-frontend-lib@3.24.3: - resolution: {integrity: sha512-293oUaAgLrezvoz+TOQkarjwAlVlejkelB1WjtxQV4Y5qMpUZhNUtfpQAscGhwg9oQy6UGpZ5urkdPzLiVY52w==} + /decky-frontend-lib@3.24.5: + resolution: {integrity: sha512-eYlbKDOOcIBPI0b76Rqvlryq2ym/QNiry4xf2pFrXmBa1f95dflqbQAb2gTq9uHEa5gFmeV4lUcMPGJ3M14Xqw==} dev: false /decode-uri-component@0.2.2: @@ -860,8 +860,8 @@ packages: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} dev: true - /fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + /fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] requiresBuild: true @@ -1597,7 +1597,7 @@ packages: engines: {node: '>=10.0.0'} hasBin: true optionalDependencies: - fsevents: 2.3.3 + fsevents: 2.3.2 dev: true /safe-buffer@5.2.1: diff --git a/src/apiTypes/SubmissionTypes.ts b/src/apiTypes/SubmissionTypes.ts new file mode 100644 index 0000000..d69f945 --- /dev/null +++ b/src/apiTypes/SubmissionTypes.ts @@ -0,0 +1,16 @@ +export interface TaskQueryResponse { + id: string; + name: string; + status: string; + completed: Date; + started: Date; + success: boolean; +} + +export interface ZipSubmitRequest { + blob: string; + description: string; + privateSubmission: boolean; + imageBlobs: string[]; + target?: string; +} diff --git a/src/apiTypes/index.ts b/src/apiTypes/index.ts index f83d273..5fa3f61 100644 --- a/src/apiTypes/index.ts +++ b/src/apiTypes/index.ts @@ -1,3 +1,5 @@ export * from "./CSSThemeTypes"; export * from "./AccountData"; export * from "./BlobTypes"; +export * from "./Motd"; +export * from "./SubmissionTypes"; diff --git a/src/backend/apiHelpers/fetchWrappers.ts b/src/backend/apiHelpers/fetchWrappers.ts new file mode 100644 index 0000000..7457f12 --- /dev/null +++ b/src/backend/apiHelpers/fetchWrappers.ts @@ -0,0 +1,100 @@ +import { refreshToken } from "../../api"; +import { toast } from "../../python"; +import { globalState, server } from "../pythonRoot"; + +function createHeadersObj(authToken: string | undefined, request: RequestInit | undefined) { + const headers = new Headers(); + if (request && request.headers) { + for (const [key, value] of Object.entries(request.headers)) { + headers.append(key, value); + } + } + if (authToken) headers.set("Authorization", `Bearer ${authToken}`); + + return headers; +} + +export async function genericApiFetch( + fetchPath: string, + request: RequestInit | undefined = undefined, + options: { + requiresAuth?: boolean; + onError?: () => void; + customAuthToken?: string; + failSilently?: boolean; + } = { + requiresAuth: false, + customAuthToken: undefined, + onError: () => {}, + failSilently: false, + } +) { + const { + requiresAuth = false, + customAuthToken = undefined, + onError = () => {}, + failSilently = false, + } = options; + + const { apiUrl } = globalState!.getPublicState(); + function doTheFetching(authToken: string | undefined = undefined) { + const headers = createHeadersObj(authToken, request); + console.log("TOKEN", authToken); + + console.log("TEST", { + method: "GET", + // If a custom method is specified in request it will overwrite + ...request, + headers: headers, + }); + return server! + .fetchNoCors(`${apiUrl}${fetchPath}`, { + method: "GET", + // If a custom method is specified in request it will overwrite + ...request, + headers: headers, + }) + .then((deckyRes) => { + if (deckyRes.success) { + return deckyRes.result; + } + throw new Error(`Fetch not successful!`); + }) + .then((res) => { + if (res.status >= 200 && res.status <= 300 && res.body) { + // @ts-ignore + return JSON.parse(res.body || ""); + } + throw new Error(`Res not OK!, code ${res.status} - ${res.body}`); + }) + .then((json) => { + console.log("JSON", json); + + if (json) { + return json; + } + throw new Error(`No json returned!`); + }) + .catch((err) => { + if (!failSilently) { + console.error(`Error fetching ${fetchPath}`, err); + } + onError(); + }); + } + if (requiresAuth) { + if (customAuthToken) { + return doTheFetching(customAuthToken); + } + return refreshToken(onError).then((token) => { + if (token) { + return doTheFetching(token); + } else { + toast("Error Refreshing Token!", ""); + return; + } + }); + } else { + return doTheFetching(); + } +} diff --git a/src/backend/apiHelpers/index.ts b/src/backend/apiHelpers/index.ts new file mode 100644 index 0000000..8113a2a --- /dev/null +++ b/src/backend/apiHelpers/index.ts @@ -0,0 +1,2 @@ +export * from "./fetchWrappers"; +export * from "./fetchWrappers"; diff --git a/src/backend/apiHelpers/profileUploadingHelpers.ts b/src/backend/apiHelpers/profileUploadingHelpers.ts new file mode 100644 index 0000000..0f82d50 --- /dev/null +++ b/src/backend/apiHelpers/profileUploadingHelpers.ts @@ -0,0 +1,74 @@ +import { TaskQueryResponse } from "../../apiTypes/SubmissionTypes"; +import { genericApiFetch } from "./fetchWrappers"; + +async function fetchLocalZip(fileName: string) { + if (!fileName.endsWith(".zip")) { + throw new Error(`File must be a .zip!`); + } + const filesRes = await fetch(`/themes_custom/${fileName}`); + if (!filesRes.ok) { + throw new Error(`Couldn't fetch zip!`); + } + const rawBlob = await filesRes.blob(); + const correctBlob = new Blob([rawBlob], { type: "application/x-zip-compressed" }); + return correctBlob; +} + +export async function uploadZipAsBlob(fileName: string): Promise { + if (!fileName.endsWith(".zip")) { + throw new Error(`File must be a .zip!`); + } + const fileBlob = await fetchLocalZip(fileName); + console.log("BLOB", fileBlob); + const formData = new FormData(); + formData.append("File", fileBlob, fileName); + console.log("FORM", formData); + + const json = await genericApiFetch( + "/blobs", + { + method: "POST", + headers: { + "Content-Type": "multipart/form-data", + }, + body: formData, + }, + { requiresAuth: true } + ); + if (json) { + return json; + } + throw new Error(`No json returned!`); +} + +export async function publishProfile( + profileId: string, + isPublic: boolean, + description: string +): Promise { + // const zipName = `${profileId}.zip`; + const zipName = "round.zip"; + const blobId = await uploadZipAsBlob(zipName); + if (!blobId) throw new Error(`No blobId returned!`); + + // ALL OF THIS IS UNTESTED, BLOB IS 415'ing RN + const json = await genericApiFetch( + `/submissions/css_zip`, + { + method: "POST", + body: JSON.stringify({ + blob: blobId, + imageBlobs: [], + description: description, + privateSubmission: !isPublic, + }), + }, + { requiresAuth: true } + ); + if (!json || !json.task) throw new Error(`No task returned`); + return json.task; +} + +export async function getTaskStatus(taskId: string): Promise { + return await genericApiFetch(`/tasks/${taskId}`, undefined, { requiresAuth: true }); +} diff --git a/src/components/Modals/UploadProfileModal.tsx b/src/components/Modals/UploadProfileModal.tsx index 67c294a..34dbcb3 100644 --- a/src/components/Modals/UploadProfileModal.tsx +++ b/src/components/Modals/UploadProfileModal.tsx @@ -11,6 +11,8 @@ import { } from "decky-frontend-lib"; import { useMemo, useState } from "react"; import { Flags } from "../../ThemeTypes"; +import { publishProfile } from "../../backend/apiHelpers/profileUploadingHelpers"; +import { TaskStatus } from "../TaskStatus"; export function UploadProfileModalRoot({ closeModal }: { closeModal?: any }) { return ( @@ -25,7 +27,7 @@ export function UploadProfileModalRoot({ closeModal }: { closeModal?: any }) { function UploadProfileModal() { const { localThemeList } = useCssLoaderState(); - let [selectedProfile, setProfile] = useState( + const [selectedProfile, setProfile] = useState( localThemeList.find((e) => e.flags.includes(Flags.isPreset))?.id ); const profiles = useMemo(() => { @@ -38,6 +40,31 @@ function UploadProfileModal() { const [isPublic, setPublic] = useState(false); const [description, setDescription] = useState(""); + const [uploadStatus, setUploadStatus] = useState< + "idle" | "submitting" | "taskStatus" | "completed" + >("idle"); + const [taskId, setTaskId] = useState(undefined); + + async function onUpload() { + if (!selectedProfile) return; + setUploadStatus("submitting"); + // eventually run the submit here + const taskId = await publishProfile(selectedProfile, isPublic, description); + setUploadStatus("taskStatus"); + setTaskId(taskId); + } + + function onTaskFinish(success: boolean) { + setUploadStatus("completed"); + if (success) { + // closeModal(); + } + } + + if (uploadStatus === "taskStatus" && taskId) { + return ; + } + return ( Upload Profile diff --git a/src/components/QAMTab/QAMThemeToggleList.tsx b/src/components/QAMTab/QAMThemeToggleList.tsx index 7f2aa49..aacb9e4 100644 --- a/src/components/QAMTab/QAMThemeToggleList.tsx +++ b/src/components/QAMTab/QAMThemeToggleList.tsx @@ -1,10 +1,11 @@ -import { Focusable } from "decky-frontend-lib"; +import { DialogButton, Focusable } from "decky-frontend-lib"; import { useCssLoaderState } from "../../state"; import { ThemeToggle } from "../ThemeToggle"; import { Flags } from "../../ThemeTypes"; import { ThemeErrorCard } from "../ThemeErrorCard"; import { BsArrowDown } from "react-icons/bs"; import { FaEyeSlash } from "react-icons/fa"; +import { uploadZipAsBlob } from "../../backend/apiHelpers/profileUploadingHelpers"; export function QAMThemeToggleList() { const { localThemeList, unpinnedThemes } = useCssLoaderState(); @@ -39,6 +40,13 @@ export function QAMThemeToggleList() { `} + { + uploadZipAsBlob("round.zip"); + }} + > + TEST + <> {localThemeList .filter((e) => !unpinnedThemes.includes(e.id) && !e.flags.includes(Flags.isPreset)) diff --git a/src/components/TaskStatus.tsx b/src/components/TaskStatus.tsx new file mode 100644 index 0000000..7fc0b2f --- /dev/null +++ b/src/components/TaskStatus.tsx @@ -0,0 +1,153 @@ +import { useState, useEffect } from "react"; +import { TaskQueryResponse } from "../apiTypes/SubmissionTypes"; +import { getTaskStatus } from "../backend/apiHelpers/profileUploadingHelpers"; +import { BsCheckCircleFill, BsXCircleFill } from "react-icons/bs"; +import { ImSpinner5 } from "react-icons/im"; + +export function TaskStatus({ + task, + onFinish, +}: { + task: string; + onFinish: (success: boolean) => void; +}) { + const [apiStatus, setStatus] = useState(null); + + async function getStatus() { + if (task) { + const data = await getTaskStatus(task); + setStatus(data); + } + } + useEffect(() => { + if (apiStatus?.completed === null) { + setTimeout(() => { + getStatus(); + }, 1000); + } + if (apiStatus?.completed) { + onFinish(apiStatus.success); + } + }, [apiStatus]); + + useEffect(() => { + getStatus(); + }, [task]); + + if (!apiStatus) { + return Loading; + } + + // This is 100% ripped from deckthemes + // Eventually please do re-do this + return ( + <> + +
+
+ {apiStatus.name} + Task {task?.split("-")[0]} +
+ {apiStatus.completed ? ( + <> +
+ {apiStatus.success ? ( +
+ + Success +
+ ) : ( +
+ + Failed +
+ )} +
+ + {apiStatus?.success ? "Completed " : "Failed "}In{" "} + + {(new Date(apiStatus.completed).valueOf() - new Date(apiStatus.started).valueOf()) / + 1000}{" "} + + Seconds + + {!apiStatus.success && {apiStatus.status}} + + ) : ( + <> +
+ + Processing +
+ {apiStatus.status} + + )} +
+ + ); +}