diff --git a/bun.lock b/bun.lock index 7825917..abd3a9a 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "@hono/node-server": "^1.19.11", "@hono/zod-validator": "^0.7.6", "@pinia/colada": "^1.0.0", + "@tanstack/vue-table": "^8.21.3", "@unhead/vue": "^2.1.12", "@vueuse/core": "^14.2.1", "aws4fetch": "^1.0.20", @@ -22,7 +23,9 @@ "i18next-vue": "^5.4.0", "is-mobile": "^5.0.0", "pinia": "^3.0.4", + "superjson": "^2.2.6", "tailwind-merge": "^3.5.0", + "tweetnacl": "^1.0.3", "vue": "^3.5.30", "vue-router": "^5.0.3", "zod": "^4.3.6", @@ -295,6 +298,10 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="], + "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], + + "@tanstack/vue-table": ["@tanstack/vue-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "vue": ">=3.2" } }, "sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="], @@ -643,6 +650,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tweetnacl": ["tweetnacl@1.0.3", "", {}, "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="], + "type-level-regexp": ["type-level-regexp@0.1.17", "", {}, "sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg=="], "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], diff --git a/components.d.ts b/components.d.ts index 6214150..7970f8d 100644 --- a/components.d.ts +++ b/components.d.ts @@ -27,6 +27,7 @@ declare module 'vue' { AppTopLoadingBar: typeof import('./src/components/AppTopLoadingBar.vue')['default'] ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default'] ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default'] + BaseTable: typeof import('./src/components/ui/table/BaseTable.vue')['default'] Bell: typeof import('./src/components/icons/Bell.vue')['default'] BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default'] Chart: typeof import('./src/components/icons/Chart.vue')['default'] @@ -107,6 +108,7 @@ declare global { const AppTopLoadingBar: typeof import('./src/components/AppTopLoadingBar.vue')['default'] const ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default'] const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default'] + const BaseTable: typeof import('./src/components/ui/table/BaseTable.vue')['default'] const Bell: typeof import('./src/components/icons/Bell.vue')['default'] const BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default'] const Chart: typeof import('./src/components/icons/Chart.vue')['default'] diff --git a/package.json b/package.json index e5686d3..3f8e774 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@hono/node-server": "^1.19.11", "@hono/zod-validator": "^0.7.6", "@pinia/colada": "^1.0.0", + "@tanstack/vue-table": "^8.21.3", "@unhead/vue": "^2.1.12", "@vueuse/core": "^14.2.1", "aws4fetch": "^1.0.20", @@ -24,7 +25,9 @@ "i18next-vue": "^5.4.0", "is-mobile": "^5.0.0", "pinia": "^3.0.4", + "superjson": "^2.2.6", "tailwind-merge": "^3.5.0", + "tweetnacl": "^1.0.3", "vue": "^3.5.30", "vue-router": "^5.0.3", "zod": "^4.3.6" diff --git a/scripts/gen-nacl-keys.ts b/scripts/gen-nacl-keys.ts new file mode 100644 index 0000000..f770d98 --- /dev/null +++ b/scripts/gen-nacl-keys.ts @@ -0,0 +1,7 @@ +// scripts/gen-nacl-keys.ts +import nacl from "tweetnacl"; + +const kp = nacl.box.keyPair(); + +console.log("PUBLIC_KEY_BASE64=", Buffer.from(kp.publicKey).toString("base64")); +console.log("SECRET_KEY_BASE64=", Buffer.from(kp.secretKey).toString("base64")); \ No newline at end of file diff --git a/src/api/httpClientAdapter.client.ts b/src/api/httpClientAdapter.client.ts index 6431b06..bba1e02 100644 --- a/src/api/httpClientAdapter.client.ts +++ b/src/api/httpClientAdapter.client.ts @@ -6,12 +6,19 @@ const GET_PAYLOAD_PARAM = "payload"; export function httpClientAdapter(opts: { url: string; pathsForGET?: string[]; + JSON?: Partial; headers?: () => Promise> | Record; }): TinyRpcClientAdapter { + const JSON: JsonTransformer = { + parse: globalThis.JSON.parse, + stringify: globalThis.JSON.stringify, + ...opts.JSON, + }; return { send: async (data) => { const url = [opts.url, data.path].join("/"); const payload = JSON.stringify(data.args); + console.log("RPC Request:", payload); const method = opts.pathsForGET?.includes(data.path) ? "GET" : "POST"; diff --git a/src/api/httpClientAdapter.server.ts b/src/api/httpClientAdapter.server.ts index cfbc77c..1a98ead 100644 --- a/src/api/httpClientAdapter.server.ts +++ b/src/api/httpClientAdapter.server.ts @@ -8,12 +8,19 @@ export const baseAPIURL = "https://api.pipic.fun"; export function httpClientAdapter(opts: { url: string; pathsForGET?: string[]; + JSON?: Partial; headers?: () => Promise> | Record; }): TinyRpcClientAdapter { + const JSON: JsonTransformer = { + parse: globalThis.JSON.parse, + stringify: globalThis.JSON.stringify, + ...opts.JSON, + }; return { send: async (data) => { const url = [opts.url, data.path].join("/"); const payload = JSON.stringify(data.args); + console.log("RPC Request:", payload); const method = opts.pathsForGET?.includes(data.path) ? "GET" : "POST"; diff --git a/src/api/rpcclient.ts b/src/api/rpcclient.ts index fdae708..8f03be9 100644 --- a/src/api/rpcclient.ts +++ b/src/api/rpcclient.ts @@ -1,12 +1,24 @@ +import type { RpcRoutes } from "@/server/routes/rpc"; import { proxyTinyRpc } from "@hiogawa/tiny-rpc"; import { httpClientAdapter } from "@httpClientAdapter"; -import type { RpcRoutes } from "@/server/routes/rpc"; const endpoint = "/rpc"; const publicEndpoint = "/rpc-public"; const url = import.meta.env.SSR ? "http://localhost" : ""; const publicMethods = ["login", "register", "forgotPassword", "resetPassword", "getGoogleLoginUrl"]; +// src/client/trpc-client-transformer.ts +import { + clientJSON +} from "@/shared/secure-json-transformer"; + +// export function createTrpcClientTransformer(cfg: ServerPublicKeyConfig) { +// return { +// input: , +// output: superjson, +// }; +// } +// const secureConfig = await fetch("/trpc-secure-config").then((r) => r.json()); export const client = proxyTinyRpc({ adapter: { send: async (data) => { @@ -14,6 +26,7 @@ export const client = proxyTinyRpc({ return await httpClientAdapter({ url: `${url}${targetEndpoint}`, pathsForGET: ["health"], + JSON: clientJSON, }).send(data); }, }, diff --git a/src/components/ui/table/BaseTable.vue b/src/components/ui/table/BaseTable.vue new file mode 100644 index 0000000..57abba4 --- /dev/null +++ b/src/components/ui/table/BaseTable.vue @@ -0,0 +1,153 @@ + + + diff --git a/src/routes/admin/AdTemplates.vue b/src/routes/admin/AdTemplates.vue index 6b4b4e2..e9ce070 100644 --- a/src/routes/admin/AdTemplates.vue +++ b/src/routes/admin/AdTemplates.vue @@ -3,8 +3,13 @@ import { client as rpcClient } from "@/api/rpcclient"; import AppButton from "@/components/app/AppButton.vue"; import AppDialog from "@/components/app/AppDialog.vue"; import AppInput from "@/components/app/AppInput.vue"; -import { computed, onMounted, reactive, ref } from "vue"; +import BaseTable from "@/components/ui/table/BaseTable.vue"; +import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue"; +import { type ColumnDef } from "@tanstack/vue-table"; +import { computed, h, onMounted, reactive, ref } from "vue"; +import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue"; import AdminSectionShell from "./components/AdminSectionShell.vue"; +import { useAdminPageHeader } from "./components/useAdminPageHeader"; type ListTemplatesResponse = Awaited>; type AdminAdTemplateRow = NonNullable[number]; @@ -25,6 +30,7 @@ const appliedSearch = ref(""); const ownerFilter = ref(""); const appliedOwnerFilter = ref(""); const createOpen = ref(false); +const detailOpen = ref(false); const editOpen = ref(false); const deleteOpen = ref(false); @@ -86,7 +92,7 @@ const loadTemplates = async () => { total.value = response.total ?? rows.value.length; limit.value = response.limit ?? limit.value; page.value = response.page ?? page.value; - if (selectedRow.value?.id) { + if (selectedRow.value?.id && (detailOpen.value || editOpen.value || deleteOpen.value)) { const fresh = rows.value.find((row) => row.id === selectedRow.value?.id); if (fresh) selectedRow.value = fresh; } @@ -110,6 +116,7 @@ const resetCreateForm = () => { const closeDialogs = () => { createOpen.value = false; + detailOpen.value = false; editOpen.value = false; deleteOpen.value = false; actionError.value = null; @@ -122,6 +129,12 @@ const applyFilters = async () => { await loadTemplates(); }; +const openDetailDialog = (row: AdminAdTemplateRow) => { + selectedRow.value = row; + actionError.value = null; + detailOpen.value = true; +}; + const openEditDialog = (row: AdminAdTemplateRow) => { selectedRow.value = row; actionError.value = null; @@ -228,128 +241,185 @@ const formatDate = (value?: string) => { return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); }; +const columns = computed[]>(() => [ + { + id: "template", + header: "Template", + accessorFn: row => row.name || "", + cell: ({ row }) => h("button", { class: "text-left", onClick: () => { openDetailDialog(row.original); } }, [ + h("div", { class: "font-medium text-foreground" }, row.original.name), + h("div", { class: "mt-1 text-xs text-foreground/60" }, row.original.ownerEmail || row.original.userId || "No owner"), + ]), + meta: { + headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50", + cellClass: "px-4 py-3", + }, + }, + { + id: "owner", + header: "Owner", + accessorFn: row => row.ownerEmail || row.userId || "", + cell: ({ row }) => h("span", { class: "text-foreground/70" }, row.original.ownerEmail || row.original.userId || "—"), + meta: { + headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50", + cellClass: "px-4 py-3", + }, + }, + { + id: "format", + header: "Format", + accessorFn: row => row.adFormat || "", + cell: ({ row }) => h("span", { class: "text-foreground/70" }, row.original.adFormat || "pre-roll"), + meta: { + headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50", + cellClass: "px-4 py-3", + }, + }, + { + id: "status", + header: "Status", + accessorFn: row => row.isActive ? "ACTIVE" : "INACTIVE", + cell: ({ row }) => h("span", { class: "text-foreground/70" }, row.original.isActive ? "ACTIVE" : "INACTIVE"), + meta: { + headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50", + cellClass: "px-4 py-3", + }, + }, + { + id: "default", + header: "Default", + accessorFn: row => row.isDefault ? "YES" : "NO", + cell: ({ row }) => h("span", { class: "text-foreground/70" }, row.original.isDefault ? "YES" : "NO"), + meta: { + headerClass: "px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50", + cellClass: "px-4 py-3", + }, + }, + { + id: "actions", + header: "Actions", + enableSorting: false, + cell: ({ row }) => h("div", { class: "flex justify-end gap-2" }, [ + h(AppButton, { size: "sm", variant: "secondary", onClick: () => openEditDialog(row.original) }, { default: () => "Edit" }), + h(AppButton, { size: "sm", variant: "danger", onClick: () => openDeleteDialog(row.original) }, { default: () => "Delete" }), + ]), + meta: { + headerClass: "px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-foreground/50", + cellClass: "px-4 py-3 text-right", + }, + }, +]); + +useAdminPageHeader(() => ({ + eyebrow: "Advertising", + badge: `${total.value} total templates`, + actions: [ + { + label: "Refresh", + variant: "secondary", + onClick: loadTemplates, + }, + { + label: "Create template", + onClick: () => { + actionError.value = null; + createOpen.value = true; + }, + }, + ], +})); + onMounted(loadTemplates);