diff --git a/electron/main/index.ts b/electron/main/index.ts index 2b4b8035..392437ca 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -249,6 +249,7 @@ async function createWindow() { title: "星火应用商店", width: 1366, height: 768, + frame: false, autoHideMenuBar: true, icon: path.join(process.env.VITE_PUBLIC, "favicon.ico"), webPreferences: { @@ -307,6 +308,27 @@ ipcMain.on("set-theme-source", (event, theme: "system" | "light" | "dark") => { nativeTheme.themeSource = theme; }); +ipcMain.on("window-control-minimize", () => { + win?.minimize(); +}); + +ipcMain.on("window-control-toggle-maximize", () => { + if (!win) { + return; + } + + if (win.isMaximized()) { + win.unmaximize(); + return; + } + + win.maximize(); +}); + +ipcMain.on("window-control-close", () => { + win?.close(); +}); + // 配置文件路径 const SPARK_CONFIG_DIR = path.join( os.homedir(), diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 57ba0c6f..e5ebee50 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -42,6 +42,12 @@ type IpcRendererFacade = { invoke: typeof ipcRenderer.invoke; }; +type WindowControlBridge = { + minimize: () => void; + toggleMaximize: () => void; + close: () => void; +}; + type UpdateCenterStateListener = (snapshot: UpdateCenterSnapshot) => void; type UpdateCenterStartTask = { taskKey: string; @@ -91,6 +97,12 @@ contextBridge.exposeInMainWorld("apm_store", { })(), }); +contextBridge.exposeInMainWorld("windowControls", { + minimize: () => ipcRenderer.send("window-control-minimize"), + toggleMaximize: () => ipcRenderer.send("window-control-toggle-maximize"), + close: () => ipcRenderer.send("window-control-close"), +} satisfies WindowControlBridge); + contextBridge.exposeInMainWorld("updateCenter", { open: (storeFilter: StoreFilter = "both"): Promise => ipcRenderer.invoke("update-center-open", storeFilter), diff --git a/src/App.vue b/src/App.vue index 4e8b4612..bd882524 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,116 +1,109 @@ @@ -53,6 +53,7 @@ const emit = defineEmits<{ width: 100%; align-items: center; gap: 0.75rem; + min-width: 0; border-radius: 0.75rem; padding: 0.625rem 0.75rem; text-align: left; @@ -62,6 +63,10 @@ const emit = defineEmits<{ transition: all 0.15s ease; } +.quick-menu-item i { + flex-shrink: 0; +} + .quick-menu-item:hover { background: rgba(0, 113, 227, 0.06); color: #0071e3; diff --git a/src/components/AppDetailModal.vue b/src/components/AppDetailModal.vue index bb252745..34ff8b2c 100644 --- a/src/components/AppDetailModal.vue +++ b/src/components/AppDetailModal.vue @@ -103,7 +103,7 @@ ? 'bg-orange-500 text-white' : 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-600' " - @click="viewingOrigin = 'spark'" + @click="selectOrigin('spark')" > Spark @@ -116,7 +116,7 @@ ? 'bg-blue-500 text-white' : 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-600' " - @click="viewingOrigin = 'apm'" + @click="selectOrigin('apm')" > APM @@ -186,7 +186,7 @@ @click="handleFavorite" > - 收藏 + {{ favoriteButtonText }} @@ -332,6 +332,7 @@ :logged-in="loggedIn" :can-submit="isinstalled" @request-login="$emit('request-login', $event)" + @show-user="emit('show-user', $event)" />
(); const emit = defineEmits<{ @@ -526,6 +529,8 @@ const emit = defineEmits<{ (e: "open-preview", index: number): void; (e: "open-app", pkgname: string, origin?: "spark" | "apm"): void; (e: "check-install", app: App): void; + (e: "select-origin", origin: "spark" | "apm"): void; + (e: "show-user", review: AppReview): void; }>(); const appPkgname = computed(() => props.app?.pkgname); @@ -655,6 +660,13 @@ const activeReviewTags = computed(() => { }); }); +const favoriteButtonText = computed(() => { + if (!props.favorited) return "收藏"; + return props.favoriteFolderName + ? `已收藏 · ${props.favoriteFolderName}` + : "已收藏"; +}); + const downloadCount = ref(""); // 监听 app 变化,获取新app的下载量 @@ -708,6 +720,11 @@ const handleFavorite = () => { emit("favorite", displayApp.value); }; +const selectOrigin = (origin: "spark" | "apm") => { + viewingOrigin.value = origin; + emit("select-origin", origin); +}; + const openPreview = (index: number) => { emit("open-preview", index); }; diff --git a/src/components/AppDetailPage.vue b/src/components/AppDetailPage.vue index 45dcbbf1..d9ad1807 100644 --- a/src/components/AppDetailPage.vue +++ b/src/components/AppDetailPage.vue @@ -123,7 +123,7 @@ @click="handleFavorite" > - 收藏 + {{ favoriteButtonText }} @@ -202,6 +202,7 @@ :logged-in="loggedIn" :can-submit="isInstalled" @request-login="$emit('request-login', $event)" + @show-user="emit('show-user', $event)" />
(); const emit = defineEmits<{ @@ -272,6 +275,8 @@ const emit = defineEmits<{ "open-preview": [index: number]; "open-app": [pkgname: string, origin?: "spark" | "apm"]; "check-install": [app: App]; + "select-origin": [origin: "spark" | "apm"]; + "show-user": [review: AppReview]; }>(); const viewingOrigin = ref<"spark" | "apm">( @@ -351,8 +356,16 @@ const reviewTags = computed(() => { }); }); +const favoriteButtonText = computed(() => { + if (!props.favorited) return "收藏"; + return props.favoriteFolderName + ? `已收藏 · ${props.favoriteFolderName}` + : "已收藏"; +}); + const selectOrigin = (origin: "spark" | "apm") => { viewingOrigin.value = origin; + emit("select-origin", origin); if (displayApp.value) emit("check-install", displayApp.value); }; diff --git a/src/components/AppSidebar.vue b/src/components/AppSidebar.vue index 9853ed02..d66ad4d3 100644 --- a/src/components/AppSidebar.vue +++ b/src/components/AppSidebar.vue @@ -1,7 +1,7 @@ diff --git a/src/components/FavoriteFolderManager.vue b/src/components/FavoriteFolderManager.vue index b0468a88..1791189b 100644 --- a/src/components/FavoriteFolderManager.vue +++ b/src/components/FavoriteFolderManager.vue @@ -109,6 +109,9 @@ > 选择可安装 + + {{ installableSelectionMessage }} + - + + {{ folder.name }} + + +

+ {{ actionMessage }} +

+ +
+ + +
+

正在加载评价...

{{ error }}

-
+
- - + + +
- - {{ review.userDisplayName || "星火用户" }} - + {{ review.rating }} 星 @@ -136,11 +194,148 @@ > {{ review.content || "暂无评论内容" }}

+
+ + + +
+
+ +
+ + +
+
+
+
+
+ + {{ reply.userDisplayName || "星火用户" }} + + 已删除 +
+

+ {{ reply.content || "该回复已删除" }} +

+
+ + + +
+
+ +
+ + +
+
+
+
-

暂无评价

+

+ {{ reviews.length ? "没有符合筛选条件的评价" : "暂无评价" }} +

@@ -148,12 +343,18 @@ import { computed, onMounted, ref, watch } from "vue"; import { + createReviewReply, + deleteReview, + deleteReviewReply, fetchRatingSummary, fetchReviews, + likeReview, + likeReviewReply, submitReview, } from "@/modules/backendApi"; import type { AppReview, + AppReviewReply, RatingSummary, ReviewTags, } from "@/global/typedefinition"; @@ -164,23 +365,37 @@ const props = withDefaults( tags: ReviewTags; loggedIn: boolean; canSubmit?: boolean; + currentUserId?: number; + currentUserIsAdmin?: boolean; }>(), - { canSubmit: true }, + { canSubmit: true, currentUserId: undefined, currentUserIsAdmin: false }, ); const emit = defineEmits<{ "request-login": [message: string]; + "show-user": [review: AppReview]; }>(); -const ratingOptions = [5, 4, 3, 2, 1]; +const ratingOptions = [1, 2, 3, 4, 5]; const rating = ref(5); +const ratingSliding = ref(false); const content = ref(""); const reviews = ref([]); const summary = ref(null); const loading = ref(false); const submitting = ref(false); const error = ref(""); +const actionMessage = ref(""); const loadGeneration = ref(0); +const selectedPackageArch = ref(""); +const selectedDistro = ref(""); +const replyContent = ref(""); +const replySubmitting = ref(false); +const replyTarget = ref<{ reviewId: number; parentId?: number } | null>(null); + +interface FlatReply extends AppReviewReply { + depth: number; +} const ratingText = computed(() => { if (!summary.value || summary.value.reviewCount === 0) return "暂无评分"; @@ -189,24 +404,157 @@ const ratingText = computed(() => { const canSubmit = computed(() => props.canSubmit); -const toReviewErrorMessage = (caught: unknown): string => { +const packageArchOptions = computed(() => + [ + ...new Set( + reviews.value.map((review) => review.packageArch).filter(Boolean), + ), + ].sort(), +); + +const distroOptions = computed(() => + [ + ...new Set(reviews.value.map((review) => review.distro).filter(Boolean)), + ].sort(), +); + +const filteredReviews = computed(() => + reviews.value.filter( + (review) => + (!selectedPackageArch.value || + review.packageArch === selectedPackageArch.value) && + (!selectedDistro.value || review.distro === selectedDistro.value), + ), +); + +const toReviewErrorMessage = ( + caught: unknown, + fallback = "发表评论失败", +): string => { const message = caught instanceof Error ? caught.message : ""; if (message === "Network Error") { return "无法连接星火账号服务,请稍后重试。"; } - return message || "发表评论失败"; + if (message.includes("401")) { + return "登录状态已失效,请重新登录星火账号。"; + } + return message || fallback; +}; + +const clampRating = (value: number): number => Math.min(5, Math.max(1, value)); + +const updateRatingFromPointer = (event: PointerEvent): void => { + const target = event.currentTarget; + if (!(target instanceof HTMLElement)) return; + const rect = target.getBoundingClientRect(); + if (rect.width <= 0) return; + const eventWithClientX = event as PointerEvent & { clientX: number }; + const ratio = (eventWithClientX.clientX - rect.left) / rect.width; + rating.value = clampRating(Math.ceil(ratio * 5)); +}; + +const startRatingSlide = (event: PointerEvent): void => { + ratingSliding.value = true; + const target = event.currentTarget; + if (target instanceof HTMLElement && "setPointerCapture" in target) { + target.setPointerCapture(event.pointerId); + } + updateRatingFromPointer(event); +}; + +const moveRatingSlide = (event: PointerEvent): void => { + updateRatingFromPointer(event); +}; + +const stopRatingSlide = (event: PointerEvent): void => { + ratingSliding.value = false; + const target = event.currentTarget; + if ( + target instanceof HTMLElement && + "hasPointerCapture" in target && + target.hasPointerCapture(event.pointerId) + ) { + target.releasePointerCapture(event.pointerId); + } +}; + +const handleRatingKeydown = (event: KeyboardEvent): void => { + if (event.key === "ArrowLeft" || event.key === "ArrowDown") { + event.preventDefault(); + rating.value = clampRating(rating.value - 1); + } + if (event.key === "ArrowRight" || event.key === "ArrowUp") { + event.preventDefault(); + rating.value = clampRating(rating.value + 1); + } }; const hideAvatar = (event: Event) => { (event.target as HTMLElement).style.display = "none"; }; +const reviewerName = (review: AppReview): string => + review.userDisplayName || "星火用户"; + +const showReviewer = (review: AppReview) => { + actionMessage.value = `正在查看 ${reviewerName(review)} 的资料`; + emit("show-user", review); +}; + +const canDeleteReview = (review: AppReview): boolean => + review.canDelete === true; + +const flattenReplies = (items: AppReviewReply[] = [], depth = 0): FlatReply[] => + items.flatMap((reply) => [ + { ...reply, depth }, + ...flattenReplies(reply.replies, depth + 1), + ]); + +const startReply = (reviewId: number, parentId?: number) => { + replyTarget.value = { reviewId, parentId }; + replyContent.value = ""; + actionMessage.value = ""; +}; + +const cancelReply = () => { + replyTarget.value = null; + replyContent.value = ""; +}; + +const toReviewActionErrorMessage = ( + caught: unknown, + fallback = "操作失败,请稍后重试。", +): string => { + return toReviewErrorMessage(caught, fallback); +}; + +const resetFilters = () => { + selectedPackageArch.value = ""; + selectedDistro.value = ""; +}; + +const resetStaleFilters = () => { + if ( + selectedPackageArch.value && + !packageArchOptions.value.includes(selectedPackageArch.value) + ) { + selectedPackageArch.value = ""; + } + if ( + selectedDistro.value && + !distroOptions.value.includes(selectedDistro.value) + ) { + selectedDistro.value = ""; + } +}; + const clearReviewState = () => { loadGeneration.value += 1; reviews.value = []; summary.value = null; loading.value = false; error.value = ""; + actionMessage.value = ""; }; const loadReviews = async () => { @@ -227,9 +575,10 @@ const loadReviews = async () => { if (generation !== loadGeneration.value || appKey !== props.appKey) return; summary.value = nextSummary; reviews.value = nextReviews; + resetStaleFilters(); } catch (caught: unknown) { if (generation !== loadGeneration.value || appKey !== props.appKey) return; - error.value = (caught as Error)?.message || "加载评价失败"; + error.value = toReviewErrorMessage(caught, "加载评价失败"); } finally { if (generation === loadGeneration.value && appKey === props.appKey) { loading.value = false; @@ -262,6 +611,85 @@ const submit = async () => { } }; +const toggleReviewLike = async (review: AppReview) => { + try { + await likeReview(props.appKey, review.id); + await loadReviews(); + } catch (caught: unknown) { + actionMessage.value = toReviewActionErrorMessage( + caught, + "请登录星火账号后再点赞。", + ); + } +}; + +const toggleReplyLike = async (reviewId: number, reply: AppReviewReply) => { + try { + await likeReviewReply(props.appKey, reviewId, reply.id); + await loadReviews(); + } catch (caught: unknown) { + actionMessage.value = toReviewActionErrorMessage( + caught, + "请登录星火账号后再点赞。", + ); + } +}; + +const submitReply = async () => { + const target = replyTarget.value; + const trimmed = replyContent.value.trim(); + if (!target || trimmed === "") return; + replySubmitting.value = true; + try { + await createReviewReply(props.appKey, target.reviewId, { + content: trimmed, + ...(target.parentId === undefined ? {} : { parentId: target.parentId }), + }); + cancelReply(); + await loadReviews(); + } catch (caught: unknown) { + actionMessage.value = toReviewActionErrorMessage( + caught, + "请登录星火账号后再回复。", + ); + } finally { + replySubmitting.value = false; + } +}; + +const removeReview = async (review: AppReview) => { + if (!canDeleteReview(review)) return; + try { + await deleteReview(props.appKey, review.id); + await loadReviews(); + } catch (caught: unknown) { + actionMessage.value = toReviewActionErrorMessage( + caught, + "没有权限删除该内容。请刷新后重试。", + ); + } +}; + +const removeReply = async (reviewId: number, reply: AppReviewReply) => { + if (!reply.canDelete) return; + try { + await deleteReviewReply(props.appKey, reviewId, reply.id); + await loadReviews(); + } catch (caught: unknown) { + actionMessage.value = toReviewActionErrorMessage( + caught, + "没有权限删除该内容。请刷新后重试。", + ); + } +}; + onMounted(loadReviews); -watch(() => [props.appKey, props.loggedIn], loadReviews); +watch( + () => props.appKey, + () => { + resetFilters(); + void loadReviews(); + }, +); +watch(() => props.loggedIn, loadReviews); diff --git a/src/components/UserManagementModal.vue b/src/components/UserManagementModal.vue new file mode 100644 index 00000000..bf640dbd --- /dev/null +++ b/src/components/UserManagementModal.vue @@ -0,0 +1,150 @@ + + + diff --git a/src/components/UserManagementView.vue b/src/components/UserManagementView.vue index ce3cc8e5..f5405f30 100644 --- a/src/components/UserManagementView.vue +++ b/src/components/UserManagementView.vue @@ -2,6 +2,12 @@
+
@@ -179,6 +185,10 @@ const userInitial = computed(() => (props.user.displayName || props.user.username || "?").slice(0, 1), ); +const coverStyle = computed(() => ({ + backgroundImage: props.user.coverUrl ? `url("${props.user.coverUrl}")` : "", +})); + const visibleForumGroups = computed(() => props.user.forumGroups.filter((group) => group !== props.user.forumLevel), ); diff --git a/src/components/WindowTitleBar.vue b/src/components/WindowTitleBar.vue new file mode 100644 index 00000000..026a6ddd --- /dev/null +++ b/src/components/WindowTitleBar.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/src/global/authState.ts b/src/global/authState.ts index 1751002d..49637e82 100644 --- a/src/global/authState.ts +++ b/src/global/authState.ts @@ -14,6 +14,7 @@ const isSparkUser = (value: unknown): value is SparkUser => { typeof user.username === "string" && typeof user.displayName === "string" && typeof user.avatarUrl === "string" && + (user.coverUrl === undefined || typeof user.coverUrl === "string") && typeof user.forumLevel === "string" && Array.isArray(user.forumGroups) && user.forumGroups.every((group) => typeof group === "string") diff --git a/src/global/storeConfig.ts b/src/global/storeConfig.ts index d5de8b4b..a0221791 100644 --- a/src/global/storeConfig.ts +++ b/src/global/storeConfig.ts @@ -13,6 +13,10 @@ export const SPARK_BACKEND_BASE_URL: string = import.meta.env.VITE_SPARK_BACKEND_BASE_URL || DEFAULT_SPARK_BACKEND_BASE_URL; +export const SPARK_ACCOUNT_CENTER_URL: string = + import.meta.env.VITE_SPARK_ACCOUNT_CENTER_URL || + "https://account.spark-app.store/account"; + export const FLARUM_BASE_URL = "https://bbs.spark-app.store"; export const FLARUM_REGISTER_URL = `${FLARUM_BASE_URL}/register`; export const FLARUM_PROFILE_URL = `${FLARUM_BASE_URL}/u`; diff --git a/src/global/typedefinition.ts b/src/global/typedefinition.ts index 135848c7..d47a2718 100644 --- a/src/global/typedefinition.ts +++ b/src/global/typedefinition.ts @@ -256,10 +256,19 @@ export interface SparkUser { username: string; displayName: string; avatarUrl: string; + coverUrl?: string; forumLevel: string; forumGroups: string[]; } +export interface ReviewUserProfile { + displayName: string; + username?: string; + avatarUrl?: string; + coverUrl?: string; + forumGroups?: string[]; +} + export interface AuthSession { accessToken: string; tokenType: "bearer"; @@ -287,8 +296,26 @@ export interface RatingSummary { starCounts: Record; } +export interface AppReviewReply { + id: number; + reviewId: number; + parentId: number | null; + content: string; + createdAt: string; + updatedAt: string; + userDisplayName: string; + userAvatarUrl: string; + likeCount: number; + likedByCurrentUser: boolean; + canDelete: boolean; + isAuthor: boolean; + isDeleted: boolean; + replies: AppReviewReply[]; +} + export interface AppReview { id: number; + userId?: number; rating: number; content: string; version: string; @@ -301,6 +328,12 @@ export interface AppReview { updatedAt: string; userDisplayName: string; userAvatarUrl: string; + likeCount?: number; + likedByCurrentUser?: boolean; + canDelete?: boolean; + isAuthor?: boolean; + isDeleted?: boolean; + replies?: AppReviewReply[]; } export interface FavoriteFolder { diff --git a/src/modules/accountCenterUrl.ts b/src/modules/accountCenterUrl.ts new file mode 100644 index 00000000..2f599ced --- /dev/null +++ b/src/modules/accountCenterUrl.ts @@ -0,0 +1,26 @@ +export const FALLBACK_ACCOUNT_CENTER_URL = + "https://account.spark-app.store/account"; + +export const buildAccountFrameUrl = ( + baseUrl: string, + username: string, +): string => { + let url: URL; + + try { + url = new URL(baseUrl); + } catch { + url = new URL(FALLBACK_ACCOUNT_CENTER_URL); + } + + if (url.protocol !== "http:" && url.protocol !== "https:") { + url = new URL(FALLBACK_ACCOUNT_CENTER_URL); + } + + const allowedParams = new URLSearchParams(); + allowedParams.set("view", "management"); + allowedParams.set("user", username); + url.search = allowedParams.toString(); + + return url.toString(); +}; diff --git a/src/modules/appListSync.ts b/src/modules/appListSync.ts index a35129ea..bd3ffee1 100644 --- a/src/modules/appListSync.ts +++ b/src/modules/appListSync.ts @@ -51,3 +51,26 @@ export const mergeInstalledApps = ( ...refreshedApps, ]; }; + +export const resolveCloudInstallCandidate = ( + item: SyncedAppListItem, + apps: App[], +): App | null => { + const exactMatch = apps.find( + (app) => + app.pkgname === item.pkgname && + app.origin === item.origin && + app.category === item.category, + ); + + const sameOriginPackageMatch = apps.find( + (app) => app.pkgname === item.pkgname && app.origin === item.origin, + ); + + return ( + exactMatch ?? + sameOriginPackageMatch ?? + apps.find((app) => app.pkgname === item.pkgname) ?? + null + ); +}; diff --git a/src/modules/backendApi.ts b/src/modules/backendApi.ts index 84471b4b..1c59a5a8 100644 --- a/src/modules/backendApi.ts +++ b/src/modules/backendApi.ts @@ -4,6 +4,7 @@ import pino from "pino"; import { SPARK_BACKEND_BASE_URL } from "@/global/storeConfig"; import type { AppReview, + AppReviewReply, AuthSession, DownloadedAppList, DownloadedAppRecord, @@ -75,7 +76,10 @@ const normalizeBackendMutationError = (error: unknown): Error => { } const status = error.response.status; - if (status === 401 || status === 403) { + if (status === 401) { + return new Error("登录状态已失效,请重新登录星火账号。"); + } + if (status === 403) { return new Error("请登录星火账号后重试。"); } if (status === 422) { @@ -122,12 +126,14 @@ const toUser = (raw: ApiRecord): SparkUser => ({ username: String(raw.username || ""), displayName: String(raw.display_name || raw.username || ""), avatarUrl: String(raw.avatar_url || ""), + coverUrl: String(raw.cover_url || raw.coverUrl || "") || undefined, forumLevel: String(raw.forum_level || "论坛用户"), forumGroups: parseForumGroups(raw.forum_groups), }); const toReview = (raw: ApiRecord): AppReview => ({ id: Number(raw.id), + userId: raw.user_id === undefined ? undefined : Number(raw.user_id), rating: Number(raw.rating), content: String(raw.content || ""), version: String(raw.version || "unknown"), @@ -140,6 +146,47 @@ const toReview = (raw: ApiRecord): AppReview => ({ updatedAt: String(raw.updated_at || ""), userDisplayName: String(raw.user_display_name || ""), userAvatarUrl: String(raw.user_avatar_url || ""), + likeCount: Number(raw.like_count || 0), + likedByCurrentUser: Boolean(raw.liked_by_current_user), + canDelete: raw.can_delete === undefined ? undefined : Boolean(raw.can_delete), + isAuthor: raw.is_author === undefined ? undefined : Boolean(raw.is_author), + isDeleted: Boolean(raw.is_deleted), + replies: asApiRecordArray(raw.replies).map(toReviewReply), +}); + +const toReviewReply = (raw: ApiRecord): AppReviewReply => ({ + id: Number(raw.id), + reviewId: Number(raw.review_id), + parentId: + raw.parent_id === null || raw.parent_id === undefined + ? null + : Number(raw.parent_id), + content: String(raw.content || ""), + createdAt: String(raw.created_at || ""), + updatedAt: String(raw.updated_at || ""), + userDisplayName: String(raw.user_display_name || ""), + userAvatarUrl: String(raw.user_avatar_url || ""), + likeCount: Number(raw.like_count || 0), + likedByCurrentUser: Boolean(raw.liked_by_current_user), + canDelete: Boolean(raw.can_delete), + isAuthor: Boolean(raw.is_author), + isDeleted: Boolean(raw.is_deleted), + replies: asApiRecordArray(raw.replies).map(toReviewReply), +}); + +export interface ReviewActionState { + likedByCurrentUser: boolean; + likeCount: number; +} + +export interface CreateReviewReplyPayload { + content: string; + parentId?: number; +} + +const toReviewActionState = (raw: ApiRecord): ReviewActionState => ({ + likedByCurrentUser: Boolean(raw.liked_by_current_user), + likeCount: Number(raw.like_count || 0), }); const toFavoriteFolder = (raw: ApiRecord): FavoriteFolder => ({ @@ -289,6 +336,86 @@ export const submitReview = async ( return toReview(asApiRecord(response.data)); }; +export const likeReview = async ( + appKey: string, + reviewId: number, +): Promise => { + let response: AxiosResponse; + try { + response = await backend.post( + `/apps/${encodeURIComponent(appKey)}/reviews/${reviewId}/like`, + ); + } catch (error) { + throw normalizeBackendMutationError(error); + } + return toReviewActionState(asApiRecord(response.data)); +}; + +export const createReviewReply = async ( + appKey: string, + reviewId: number, + payload: CreateReviewReplyPayload, +): Promise => { + let response: AxiosResponse; + try { + response = await backend.post( + `/apps/${encodeURIComponent(appKey)}/reviews/${reviewId}/replies`, + { + content: payload.content, + ...(payload.parentId === undefined + ? {} + : { parent_id: payload.parentId }), + }, + ); + } catch (error) { + throw normalizeBackendMutationError(error); + } + return toReviewReply(asApiRecord(response.data)); +}; + +export const deleteReview = async ( + appKey: string, + reviewId: number, +): Promise => { + try { + await backend.delete( + `/apps/${encodeURIComponent(appKey)}/reviews/${reviewId}`, + ); + } catch (error) { + throw normalizeBackendMutationError(error); + } +}; + +export const likeReviewReply = async ( + appKey: string, + reviewId: number, + replyId: number, +): Promise => { + let response: AxiosResponse; + try { + response = await backend.post( + `/apps/${encodeURIComponent(appKey)}/reviews/${reviewId}/replies/${replyId}/like`, + ); + } catch (error) { + throw normalizeBackendMutationError(error); + } + return toReviewActionState(asApiRecord(response.data)); +}; + +export const deleteReviewReply = async ( + appKey: string, + reviewId: number, + replyId: number, +): Promise => { + try { + await backend.delete( + `/apps/${encodeURIComponent(appKey)}/reviews/${reviewId}/replies/${replyId}`, + ); + } catch (error) { + throw normalizeBackendMutationError(error); + } +}; + export const listFavoriteFolders = async (): Promise => { const response = await backend.get("/me/favorite-folders"); return asApiRecordArray(response.data).map(toFavoriteFolder); diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 1eca2b6e..d33ad11e 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -20,6 +20,7 @@ declare global { apm_store: { arch: string; }; + windowControls: WindowControlBridge; updateCenter: UpdateCenterBridge; } } @@ -31,6 +32,12 @@ interface IpcRendererFacade { invoke: import("electron").IpcRenderer["invoke"]; } +interface WindowControlBridge { + minimize: () => void; + toggleMaximize: () => void; + close: () => void; +} + // IPC channel type definitions declare interface IpcChannels { "get-app-version": () => string;