mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-20 19:51:09 +10:00
Rearchitecture for v0.4.0 (#197)
* feat: database redist support * feat: rearchitecture of database schemas, migration reset, and #180 * feat: import redists * fix: giantbomb logging bug * feat: partial user platform support + statusMessage -> message * feat: add user platform filters to store view * fix: sanitize svg uploads ... copilot suggested this I feel dirty. * feat: beginnings of platform & redist management * feat: add server side redist patching * fix: update drop-base commit * feat: import of custom platforms & file extensions * fix: redelete platform * fix: remove platform * feat: uninstall commands, new R UI * checkpoint: before migrating to nuxt v4 * update to nuxt 4 * fix: fixes for Nuxt v4 update * fix: remaining type issues * feat: initial feedback to import other kinds of versions * working commit * fix: lint * feat: redist import
This commit is contained in:
48
app/composables/collection.ts
Normal file
48
app/composables/collection.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import type {
|
||||
CollectionModel,
|
||||
CollectionEntryModel,
|
||||
GameModel,
|
||||
} from "~~/prisma/client/models";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
|
||||
type FullCollection = CollectionModel & {
|
||||
entries: Array<CollectionEntryModel & { game: SerializeObject<GameModel> }>;
|
||||
};
|
||||
|
||||
export const useCollections = async () => {
|
||||
// @ts-expect-error undefined is used to tell if value has been fetched or not
|
||||
const state = useState<FullCollection[]>("collections", () => undefined);
|
||||
if (state.value === undefined) {
|
||||
state.value = await $dropFetch<FullCollection[]>("/api/v1/collection");
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export async function refreshCollection(id: string) {
|
||||
const state = useState<FullCollection[]>("collections");
|
||||
const collection = await $dropFetch<FullCollection>(
|
||||
`/api/v1/collection/${id}`,
|
||||
);
|
||||
const index = state.value.findIndex((e) => e.id == id);
|
||||
if (index == -1) {
|
||||
state.value.push(collection);
|
||||
return;
|
||||
}
|
||||
state.value[index] = collection;
|
||||
}
|
||||
|
||||
export const useLibrary = async () => {
|
||||
// @ts-expect-error undefined is used to tell if value has been fetched or not
|
||||
const state = useState<FullCollection>("library", () => undefined);
|
||||
if (state.value === undefined) {
|
||||
await refreshLibrary();
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export async function refreshLibrary() {
|
||||
const state = useState<FullCollection>("library");
|
||||
state.value = await $dropFetch<FullCollection>("/api/v1/collection/default");
|
||||
}
|
||||
30
app/composables/current-page-engine.ts
Normal file
30
app/composables/current-page-engine.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import type { RouteLocationNormalized } from "vue-router";
|
||||
import type { NavigationItem } from "./types";
|
||||
|
||||
export const useCurrentNavigationIndex = (
|
||||
navigation: Array<NavigationItem>,
|
||||
) => {
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const currentNavigation = ref(-1);
|
||||
|
||||
function calculateCurrentNavIndex(to: RouteLocationNormalized) {
|
||||
const validOptions = navigation
|
||||
.map((e, i) => ({ ...e, index: i }))
|
||||
.filter((e) => to.fullPath.startsWith(e.prefix));
|
||||
const bestOption = validOptions
|
||||
.sort((a, b) => b.route.length - a.route.length)
|
||||
.at(0);
|
||||
|
||||
return bestOption?.index ?? -1;
|
||||
}
|
||||
|
||||
currentNavigation.value = calculateCurrentNavIndex(route);
|
||||
|
||||
router.afterEach((to) => {
|
||||
currentNavigation.value = calculateCurrentNavIndex(to);
|
||||
});
|
||||
|
||||
return currentNavigation;
|
||||
};
|
||||
41
app/composables/news.ts
Normal file
41
app/composables/news.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import type { ArticleModel } from "~~/prisma/client/models";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
|
||||
export const useNews = () =>
|
||||
useState<
|
||||
| Array<
|
||||
SerializeObject<
|
||||
ArticleModel & {
|
||||
tags: Array<{ id: string; name: string }>;
|
||||
author: { displayName: string; id: string } | null;
|
||||
}
|
||||
>
|
||||
>
|
||||
| undefined
|
||||
>("news", () => undefined);
|
||||
|
||||
export const fetchNews = async (options?: {
|
||||
limit?: number;
|
||||
skip?: number;
|
||||
orderBy?: "asc" | "desc";
|
||||
tags?: string[];
|
||||
search?: string;
|
||||
}) => {
|
||||
const query = new URLSearchParams();
|
||||
|
||||
if (options?.limit) query.set("limit", options.limit.toString());
|
||||
if (options?.skip) query.set("skip", options.skip.toString());
|
||||
if (options?.orderBy) query.set("order", options.orderBy);
|
||||
if (options?.tags?.length) query.set("tags", options.tags.join(","));
|
||||
if (options?.search) query.set("search", options.search);
|
||||
|
||||
const news = useNews();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore forget why this ignor exists
|
||||
const newValue = await $dropFetch(`/api/v1/news?${query.toString()}`);
|
||||
|
||||
news.value = newValue;
|
||||
|
||||
return newValue;
|
||||
};
|
||||
12
app/composables/notifications.ts
Normal file
12
app/composables/notifications.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import type { NotificationModel } from "~~/prisma/client/models";
|
||||
|
||||
const ws = new WebSocketHandler("/api/v1/notifications/ws");
|
||||
|
||||
export const useNotifications = () =>
|
||||
useState<Array<NotificationModel>>("notifications", () => []);
|
||||
|
||||
ws.listen((e) => {
|
||||
const notification = JSON.parse(e) as NotificationModel;
|
||||
const notifications = useNotifications();
|
||||
notifications.value.push(notification);
|
||||
});
|
||||
1
app/composables/objects.ts
Normal file
1
app/composables/objects.ts
Normal file
@ -0,0 +1 @@
|
||||
export const useObject = (id: string) => `/api/v1/object/${id}`;
|
||||
36
app/composables/platform.ts
Normal file
36
app/composables/platform.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import type { UserPlatform } from "~~/prisma/client/client";
|
||||
import { HardwarePlatform } from "~~/prisma/client/enums";
|
||||
|
||||
export type PlatformRenderable = {
|
||||
name: string;
|
||||
param: string;
|
||||
platformIcon: { key: string; fallback?: string };
|
||||
};
|
||||
|
||||
export function renderPlatforms(
|
||||
userPlatforms: { platformName: string; id: string; iconSvg: string }[],
|
||||
): PlatformRenderable[] {
|
||||
return [
|
||||
...Object.values(HardwarePlatform).map((e) => ({
|
||||
name: e,
|
||||
param: e,
|
||||
platformIcon: { key: e },
|
||||
})),
|
||||
...userPlatforms.map((e) => ({
|
||||
name: e.platformName,
|
||||
param: e.id,
|
||||
platformIcon: { key: e.id, fallback: e.iconSvg },
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
const rawUseAdminPlatforms = () => useState<Array<UserPlatform> | null>('adminPlatforms', () => null);
|
||||
|
||||
export async function useAdminPlatforms() {
|
||||
const platforms = rawUseAdminPlatforms();
|
||||
if(platforms.value === null){
|
||||
platforms.value = await $dropFetch("/api/v1/admin/platforms");
|
||||
}
|
||||
|
||||
return platforms.value!
|
||||
}
|
||||
94
app/composables/request.ts
Normal file
94
app/composables/request.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import type {
|
||||
ExtractedRouteMethod,
|
||||
NitroFetchOptions,
|
||||
NitroFetchRequest,
|
||||
TypedInternalResponse,
|
||||
} from "nitropack/types";
|
||||
import { FetchError } from "ofetch";
|
||||
|
||||
interface DropFetch<
|
||||
DefaultT = unknown,
|
||||
DefaultR extends NitroFetchRequest = NitroFetchRequest,
|
||||
> {
|
||||
<
|
||||
T = DefaultT,
|
||||
R extends NitroFetchRequest = DefaultR,
|
||||
O extends NitroFetchOptions<R> = NitroFetchOptions<R>,
|
||||
>(
|
||||
request: R,
|
||||
opts?: O & { failTitle?: string },
|
||||
): Promise<
|
||||
// sometimes there is an error, other times there isn't
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
TypedInternalResponse<
|
||||
R,
|
||||
T,
|
||||
NitroFetchOptions<R> extends O ? "get" : ExtractedRouteMethod<R, O>
|
||||
>
|
||||
>;
|
||||
}
|
||||
|
||||
export const $dropFetch: DropFetch = async (rawRequest, opts) => {
|
||||
const requestParts = rawRequest.toString().split("/");
|
||||
requestParts.forEach((part, index) => {
|
||||
if (!part.startsWith(":")) {
|
||||
return;
|
||||
}
|
||||
const partName = part.slice(1);
|
||||
const replacement = opts?.params?.[partName] as string | undefined;
|
||||
if (!replacement) {
|
||||
return;
|
||||
}
|
||||
requestParts[index] = replacement;
|
||||
|
||||
delete opts?.params?.[partName];
|
||||
});
|
||||
const request = requestParts.join("/");
|
||||
|
||||
// If not in setup
|
||||
if (!getCurrentInstance()?.proxy) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore Excessive stack depth comparing types
|
||||
return await $fetch(request, opts);
|
||||
} catch (e) {
|
||||
if (import.meta.client && opts?.failTitle) {
|
||||
console.warn(e);
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: opts.failTitle,
|
||||
description:
|
||||
(e as FetchError)?.message ?? (e as string).toString(),
|
||||
//buttonText: $t("common.close"),
|
||||
},
|
||||
(_, c) => c(),
|
||||
);
|
||||
}
|
||||
if(e instanceof FetchError) {
|
||||
e.message = e.data.message ?? e.message;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const id = request.toString();
|
||||
|
||||
const state = useState(id);
|
||||
if (state.value) {
|
||||
// Deep copy
|
||||
const object = JSON.parse(JSON.stringify(state.value));
|
||||
// Never use again on client
|
||||
if (import.meta.client) state.value = undefined;
|
||||
return object;
|
||||
}
|
||||
|
||||
const headers = useRequestHeaders(["cookie", "authorization"]);
|
||||
const data = await $fetch(request, {
|
||||
...opts,
|
||||
headers: { ...headers, ...opts?.headers },
|
||||
});
|
||||
if (import.meta.server) state.value = data;
|
||||
return data;
|
||||
};
|
||||
12
app/composables/store.ts
Normal file
12
app/composables/store.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export type StoreFilterOption = {
|
||||
name: string;
|
||||
param: string;
|
||||
options: Array<StoreSortOption>;
|
||||
multiple?: boolean;
|
||||
};
|
||||
|
||||
export type StoreSortOption = {
|
||||
name: string;
|
||||
param: string;
|
||||
platformIcon?: { key: string; fallback?: string };
|
||||
};
|
||||
78
app/composables/task.ts
Normal file
78
app/composables/task.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import type { TaskMessage } from "~~/server/internal/tasks";
|
||||
import { WebSocketHandler } from "./ws";
|
||||
|
||||
const websocketHandler = new WebSocketHandler("/api/v1/task");
|
||||
// const taskStates: { [key: string]: } = {};
|
||||
const taskStates = new Map<string, Ref<TaskMessage | undefined>>();
|
||||
|
||||
function handleUpdateMessage(msg: TaskMessage) {
|
||||
const taskStates = useTaskStates();
|
||||
const state = taskStates.get(msg.id);
|
||||
if (!state) return;
|
||||
if (!state.value || msg.reset) {
|
||||
state.value = msg;
|
||||
return;
|
||||
}
|
||||
state.value.log.push(...msg.log);
|
||||
|
||||
Object.assign(state.value, { ...msg, log: state.value.log });
|
||||
}
|
||||
|
||||
websocketHandler.listen((message) => {
|
||||
try {
|
||||
// If it's an object, it's an update message
|
||||
const msg = JSON.parse(message) as TaskMessage;
|
||||
handleUpdateMessage(msg);
|
||||
} catch {
|
||||
// Otherwise it's control message
|
||||
const taskStates = useTaskStates();
|
||||
|
||||
const [action, ...data] = message.split("/");
|
||||
|
||||
switch (action) {
|
||||
case "connect": {
|
||||
const taskReady = useTaskReady();
|
||||
taskReady.value = true;
|
||||
break;
|
||||
}
|
||||
case "disconnect": {
|
||||
const disconnectTaskId = data[0];
|
||||
taskStates.delete(disconnectTaskId);
|
||||
console.log(`disconnected from ${disconnectTaskId}`);
|
||||
break;
|
||||
}
|
||||
case "error": {
|
||||
const [taskId, title, description] = data;
|
||||
const state = taskStates.get(taskId);
|
||||
if (!state) break;
|
||||
state.value ??= {
|
||||
id: taskId,
|
||||
name: "Unknown task",
|
||||
success: false,
|
||||
progress: 0,
|
||||
error: undefined,
|
||||
log: [],
|
||||
};
|
||||
state.value.error = { title, description };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const useTaskStates = () => taskStates;
|
||||
|
||||
export const useTaskReady = () => useState("taskready", () => false);
|
||||
|
||||
export const useTask = (taskId: string): Ref<TaskMessage | undefined> => {
|
||||
if (import.meta.server) return ref(undefined);
|
||||
const taskStates = useTaskStates();
|
||||
const task = taskStates.get(taskId);
|
||||
if (task && task.value && !task.value.error) return task;
|
||||
|
||||
taskStates.set(taskId, ref(undefined));
|
||||
console.log("connecting to " + taskId);
|
||||
websocketHandler.send(`connect/${taskId}`);
|
||||
// TODO: this may have changed behavior
|
||||
return taskStates.get(taskId) ?? ref(undefined);
|
||||
};
|
||||
13
app/composables/types.ts
Normal file
13
app/composables/types.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import type { Component } from "vue";
|
||||
|
||||
export type NavigationItem = {
|
||||
prefix: string;
|
||||
route: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type QuickActionNav = {
|
||||
icon: Component;
|
||||
notifications?: Ref<number>;
|
||||
action: () => Promise<void>;
|
||||
};
|
||||
13
app/composables/user.ts
Normal file
13
app/composables/user.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import type { UserModel } from "~~/prisma/client/models";
|
||||
|
||||
// undefined = haven't check
|
||||
// null = check, no user
|
||||
// {} = check, user
|
||||
|
||||
export const useUser = () => useState<UserModel | undefined | null>(undefined);
|
||||
export const updateUser = async () => {
|
||||
const user = useUser();
|
||||
if (user.value === null) return;
|
||||
|
||||
user.value = await $dropFetch<UserModel | null>("/api/v1/user");
|
||||
};
|
||||
25
app/composables/users.ts
Normal file
25
app/composables/users.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import type { UserModel } from "~~/prisma/client/models";
|
||||
import type { AuthMec } from "~~/prisma/client/enums";
|
||||
|
||||
export const useUsers = () =>
|
||||
useState<
|
||||
| Array<
|
||||
SerializeObject<
|
||||
UserModel & {
|
||||
authMecs?: Array<{ id: string; mec: AuthMec }>;
|
||||
}
|
||||
>
|
||||
>
|
||||
| undefined
|
||||
>("users", () => undefined);
|
||||
|
||||
export const fetchUsers = async () => {
|
||||
const users = useUsers();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore forget why this ignor exists
|
||||
const newValue: User[] = await $dropFetch("/api/v1/admin/users");
|
||||
users.value = newValue;
|
||||
return newValue;
|
||||
};
|
||||
72
app/composables/ws.ts
Normal file
72
app/composables/ws.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import type { NuxtError } from "#app";
|
||||
|
||||
export type WebSocketCallback = (message: string) => void;
|
||||
export type WebSocketErrorHandler = (error: NuxtError<unknown>) => void;
|
||||
|
||||
export class WebSocketHandler {
|
||||
private listeners: Array<WebSocketCallback> = [];
|
||||
|
||||
private outQueue: Array<string> = [];
|
||||
private inQueue: Array<string> = [];
|
||||
|
||||
private ws: WebSocket | undefined = undefined;
|
||||
private connected: boolean = false;
|
||||
|
||||
private errorHandler: WebSocketErrorHandler | undefined = undefined;
|
||||
|
||||
constructor(route: string) {
|
||||
if (import.meta.server) return;
|
||||
const isSecure = location.protocol === "https:";
|
||||
const url = (isSecure ? "wss://" : "ws://") + location.host + route;
|
||||
this.ws = new WebSocket(url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.connected = true;
|
||||
for (const message of this.outQueue) {
|
||||
this.ws?.send(message);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onmessage = (e) => {
|
||||
const message = e.data;
|
||||
switch (message) {
|
||||
case "unauthenticated": {
|
||||
const error = createError({
|
||||
statusCode: 403,
|
||||
message: "Unable to connect to websocket - unauthenticated",
|
||||
});
|
||||
if (this.errorHandler) {
|
||||
return this.errorHandler(error);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.listeners.length == 0) {
|
||||
this.inQueue.push(message);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const listener of this.listeners) {
|
||||
listener(message);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
error(handler: WebSocketErrorHandler) {
|
||||
this.errorHandler = handler;
|
||||
}
|
||||
|
||||
listen(callback: WebSocketCallback) {
|
||||
this.listeners.push(callback);
|
||||
}
|
||||
|
||||
send(message: string) {
|
||||
if (!this.connected || !this.ws) {
|
||||
this.outQueue.push(message);
|
||||
return;
|
||||
}
|
||||
|
||||
this.ws.send(message);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user