diff --git a/.dev/Caddyfile b/.dev/Caddyfile index 8505b3a..8018ced 100644 --- a/.dev/Caddyfile +++ b/.dev/Caddyfile @@ -20,12 +20,12 @@ nattyarch.local { header Accept text/html* } - @static { - path /favicon.ico /favicon.png /favicon.svg /manifest.json /api-doc /sw.js /static-assets* /client-assets* /assets* /twemoji* /url + @frontend { + path /favicon.ico /favicon.png /favicon.svg /manifest.json /api-doc /sw.js /static-assets* /client-assets* /assets* /twemoji* /url /fe-api* } reverse_proxy @render_html 127.0.0.1:4938 - reverse_proxy @static 127.0.0.1:4938 + reverse_proxy @frontend 127.0.0.1:4938 reverse_proxy 127.0.0.1:4937 } diff --git a/Cargo.lock b/Cargo.lock index 3e20f30..d60fcdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -986,6 +986,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "walkdir", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c52c94d..a5b5ce5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ tower-http = "0.4" tracing = "0.1" tracing-subscriber = "0.3" url = "2.3" +walkdir = "2.3" [dependencies] magnetar_core = { path = "./core" } diff --git a/fe_calckey/Cargo.toml b/fe_calckey/Cargo.toml index f082f0e..90a01f5 100644 --- a/fe_calckey/Cargo.toml +++ b/fe_calckey/Cargo.toml @@ -27,4 +27,6 @@ serde = { workspace = true, features = ["derive"] } toml = { workspace = true } serde_json = { workspace = true } -chrono = { workspace = true } \ No newline at end of file +chrono = { workspace = true } + +walkdir = { workspace = true } \ No newline at end of file diff --git a/fe_calckey/frontend/assets/api-doc.png b/fe_calckey/frontend/assets/api-doc.png deleted file mode 100644 index 95fe697..0000000 Binary files a/fe_calckey/frontend/assets/api-doc.png and /dev/null differ diff --git a/fe_calckey/frontend/assets/redoc.html b/fe_calckey/frontend/assets/redoc.html deleted file mode 100644 index 6f48c17..0000000 --- a/fe_calckey/frontend/assets/redoc.html +++ /dev/null @@ -1,23 +0,0 @@ - - - - Calckey API - - - - - - - - - - - - diff --git a/fe_calckey/frontend/client/.prettierrc b/fe_calckey/frontend/client/.prettierrc index 77a5016..47c6381 100644 --- a/fe_calckey/frontend/client/.prettierrc +++ b/fe_calckey/frontend/client/.prettierrc @@ -1,15 +1,15 @@ { "tabWidth": 4, - "useTabs": true, - "singleQuote": false, - "vueIndentScriptAndStyle": false, - "plugins": ["vue"], - "overrides": [ - { - "files": "*.vue", - "options": { - "parser": "vue" - } - } - ] + "useTabs": false, + "singleQuote": false, + "vueIndentScriptAndStyle": false, + "plugins": ["vue"], + "overrides": [ + { + "files": "*.vue", + "options": { + "parser": "vue" + } + } + ] } diff --git a/fe_calckey/frontend/client/package.json b/fe_calckey/frontend/client/package.json index 879d305..9aa1116 100644 --- a/fe_calckey/frontend/client/package.json +++ b/fe_calckey/frontend/client/package.json @@ -53,6 +53,7 @@ "insert-text-at-cursor": "0.3.0", "json5": "2.2.3", "katex": "0.16.7", + "magnetar-common": "workspace:*", "matter-js": "0.18.0", "mfm-js": "0.23.3", "photoswipe": "5.3.7", diff --git a/fe_calckey/frontend/client/src/config.ts b/fe_calckey/frontend/client/src/config.ts index 8a18ba9..52fc761 100644 --- a/fe_calckey/frontend/client/src/config.ts +++ b/fe_calckey/frontend/client/src/config.ts @@ -7,6 +7,7 @@ export const host = _HOST || address.host; export const hostname = address.hostname; export const url = _REMOTE_URL || address.origin; export const apiUrl = `${url}/api`; +export const feApiUrl = `${url}/fe-api`; export const wsUrl = `${url .replace("http://", "ws://") .replace("https://", "wss://")}/streaming`; diff --git a/fe_calckey/frontend/client/src/os.ts b/fe_calckey/frontend/client/src/os.ts index 32bb8f7..58145e5 100644 --- a/fe_calckey/frontend/client/src/os.ts +++ b/fe_calckey/frontend/client/src/os.ts @@ -1,10 +1,10 @@ // TODO: なんでもかんでもos.tsに突っ込むのやめたいのでよしなに分割する -import { Component, markRaw, Ref, ref, defineAsyncComponent } from "vue"; +import { Component, defineAsyncComponent, markRaw, ref, Ref } from "vue"; import { EventEmitter } from "eventemitter3"; import insertTextAtCursor from "insert-text-at-cursor"; import * as Misskey from "calckey-js"; -import { apiUrl, url } from "@/config"; +import { apiUrl, feApiUrl, url } from "@/config"; import MkPostFormDialog from "@/components/MkPostFormDialog.vue"; import MkWaitingDialog from "@/components/MkWaitingDialog.vue"; import MkToast from "@/components/MkToast.vue"; @@ -12,899 +12,967 @@ import MkDialog from "@/components/MkDialog.vue"; import { MenuItem } from "@/types/menu"; import { $i } from "@/account"; import { i18n } from "./i18n"; +import { + FrontendApiEndpoint, + FrontendApiEndpoints, +} from "magnetar-common/built/fe-api"; export const pendingApiRequestsCount = ref(0); const apiClient = new Misskey.api.APIClient({ - origin: url, + origin: url, }); +export async function feApi( + endpointDef: FrontendApiEndpoint< + FrontendApiEndpoints[T]["method"], + FrontendApiEndpoints[T]["path"], + FrontendApiEndpoints[T]["request"], + FrontendApiEndpoints[T]["response"] + >, + data: FrontendApiEndpoints[T]["request"], + token?: string | null | undefined +): Promise { + type Response = FrontendApiEndpoints[T]["response"]; + + pendingApiRequestsCount.value++; + + const authorizationToken = token ?? $i?.token ?? undefined; + const authorization = authorizationToken + ? `Bearer ${authorizationToken}` + : undefined; + + const endpoint = endpointDef.path; + + let url = `${feApiUrl}/${endpoint}`; + + if (endpointDef.method === "GET") { + const query = new URLSearchParams(data as any).toString(); + if (query) { + url += `?${query}`; + } + } + + const promise: Promise = new Promise((resolve, reject) => { + fetch(url, { + method: endpointDef.method, + body: + endpointDef.method !== "GET" ? JSON.stringify(data) : undefined, + credentials: "omit", + cache: "no-cache", + headers: authorization ? { authorization } : {}, + }) + .then(async (res) => { + const body = res.status === 204 ? null : await res.json(); + + if (res.status === 200) { + resolve(body as Response); + } else if (res.status === 204) { + resolve(null as any as Response); + } else { + reject(body.error); + } + }) + .catch(reject); + }); + + promise.finally(() => { + pendingApiRequestsCount.value--; + }); + + return promise; +} + export const api = (( - endpoint: string, - data: Record = {}, - token?: string | null | undefined, + endpoint: string, + data: Record = {}, + token?: string | null | undefined ) => { - pendingApiRequestsCount.value++; + pendingApiRequestsCount.value++; - const onFinally = () => { - pendingApiRequestsCount.value--; - }; + const onFinally = () => { + pendingApiRequestsCount.value--; + }; - const authorizationToken = token ?? $i?.token ?? undefined; - const authorization = authorizationToken - ? `Bearer ${authorizationToken}` - : undefined; + const authorizationToken = token ?? $i?.token ?? undefined; + const authorization = authorizationToken + ? `Bearer ${authorizationToken}` + : undefined; - const promise = new Promise((resolve, reject) => { - fetch(endpoint.indexOf("://") > -1 ? endpoint : `${apiUrl}/${endpoint}`, { - method: "POST", - body: JSON.stringify(data), - credentials: "omit", - cache: "no-cache", - headers: authorization ? { authorization } : {}, - }) - .then(async (res) => { - const body = res.status === 204 ? null : await res.json(); + const promise = new Promise((resolve, reject) => { + fetch( + endpoint.indexOf("://") > -1 ? endpoint : `${apiUrl}/${endpoint}`, + { + method: "POST", + body: JSON.stringify(data), + credentials: "omit", + cache: "no-cache", + headers: authorization ? { authorization } : {}, + } + ) + .then(async (res) => { + const body = res.status === 204 ? null : await res.json(); - if (res.status === 200) { - resolve(body); - } else if (res.status === 204) { - resolve(); - } else { - reject(body.error); - } - }) - .catch(reject); - }); + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(); + } else { + reject(body.error); + } + }) + .catch(reject); + }); - promise.then(onFinally, onFinally); + promise.then(onFinally, onFinally); - return promise; + return promise; }) as typeof apiClient.request; export const apiGet = (( - endpoint: string, - data: Record = {}, - token?: string | null | undefined, + endpoint: string, + data: Record = {}, + token?: string | null | undefined ) => { - pendingApiRequestsCount.value++; + pendingApiRequestsCount.value++; - const onFinally = () => { - pendingApiRequestsCount.value--; - }; + const onFinally = () => { + pendingApiRequestsCount.value--; + }; - const query = new URLSearchParams(data); + const query = new URLSearchParams(data); - const authorizationToken = token ?? $i?.token ?? undefined; - const authorization = authorizationToken - ? `Bearer ${authorizationToken}` - : undefined; + const authorizationToken = token ?? $i?.token ?? undefined; + const authorization = authorizationToken + ? `Bearer ${authorizationToken}` + : undefined; - const promise = new Promise((resolve, reject) => { - // Send request - fetch(`${apiUrl}/${endpoint}?${query}`, { - method: "GET", - credentials: "omit", - cache: "default", - headers: authorization ? { authorization } : {}, - }) - .then(async (res) => { - const body = res.status === 204 ? null : await res.json(); + const promise = new Promise((resolve, reject) => { + // Send request + fetch(`${apiUrl}/${endpoint}?${query}`, { + method: "GET", + credentials: "omit", + cache: "default", + headers: authorization ? { authorization } : {}, + }) + .then(async (res) => { + const body = res.status === 204 ? null : await res.json(); - if (res.status === 200) { - resolve(body); - } else if (res.status === 204) { - resolve(); - } else { - reject(body.error); - } - }) - .catch(reject); - }); + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(); + } else { + reject(body.error); + } + }) + .catch(reject); + }); - promise.then(onFinally, onFinally); + promise.then(onFinally, onFinally); - return promise; + return promise; }) as typeof apiClient.request; export const apiWithDialog = (( - endpoint: string, - data: Record = {}, - token?: string | null | undefined, + endpoint: string, + data: Record = {}, + token?: string | null | undefined ) => { - const promise = api(endpoint, data, token); - promiseDialog(promise, null, (err) => { - alert({ - type: "error", - text: err.message + "\n" + (err as any).id, - }); - }); + const promise = api(endpoint, data, token); + promiseDialog(promise, null, (err) => { + alert({ + type: "error", + text: err.message + "\n" + (err as any).id, + }); + }); - return promise; + return promise; }) as typeof api; export function promiseDialog>( - promise: T, - onSuccess?: ((res: any) => void) | null, - onFailure?: ((err: Error) => void) | null, - text?: string, + promise: T, + onSuccess?: ((res: any) => void) | null, + onFailure?: ((err: Error) => void) | null, + text?: string ): T { - const showing = ref(true); - const success = ref(false); + const showing = ref(true); + const success = ref(false); - promise - .then((res) => { - if (onSuccess) { - showing.value = false; - onSuccess(res); - } else { - success.value = true; - window.setTimeout(() => { - showing.value = false; - }, 1000); - } - }) - .catch((err) => { - showing.value = false; - if (onFailure) { - onFailure(err); - } else { - alert({ - type: "error", - text: err, - }); - } - }); + promise + .then((res) => { + if (onSuccess) { + showing.value = false; + onSuccess(res); + } else { + success.value = true; + window.setTimeout(() => { + showing.value = false; + }, 1000); + } + }) + .catch((err) => { + showing.value = false; + if (onFailure) { + onFailure(err); + } else { + alert({ + type: "error", + text: err, + }); + } + }); - // NOTE: dynamic importすると挙動がおかしくなる(showingの変更が伝播しない) - popup( - MkWaitingDialog, - { - success: success, - showing: showing, - text: text, - }, - {}, - "closed", - ); + // NOTE: dynamic importすると挙動がおかしくなる(showingの変更が伝播しない) + popup( + MkWaitingDialog, + { + success: success, + showing: showing, + text: text, + }, + {}, + "closed" + ); - return promise; + return promise; } let popupIdCount = 0; export const popups = ref([]) as Ref< - { - id: any; - component: any; - props: Record; - }[] + { + id: any; + component: any; + props: Record; + }[] >; const zIndexes = { - low: 1000000, - middle: 2000000, - high: 3000000, + low: 1000000, + middle: 2000000, + high: 3000000, }; export function claimZIndex( - priority: "low" | "middle" | "high" = "low", + priority: "low" | "middle" | "high" = "low" ): number { - zIndexes[priority] += 100; - return zIndexes[priority]; + zIndexes[priority] += 100; + return zIndexes[priority]; } let uniqueId = 0; export function getUniqueId(): string { - return uniqueId++ + ""; + return uniqueId++ + ""; } export async function popup( - component: Component, - props: Record, - events = {}, - disposeEvent?: string, + component: Component, + props: Record, + events = {}, + disposeEvent?: string ) { - markRaw(component); + markRaw(component); - const id = ++popupIdCount; - const dispose = () => { - // このsetTimeoutが無いと挙動がおかしくなる(autocompleteが閉じなくなる)。Vueのバグ? - window.setTimeout(() => { - popups.value = popups.value.filter((popup) => popup.id !== id); - }, 0); - }; - const state = { - component, - props, - events: disposeEvent - ? { - ...events, - [disposeEvent]: dispose, - } - : events, - id, - }; + const id = ++popupIdCount; + const dispose = () => { + // このsetTimeoutが無いと挙動がおかしくなる(autocompleteが閉じなくなる)。Vueのバグ? + window.setTimeout(() => { + popups.value = popups.value.filter((popup) => popup.id !== id); + }, 0); + }; + const state = { + component, + props, + events: disposeEvent + ? { + ...events, + [disposeEvent]: dispose, + } + : events, + id, + }; - popups.value.push(state); + popups.value.push(state); - return { - dispose, - }; + return { + dispose, + }; } export function pageWindow(path: string) { - popup( - defineAsyncComponent({ - loader: () => import("@/components/MkPageWindow.vue"), - loadingComponent: MkWaitingDialog, - delay: 1000, - }), - { - initialPath: path, - }, - {}, - "closed", - ); + popup( + defineAsyncComponent({ + loader: () => import("@/components/MkPageWindow.vue"), + loadingComponent: MkWaitingDialog, + delay: 1000, + }), + { + initialPath: path, + }, + {}, + "closed" + ); } export function modalPageWindow(path: string) { - popup( - defineAsyncComponent({ - loader: () => import("@/components/MkModalPageWindow.vue"), - loadingComponent: MkWaitingDialog, - delay: 1000, - }), - { - initialPath: path, - }, - {}, - "closed", - ); + popup( + defineAsyncComponent({ + loader: () => import("@/components/MkModalPageWindow.vue"), + loadingComponent: MkWaitingDialog, + delay: 1000, + }), + { + initialPath: path, + }, + {}, + "closed" + ); } export function toast(message: string) { - popup( - MkToast, - { - message, - }, - {}, - "closed", - ); + popup( + MkToast, + { + message, + }, + {}, + "closed" + ); } export function alert(props: { - type?: "error" | "info" | "success" | "warning" | "waiting" | "question"; - title?: string | null; - text?: string | null; + type?: "error" | "info" | "success" | "warning" | "waiting" | "question"; + title?: string | null; + text?: string | null; }): Promise { - return new Promise((resolve, reject) => { - if (props.text == null && props.type === "error") { - props.text = i18n.ts.somethingHappened; - } - popup( - MkDialog, - props, - { - done: (result) => { - resolve(); - }, - }, - "closed", - ); - }); + return new Promise((resolve, reject) => { + if (props.text == null && props.type === "error") { + props.text = i18n.ts.somethingHappened; + } + popup( + MkDialog, + props, + { + done: (result) => { + resolve(); + }, + }, + "closed" + ); + }); } export function confirm(props: { - type: "error" | "info" | "success" | "warning" | "waiting" | "question"; - title?: string | null; - text?: string | null; - okText?: string; - cancelText?: string; + type: "error" | "info" | "success" | "warning" | "waiting" | "question"; + title?: string | null; + text?: string | null; + okText?: string; + cancelText?: string; }): Promise<{ canceled: boolean }> { - return new Promise((resolve, reject) => { - popup( - MkDialog, - { - ...props, - showCancelButton: true, - }, - { - done: (result) => { - resolve(result ? result : { canceled: true }); - }, - }, - "closed", - ); - }); + return new Promise((resolve, reject) => { + popup( + MkDialog, + { + ...props, + showCancelButton: true, + }, + { + done: (result) => { + resolve(result ? result : { canceled: true }); + }, + }, + "closed" + ); + }); } export function yesno(props: { - type: "error" | "info" | "success" | "warning" | "waiting" | "question"; - title?: string | null; - text?: string | null; + type: "error" | "info" | "success" | "warning" | "waiting" | "question"; + title?: string | null; + text?: string | null; }): Promise<{ canceled: boolean }> { - return new Promise((resolve, reject) => { - popup( - defineAsyncComponent({ - loader: () => import("@/components/MkDialog.vue"), - loadingComponent: MkWaitingDialog, - delay: 1000, - }), - { - ...props, - showCancelButton: true, - isYesNo: true, - }, - { - done: (result) => { - resolve(result ? result : { canceled: true }); - }, - }, - "closed", - ); - }); + return new Promise((resolve, reject) => { + popup( + defineAsyncComponent({ + loader: () => import("@/components/MkDialog.vue"), + loadingComponent: MkWaitingDialog, + delay: 1000, + }), + { + ...props, + showCancelButton: true, + isYesNo: true, + }, + { + done: (result) => { + resolve(result ? result : { canceled: true }); + }, + }, + "closed" + ); + }); } export function inputText(props: { - type?: "text" | "email" | "password" | "url" | "search"; - title?: string | null; - text?: string | null; - placeholder?: string | null; - autocomplete?: string; - default?: string | null; - minLength?: number; - maxLength?: number; + type?: "text" | "email" | "password" | "url" | "search"; + title?: string | null; + text?: string | null; + placeholder?: string | null; + autocomplete?: string; + default?: string | null; + minLength?: number; + maxLength?: number; }): Promise< - | { canceled: true; result: undefined } - | { - canceled: false; - result: string; - } + | { canceled: true; result: undefined } + | { + canceled: false; + result: string; + } > { - return new Promise((resolve, reject) => { - popup( - MkDialog, - { - title: props.title, - text: props.text, - input: { - type: props.type, - placeholder: props.placeholder, - autocomplete: props.autocomplete, - default: props.default, - minLength: props.minLength, - maxLength: props.maxLength, - }, - }, - { - done: (result) => { - resolve(result ? result : { canceled: true }); - }, - }, - "closed", - ); - }); + return new Promise((resolve, reject) => { + popup( + MkDialog, + { + title: props.title, + text: props.text, + input: { + type: props.type, + placeholder: props.placeholder, + autocomplete: props.autocomplete, + default: props.default, + minLength: props.minLength, + maxLength: props.maxLength, + }, + }, + { + done: (result) => { + resolve(result ? result : { canceled: true }); + }, + }, + "closed" + ); + }); } export function inputParagraph(props: { - title?: string | null; - text?: string | null; - placeholder?: string | null; - default?: string | null; + title?: string | null; + text?: string | null; + placeholder?: string | null; + default?: string | null; }): Promise< - | { canceled: true; result: undefined } - | { - canceled: false; - result: string; - } + | { canceled: true; result: undefined } + | { + canceled: false; + result: string; + } > { - return new Promise((resolve, reject) => { - popup( - defineAsyncComponent({ - loader: () => import("@/components/MkDialog.vue"), - loadingComponent: MkWaitingDialog, - delay: 1000, - }), - { - title: props.title, - text: props.text, - input: { - type: "paragraph", - placeholder: props.placeholder, - default: props.default, - }, - }, - { - done: (result) => { - resolve(result ? result : { canceled: true }); - }, - }, - "closed", - ); - }); + return new Promise((resolve, reject) => { + popup( + defineAsyncComponent({ + loader: () => import("@/components/MkDialog.vue"), + loadingComponent: MkWaitingDialog, + delay: 1000, + }), + { + title: props.title, + text: props.text, + input: { + type: "paragraph", + placeholder: props.placeholder, + default: props.default, + }, + }, + { + done: (result) => { + resolve(result ? result : { canceled: true }); + }, + }, + "closed" + ); + }); } export function inputNumber(props: { - title?: string | null; - text?: string | null; - placeholder?: string | null; - default?: number | null; - autocomplete?: string; + title?: string | null; + text?: string | null; + placeholder?: string | null; + default?: number | null; + autocomplete?: string; }): Promise< - | { canceled: true; result: undefined } - | { - canceled: false; - result: number; - } + | { canceled: true; result: undefined } + | { + canceled: false; + result: number; + } > { - return new Promise((resolve, reject) => { - popup( - defineAsyncComponent({ - loader: () => import("@/components/MkDialog.vue"), - loadingComponent: MkWaitingDialog, - delay: 1000, - }), - { - title: props.title, - text: props.text, - input: { - type: "number", - placeholder: props.placeholder, - autocomplete: props.autocomplete, - default: props.default, - }, - }, - { - done: (result) => { - resolve(result ? result : { canceled: true }); - }, - }, - "closed", - ); - }); + return new Promise((resolve, reject) => { + popup( + defineAsyncComponent({ + loader: () => import("@/components/MkDialog.vue"), + loadingComponent: MkWaitingDialog, + delay: 1000, + }), + { + title: props.title, + text: props.text, + input: { + type: "number", + placeholder: props.placeholder, + autocomplete: props.autocomplete, + default: props.default, + }, + }, + { + done: (result) => { + resolve(result ? result : { canceled: true }); + }, + }, + "closed" + ); + }); } export function inputDate(props: { - title?: string | null; - text?: string | null; - placeholder?: string | null; - default?: Date | null; + title?: string | null; + text?: string | null; + placeholder?: string | null; + default?: Date | null; }): Promise< - | { canceled: true; result: undefined } - | { - canceled: false; - result: Date; - } + | { canceled: true; result: undefined } + | { + canceled: false; + result: Date; + } > { - return new Promise((resolve, reject) => { - popup( - MkDialog, - { - title: props.title, - text: props.text, - input: { - type: "date", - placeholder: props.placeholder, - default: props.default, - }, - }, - { - done: (result) => { - resolve( - result - ? { - result: new Date(result.result), - canceled: false, - } - : { canceled: true }, - ); - }, - }, - "closed", - ); - }); + return new Promise((resolve, reject) => { + popup( + MkDialog, + { + title: props.title, + text: props.text, + input: { + type: "date", + placeholder: props.placeholder, + default: props.default, + }, + }, + { + done: (result) => { + resolve( + result + ? { + result: new Date(result.result), + canceled: false, + } + : { canceled: true } + ); + }, + }, + "closed" + ); + }); } export function select( - props: { - title?: string | null; - text?: string | null; - default?: string | null; - } & ( - | { - items: { - value: C; - text: string; - }[]; - } - | { - groupedItems: { - label: string; - items: { - value: C; - text: string; - }[]; - }[]; - } - ), + props: { + title?: string | null; + text?: string | null; + default?: string | null; + } & ( + | { + items: { + value: C; + text: string; + }[]; + } + | { + groupedItems: { + label: string; + items: { + value: C; + text: string; + }[]; + }[]; + } + ) ): Promise< - | { canceled: true; result: undefined } - | { - canceled: false; - result: C; - } + | { canceled: true; result: undefined } + | { + canceled: false; + result: C; + } > { - return new Promise((resolve, reject) => { - popup( - MkDialog, - { - title: props.title, - text: props.text, - select: { - items: props.items, - groupedItems: props.groupedItems, - default: props.default, - }, - }, - { - done: (result) => { - resolve(result ? result : { canceled: true }); - }, - }, - "closed", - ); - }); + return new Promise((resolve, reject) => { + popup( + MkDialog, + { + title: props.title, + text: props.text, + select: { + items: props.items, + groupedItems: props.groupedItems, + default: props.default, + }, + }, + { + done: (result) => { + resolve(result ? result : { canceled: true }); + }, + }, + "closed" + ); + }); } export function success(): Promise { - return new Promise((resolve, reject) => { - const showing = ref(true); - window.setTimeout(() => { - showing.value = false; - }, 1000); - popup( - MkWaitingDialog, - { - success: true, - showing: showing, - }, - { - done: () => resolve(), - }, - "closed", - ); - }); + return new Promise((resolve, reject) => { + const showing = ref(true); + window.setTimeout(() => { + showing.value = false; + }, 1000); + popup( + MkWaitingDialog, + { + success: true, + showing: showing, + }, + { + done: () => resolve(), + }, + "closed" + ); + }); } export function waiting(): Promise { - return new Promise((resolve, reject) => { - const showing = ref(true); - popup( - MkWaitingDialog, - { - success: false, - showing: showing, - }, - { - done: () => resolve(), - }, - "closed", - ); - }); + return new Promise((resolve, reject) => { + const showing = ref(true); + popup( + MkWaitingDialog, + { + success: false, + showing: showing, + }, + { + done: () => resolve(), + }, + "closed" + ); + }); } export function form(title, form) { - return new Promise((resolve, reject) => { - popup( - defineAsyncComponent({ - loader: () => import("@/components/MkFormDialog.vue"), - loadingComponent: MkWaitingDialog, - delay: 1000, - }), - { title, form }, - { - done: (result) => { - resolve(result); - }, - }, - "closed", - ); - }); + return new Promise((resolve, reject) => { + popup( + defineAsyncComponent({ + loader: () => import("@/components/MkFormDialog.vue"), + loadingComponent: MkWaitingDialog, + delay: 1000, + }), + { title, form }, + { + done: (result) => { + resolve(result); + }, + }, + "closed" + ); + }); } export async function selectUser() { - return new Promise((resolve, reject) => { - popup( - defineAsyncComponent({ - loader: () => import("@/components/MkUserSelectDialog.vue"), - loadingComponent: MkWaitingDialog, - delay: 1000, - }), - {}, - { - ok: (user) => { - resolve(user); - }, - }, - "closed", - ); - }); + return new Promise((resolve, reject) => { + popup( + defineAsyncComponent({ + loader: () => import("@/components/MkUserSelectDialog.vue"), + loadingComponent: MkWaitingDialog, + delay: 1000, + }), + {}, + { + ok: (user) => { + resolve(user); + }, + }, + "closed" + ); + }); } export async function selectInstance(): Promise { - return new Promise((resolve, reject) => { - popup( - defineAsyncComponent({ - loader: () => import("@/components/MkInstanceSelectDialog.vue"), - loadingComponent: MkWaitingDialog, - delay: 1000, - }), - {}, - { - ok: (instance) => { - resolve(instance); - }, - }, - "closed", - ); - }); + return new Promise((resolve, reject) => { + popup( + defineAsyncComponent({ + loader: () => import("@/components/MkInstanceSelectDialog.vue"), + loadingComponent: MkWaitingDialog, + delay: 1000, + }), + {}, + { + ok: (instance) => { + resolve(instance); + }, + }, + "closed" + ); + }); } export async function selectDriveFile(multiple: boolean) { - return new Promise((resolve, reject) => { - popup( - defineAsyncComponent({ - loader: () => import("@/components/MkDriveSelectDialog.vue"), - loadingComponent: MkWaitingDialog, - delay: 1000, - }), - { - type: "file", - multiple, - }, - { - done: (files) => { - if (files) { - resolve(multiple ? files : files[0]); - } - }, - }, - "closed", - ); - }); + return new Promise((resolve, reject) => { + popup( + defineAsyncComponent({ + loader: () => import("@/components/MkDriveSelectDialog.vue"), + loadingComponent: MkWaitingDialog, + delay: 1000, + }), + { + type: "file", + multiple, + }, + { + done: (files) => { + if (files) { + resolve(multiple ? files : files[0]); + } + }, + }, + "closed" + ); + }); } export async function selectDriveFolder(multiple: boolean) { - return new Promise((resolve, reject) => { - popup( - defineAsyncComponent({ - loader: () => import("@/components/MkDriveSelectDialog.vue"), - loadingComponent: MkWaitingDialog, - delay: 1000, - }), - { - type: "folder", - multiple, - }, - { - done: (folders) => { - if (folders) { - resolve(multiple ? folders : folders[0]); - } - }, - }, - "closed", - ); - }); + return new Promise((resolve, reject) => { + popup( + defineAsyncComponent({ + loader: () => import("@/components/MkDriveSelectDialog.vue"), + loadingComponent: MkWaitingDialog, + delay: 1000, + }), + { + type: "folder", + multiple, + }, + { + done: (folders) => { + if (folders) { + resolve(multiple ? folders : folders[0]); + } + }, + }, + "closed" + ); + }); } export async function pickEmoji(src: HTMLElement | null, opts) { - return new Promise((resolve, reject) => { - popup( - defineAsyncComponent({ - loader: () => import("@/components/MkEmojiPickerDialog.vue"), - loadingComponent: MkWaitingDialog, - delay: 1000, - }), - { - src, - ...opts, - }, - { - done: (emoji) => { - resolve(emoji); - }, - }, - "closed", - ); - }); + return new Promise((resolve, reject) => { + popup( + defineAsyncComponent({ + loader: () => import("@/components/MkEmojiPickerDialog.vue"), + loadingComponent: MkWaitingDialog, + delay: 1000, + }), + { + src, + ...opts, + }, + { + done: (emoji) => { + resolve(emoji); + }, + }, + "closed" + ); + }); } export async function cropImage( - image: Misskey.entities.DriveFile, - options: { - aspectRatio: number; - }, + image: Misskey.entities.DriveFile, + options: { + aspectRatio: number; + } ): Promise { - return new Promise((resolve, reject) => { - popup( - defineAsyncComponent({ - loader: () => import("@/components/MkCropperDialog.vue"), - loadingComponent: MkWaitingDialog, - delay: 1000, - }), - { - file: image, - aspectRatio: options.aspectRatio, - }, - { - ok: (x) => { - resolve(x); - }, - }, - "closed", - ); - }); + return new Promise((resolve, reject) => { + popup( + defineAsyncComponent({ + loader: () => import("@/components/MkCropperDialog.vue"), + loadingComponent: MkWaitingDialog, + delay: 1000, + }), + { + file: image, + aspectRatio: options.aspectRatio, + }, + { + ok: (x) => { + resolve(x); + }, + }, + "closed" + ); + }); } type AwaitType = T extends Promise - ? U - : T extends (...args: any[]) => Promise - ? V - : T; + ? U + : T extends (...args: any[]) => Promise + ? V + : T; let openingEmojiPicker: AwaitType> | null = null; let activeTextarea: HTMLTextAreaElement | HTMLInputElement | null = null; export async function openEmojiPicker( - src?: HTMLElement, - opts, - initialTextarea: typeof activeTextarea, + src?: HTMLElement, + opts, + initialTextarea: typeof activeTextarea ) { - if (openingEmojiPicker) return; + if (openingEmojiPicker) return; - activeTextarea = initialTextarea; + activeTextarea = initialTextarea; - const textareas = document.querySelectorAll("textarea, input"); - for (const textarea of Array.from(textareas)) { - textarea.addEventListener("focus", () => { - activeTextarea = textarea; - }); - } + const textareas = document.querySelectorAll("textarea, input"); + for (const textarea of Array.from(textareas)) { + textarea.addEventListener("focus", () => { + activeTextarea = textarea; + }); + } - const observer = new MutationObserver((records) => { - for (const record of records) { - for (const node of Array.from(record.addedNodes).filter( - (node) => node instanceof HTMLElement, - ) as HTMLElement[]) { - const textareas = node.querySelectorAll("textarea, input"); - for (const textarea of Array.from(textareas).filter( - (textarea) => textarea.dataset.preventEmojiInsert == null, - )) { - if (document.activeElement === textarea) activeTextarea = textarea; - textarea.addEventListener("focus", () => { - activeTextarea = textarea; - }); - } - } - } - }); + const observer = new MutationObserver((records) => { + for (const record of records) { + for (const node of Array.from(record.addedNodes).filter( + (node) => node instanceof HTMLElement + ) as HTMLElement[]) { + const textareas = node.querySelectorAll("textarea, input"); + for (const textarea of Array.from(textareas).filter( + (textarea) => textarea.dataset.preventEmojiInsert == null + )) { + if (document.activeElement === textarea) + activeTextarea = textarea; + textarea.addEventListener("focus", () => { + activeTextarea = textarea; + }); + } + } + } + }); - observer.observe(document.body, { - childList: true, - subtree: true, - attributes: false, - characterData: false, - }); + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: false, + characterData: false, + }); - openingEmojiPicker = await popup( - defineAsyncComponent({ - loader: () => import("@/components/MkEmojiPickerDialog.vue"), - loadingComponent: MkWaitingDialog, - delay: 1000, - }), - { - src, - ...opts, - }, - { - chosen: (emoji) => { - insertTextAtCursor(activeTextarea, emoji); - }, - done: (emoji) => { - insertTextAtCursor(activeTextarea, emoji); - }, - closed: () => { - openingEmojiPicker!.dispose(); - openingEmojiPicker = null; - observer.disconnect(); - }, - }, - ); + openingEmojiPicker = await popup( + defineAsyncComponent({ + loader: () => import("@/components/MkEmojiPickerDialog.vue"), + loadingComponent: MkWaitingDialog, + delay: 1000, + }), + { + src, + ...opts, + }, + { + chosen: (emoji) => { + insertTextAtCursor(activeTextarea, emoji); + }, + done: (emoji) => { + insertTextAtCursor(activeTextarea, emoji); + }, + closed: () => { + openingEmojiPicker!.dispose(); + openingEmojiPicker = null; + observer.disconnect(); + }, + } + ); } export function popupMenu( - items: MenuItem[] | Ref, - src?: HTMLElement, - options?: { - align?: string; - width?: number; - viaKeyboard?: boolean; - noReturnFocus?: boolean; - }, + items: MenuItem[] | Ref, + src?: HTMLElement, + options?: { + align?: string; + width?: number; + viaKeyboard?: boolean; + noReturnFocus?: boolean; + } ) { - return new Promise((resolve, reject) => { - let dispose; - popup( - defineAsyncComponent({ - loader: () => import("@/components/MkPopupMenu.vue"), - loadingComponent: MkWaitingDialog, - delay: 1000, - }), - { - items, - src, - width: options?.width, - align: options?.align, - viaKeyboard: options?.viaKeyboard, - noReturnFocus: options?.noReturnFocus, - }, - { - closed: () => { - resolve(); - dispose(); - }, - }, - ).then((res) => { - dispose = res.dispose; - }); - }); + return new Promise((resolve, reject) => { + let dispose; + popup( + defineAsyncComponent({ + loader: () => import("@/components/MkPopupMenu.vue"), + loadingComponent: MkWaitingDialog, + delay: 1000, + }), + { + items, + src, + width: options?.width, + align: options?.align, + viaKeyboard: options?.viaKeyboard, + noReturnFocus: options?.noReturnFocus, + }, + { + closed: () => { + resolve(); + dispose(); + }, + } + ).then((res) => { + dispose = res.dispose; + }); + }); } export function contextMenu( - items: MenuItem[] | Ref, - ev: MouseEvent, + items: MenuItem[] | Ref, + ev: MouseEvent ) { - ev.preventDefault(); - return new Promise((resolve, reject) => { - let dispose; - popup( - defineAsyncComponent({ - loader: () => import("@/components/MkContextMenu.vue"), - loadingComponent: MkWaitingDialog, - delay: 1000, - }), - { - items, - ev, - }, - { - closed: () => { - resolve(); - dispose(); - }, - }, - ).then((res) => { - dispose = res.dispose; - }); - }); + ev.preventDefault(); + return new Promise((resolve, reject) => { + let dispose; + popup( + defineAsyncComponent({ + loader: () => import("@/components/MkContextMenu.vue"), + loadingComponent: MkWaitingDialog, + delay: 1000, + }), + { + items, + ev, + }, + { + closed: () => { + resolve(); + dispose(); + }, + } + ).then((res) => { + dispose = res.dispose; + }); + }); } export function post(props: Record = {}) { - return new Promise((resolve, reject) => { - // NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない - // NOTE: ただ、dynamic importしない場合、MkPostFormDialogインスタンスが使いまわされ、 - // Vueが渡されたコンポーネントに内部的に__propsというプロパティを生やす影響で、 - // 複数のpost formを開いたときに場合によってはエラーになる - // もちろん複数のpost formを開けること自体Misskeyサイドのバグなのだが - let dispose; - popup(MkPostFormDialog, props, { - closed: () => { - resolve(); - dispose(); - }, - }).then((res) => { - dispose = res.dispose; - }); - }); + return new Promise((resolve, reject) => { + // NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない + // NOTE: ただ、dynamic importしない場合、MkPostFormDialogインスタンスが使いまわされ、 + // Vueが渡されたコンポーネントに内部的に__propsというプロパティを生やす影響で、 + // 複数のpost formを開いたときに場合によってはエラーになる + // もちろん複数のpost formを開けること自体Misskeyサイドのバグなのだが + let dispose; + popup(MkPostFormDialog, props, { + closed: () => { + resolve(); + dispose(); + }, + }).then((res) => { + dispose = res.dispose; + }); + }); } export const deckGlobalEvents = new EventEmitter(); diff --git a/fe_calckey/frontend/client/src/pages/settings/sounds.vue b/fe_calckey/frontend/client/src/pages/settings/sounds.vue index d01fc36..bc7d864 100644 --- a/fe_calckey/frontend/client/src/pages/settings/sounds.vue +++ b/fe_calckey/frontend/client/src/pages/settings/sounds.vue @@ -1,40 +1,40 @@ diff --git a/fe_calckey/frontend/magnetar-common/.prettierrc b/fe_calckey/frontend/magnetar-common/.prettierrc new file mode 100644 index 0000000..0601cc1 --- /dev/null +++ b/fe_calckey/frontend/magnetar-common/.prettierrc @@ -0,0 +1,7 @@ +{ + "tabWidth": 4, + "useTabs": false, + "singleQuote": false, + "semi": true, + "bracketSameLine": true +} diff --git a/fe_calckey/frontend/magnetar-common/package-lock.json b/fe_calckey/frontend/magnetar-common/package-lock.json new file mode 100644 index 0000000..b52515e --- /dev/null +++ b/fe_calckey/frontend/magnetar-common/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "magnetar-common", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "magnetar-common", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "typescript": "^5.1.6" + } + }, + "node_modules/typescript": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", + "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/fe_calckey/frontend/magnetar-common/package.json b/fe_calckey/frontend/magnetar-common/package.json new file mode 100644 index 0000000..9546eca --- /dev/null +++ b/fe_calckey/frontend/magnetar-common/package.json @@ -0,0 +1,14 @@ +{ + "name": "magnetar-common", + "version": "0.0.1", + "main": "index.js", + "scripts": { + "build": "tsc" + }, + "author": "Natty", + "license": "MIT", + "description": "A library with common utilities for Magnetar application development", + "devDependencies": { + "typescript": "^5.1.6" + } +} diff --git a/fe_calckey/frontend/magnetar-common/src/fe-api.ts b/fe_calckey/frontend/magnetar-common/src/fe-api.ts new file mode 100644 index 0000000..a7a8e84 --- /dev/null +++ b/fe_calckey/frontend/magnetar-common/src/fe-api.ts @@ -0,0 +1,28 @@ +type Method = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; + +export interface FrontendApiEndpoint { + method: M; + path: P; + request?: T; + response?: R; +} + +function endpointDef( + method: Method, + path: string +): FrontendApiEndpoint { + return { + method, + path, + }; +} + +export const feEndpoints = { + defaultSounds: endpointDef<{}, string[]>("GET", "default-sounds"), +} as const; + +type Endpoints = typeof feEndpoints; + +export type FrontendApiEndpoints = { + [N in keyof Endpoints as Endpoints[N]["path"] & string]: Endpoints[N]; +}; diff --git a/fe_calckey/frontend/magnetar-common/tsconfig.json b/fe_calckey/frontend/magnetar-common/tsconfig.json new file mode 100644 index 0000000..47f57ab --- /dev/null +++ b/fe_calckey/frontend/magnetar-common/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "esnext", + "target": "es2020", + "moduleResolution": "node", + "sourceMap": true, + "strict": true, + "declaration": true, + "declarationMap": true, + "outDir": "./built/", + "removeComments": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "esModuleInterop": true, + }, + "exclude": [ + "node_modules", + "built", + ] +} \ No newline at end of file diff --git a/fe_calckey/frontend/pnpm-lock.yaml b/fe_calckey/frontend/pnpm-lock.yaml index b46abae..d9c2c28 100644 --- a/fe_calckey/frontend/pnpm-lock.yaml +++ b/fe_calckey/frontend/pnpm-lock.yaml @@ -254,6 +254,9 @@ importers: katex: specifier: 0.16.7 version: 0.16.7 + magnetar-common: + specifier: workspace:* + version: link:../magnetar-common matter-js: specifier: 0.18.0 version: 0.18.0 @@ -366,6 +369,12 @@ importers: specifier: 4.1.0 version: 4.1.0(vue@3.3.4) + magnetar-common: + devDependencies: + typescript: + specifier: ^5.1.6 + version: 5.1.6 + sw: devDependencies: '@swc/cli': @@ -7134,6 +7143,12 @@ packages: hasBin: true dev: true + /typescript@5.1.6: + resolution: {integrity: sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==} + engines: {node: '>=14.17'} + hasBin: true + dev: true + /unc-path-regex@0.1.2: resolution: {integrity: sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==} engines: {node: '>=0.10.0'} diff --git a/fe_calckey/frontend/pnpm-workspace.yaml b/fe_calckey/frontend/pnpm-workspace.yaml index aa05066..0bc81b7 100644 --- a/fe_calckey/frontend/pnpm-workspace.yaml +++ b/fe_calckey/frontend/pnpm-workspace.yaml @@ -2,3 +2,4 @@ packages: - 'client' - 'sw' - 'calckey-js' + - "magnetar-common" diff --git a/fe_calckey/src/frontend_api.rs b/fe_calckey/src/frontend_api.rs new file mode 100644 index 0000000..f05fdaf --- /dev/null +++ b/fe_calckey/src/frontend_api.rs @@ -0,0 +1,41 @@ +use axum::extract::State; +use axum::routing::get; +use axum::{Json, Router}; +use std::path::Path; +use walkdir::WalkDir; + +pub fn create_frontend_api(assets_dir: &str) -> Router { + Router::new().route( + "/default-sounds", + get(default_sounds).with_state(assets_dir.to_owned()), + ) +} + +async fn default_sounds(State(ref assets_dir): State) -> Json> { + let sounds_dir = &Path::new(assets_dir).join("sounds"); + let mut sounds = Vec::new(); + + for entry in WalkDir::new(sounds_dir) { + let Ok(entry) = entry else { + continue; + }; + + if !entry.file_type().is_file() { + continue; + } + + let Ok(Some(entry)) = entry + .path() + .strip_prefix(sounds_dir) + .map(|p| p.to_str()) + .map(|p| p.and_then(|pp| pp.strip_suffix(".mp3"))) else { + continue; + }; + + sounds.push(entry.to_owned()); + } + + sounds.sort(); + + Json(sounds) +} diff --git a/fe_calckey/src/frontend_render.rs b/fe_calckey/src/frontend_render.rs index f97c7db..fbaf06b 100644 --- a/fe_calckey/src/frontend_render.rs +++ b/fe_calckey/src/frontend_render.rs @@ -10,7 +10,7 @@ use serde_json::Value; use std::sync::Arc; use std::time::Duration; use tera::{Context, Tera}; -use tracing::{error, info}; +use tracing::error; pub fn new_frontend_render_router(frontend_renderer_config: FrontendConfig) -> Router { Router::new() diff --git a/fe_calckey/src/main.rs b/fe_calckey/src/main.rs index fc0127c..40b4a7c 100644 --- a/fe_calckey/src/main.rs +++ b/fe_calckey/src/main.rs @@ -1,9 +1,11 @@ +mod frontend_api; mod frontend_render; mod manifest; mod static_serve; mod summary_proxy; -use crate::frontend_render::{new_frontend_render_router, render_frontend, FrontendConfig}; +use crate::frontend_api::create_frontend_api; +use crate::frontend_render::{new_frontend_render_router, FrontendConfig}; use crate::manifest::handle_manifest; use crate::static_serve::{static_serve, static_serve_svg, static_serve_sw}; use crate::summary_proxy::generate_summary; @@ -19,7 +21,6 @@ use std::sync::Arc; use std::time::Duration; use tera::Tera; use thiserror::Error; -use tower::layer::layer_fn; use tower_http::services::ServeFile; use tower_http::trace::TraceLayer; use tracing::log::info; @@ -52,10 +53,6 @@ fn new_calckey_fe_router(config: &'static MagnetarConfig) -> Result Result