Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,15 @@ NUXT_CLOUDFLARE_API_TOKEN=

# ===================================
# Website Environment (Required)
# API base url:
# WolfStar bot API origin used by `$api` (legacy-style direct calls).
# Production/internal: https://api.wolfstar.rocks (default when unset)
# Local bot instance: http://localhost:8282
NUXT_PUBLIC_API_BASE_URL=http://localhost:8282

# Optional: sapphire-plugin-api cookie name for SSR auth cookie injection.
# Defaults to SAPPHIRE_AUTH. Encryption uses NUXT_OAUTH_DISCORD_CLIENT_SECRET.
# NUXT_BOT_API_AUTH_COOKIE=SAPPHIRE_AUTH

# Site URL (e.g., https://wolfstar.rocks)
NUXT_PUBLIC_SITE_URL=
# ===================================
Expand Down
43 changes: 6 additions & 37 deletions app/components/command/SubCategory.vue
Original file line number Diff line number Diff line change
@@ -1,51 +1,20 @@
<template>
<div
v-if="filteredCommands.length > 0"
role="region"
:aria-labelledby="`category-${categoryName.replace(/\s+/g, '-').toLowerCase()}`"
>
<div v-if="filteredCommands.length > 0" class="animate-fade-in">
<UAccordion
:items="categoryItems"
:ui="{
root: 'space-y-3',
item: '',
trigger:
'w-full justify-between rounded-2xl border border-base-content/10 bg-base-200/50 px-6 py-4 transition-all hover:bg-base-200/80 data-[state=open]:rounded-b-none',
content:
'rounded-2xl rounded-t-none overflow-hidden transition-all border border-t-0 border-base-content/10 bg-base-200/30',
label: 'truncate text-xl font-bold',
trailingIcon: 'size-5 shrink-0',
}"
class="animate-fade-in"
>
<template #default="{ item, open }">
<UButton
color="neutral"
variant="ghost"
class="w-full justify-between rounded-2xl border border-base-content/10 bg-base-200/50 px-6 py-4 transition-all hover:bg-base-200/80"
:class="{ 'rounded-b-none': open }"
:aria-expanded="open"
:aria-controls="`category-content-${item.value.replace(/\s+/g, '-').toLowerCase()}`"
>
<span
:id="`category-${item.value.replace(/\s+/g, '-').toLowerCase()}`"
class="truncate text-xl font-bold"
>
{{ item.label }}
</span>

<template #trailing>
<UIcon
name="i-heroicons-chevron-down-20-solid"
class="size-5 shrink-0 transform transition-transform duration-200"
:class="[open && 'rotate-180']"
aria-hidden="true"
/>
</template>
</UButton>
</template>

<template #body="{ item }">
<div
:id="`category-content-${item.value.replace(/\s+/g, '-').toLowerCase()}`"
class="space-y-3 p-4"
>
<div class="space-y-3 p-4">
<UAccordion
:items="
getCommandsByCategory(item.label).map((command) => ({
Expand Down
4 changes: 4 additions & 0 deletions app/composables/createApiComposable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ export interface ApiComposableOptions {
immediate?: boolean;
}

/**
* Lazy-fetch composable for WolfStar bot API routes via `$api`
* (e.g. `/commands`, `/languages`, `/guilds/:id`).
*/
export function createApiComposable<T>(
key: string,
endpoint: string,
Expand Down
12 changes: 8 additions & 4 deletions app/composables/useAuditLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ export function useAuditLog({
const resolvedOffset = computed(() => (offset !== undefined ? toValue(offset) : undefined));
const resolvedFilters = computed(() => (filters !== undefined ? toValue(filters) : undefined));

const { $api } = useNuxtApp();

const asyncData = useLazyAsyncData(
() =>
`guild:${toValue(guildId)}:logs:activity:${resolvedLimit.value ?? "default"}:${resolvedOffset.value ?? 0}:${JSON.stringify(resolvedFilters.value ?? {})}`,
() => {
async () => {
const query: Record<string, string | number> = {};
if (resolvedLimit.value !== undefined) query.limit = resolvedLimit.value;
if (resolvedOffset.value !== undefined) query.offset = resolvedOffset.value;
Expand All @@ -44,9 +46,11 @@ export function useAuditLog({
if (f.to) query.to = f.to;
if (f.q) query.q = f.q;
}
return $fetch<AuditLogResponse>(`/api/guilds/${toValue(guildId)}/logs`, {
query,
});
const { data } = await $api<AuditLogResponse>(
`/guilds/${toValue(guildId)}/audit-logs`,
{ query },
);
return data;
},
{
immediate: immediate !== false,
Expand Down
12 changes: 8 additions & 4 deletions app/composables/useCommandLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ export function useCommandLog({
const resolvedOffset = computed(() => (offset !== undefined ? toValue(offset) : undefined));
const resolvedFilters = computed(() => (filters !== undefined ? toValue(filters) : undefined));

const { $api } = useNuxtApp();

const asyncData = useLazyAsyncData(
() =>
`guild:${toValue(guildId)}:logs:commands:${resolvedLimit.value ?? "default"}:${resolvedOffset.value ?? 0}:${JSON.stringify(resolvedFilters.value || {})}`,
() => {
async () => {
const query: Record<string, string | number> = {};
if (resolvedLimit.value !== undefined) query.limit = resolvedLimit.value;
if (resolvedOffset.value !== undefined) query.offset = resolvedOffset.value;
Expand All @@ -46,9 +48,11 @@ export function useCommandLog({
if (f.to) query.to = f.to;
if (f.q) query.q = f.q;
}
return $fetch<CommandLogResponse>(`/api/guilds/${toValue(guildId)}/logs/commands`, {
query,
});
const { data } = await $api<CommandLogResponse>(
`/guilds/${toValue(guildId)}/command-logs`,
{ query },
);
return data;
},
{
immediate: immediate !== false,
Expand Down
2 changes: 2 additions & 0 deletions app/composables/useCommands.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { FlattenedCommand } from "#shared/types/discord";

export function useCommands(options?: ApiComposableOptions) {
return createApiComposable<FlattenedCommand[]>("wolfstar:commands", "/commands", [], options);
}
58 changes: 30 additions & 28 deletions app/layouts/dashboard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ watch(
{ immediate: true },
);

const requestFetch = useRequestFetch();
const { $api } = useNuxtApp();
const route = useRoute();
const refreshGuildCache = computed(() => route.query.refresh === "true");

Expand All @@ -200,17 +200,18 @@ const {
error,
} = useAsyncData(
() => `dashboard:guild:${guildId.value}`,
() => {
async () => {
const refreshQuery = refreshGuildCache.value ? { refresh: "true" } : undefined;
return Promise.all([
requestFetch<ValuesType<NonNullable<TransformedLoginData["transformedGuilds"]>>>(
`/api/guilds/${guildId.value}`,
const [guildResult, settingsResult] = await Promise.all([
$api<ValuesType<NonNullable<TransformedLoginData["transformedGuilds"]>>>(
`/guilds/${guildId.value}`,
{ query: refreshQuery },
),
requestFetch<string>(`/api/guilds/${guildId.value}/settings`, {
$api<string>(`/guilds/${guildId.value}/settings`, {
query: refreshQuery,
}),
]);
return [guildResult.data, settingsResult.data] as const;
},
);

Expand Down Expand Up @@ -472,44 +473,45 @@ function isValidGuildId(id: string | undefined | null): boolean {
}

async function submitChanges() {
const { data } = await useFetch(`/api/guilds/${guildId.value}/settings`, {
const { data: response } = await $api<string | GuildData>(`/guilds/${guildId.value}/settings`, {
body: {
guild_id: guildId.value,
data: objectToTuples(guildSettingsChanges.value as Partial<GuildData>),
},
transform: (response: string) => {
try {
return JSON.parse(response);
} catch (error) {
logger.error(
`Failed to parse response from settings update for guild Id: ${guildId.value}`,
parseError(error),
);
throw createError({
message: "Failed to update guild settings",
why: "Something went wrong while saving your settings.",
status: 500,
fix: "Please try again later. If the issue persists, contact support.",
cause: error as Error,
});
}
},
method: "PATCH",
});

if (!data.value) {
let parsed: GuildData;
try {
parsed = typeof response === "string" ? (JSON.parse(response) as GuildData) : response;
} catch (error) {
logger.error(
`Failed to parse response from settings update for guild Id: ${guildId.value}`,
parseError(error),
);
throw createError({
message: "Failed to update guild settings",
why: "Something went wrong while saving your settings.",
status: 500,
fix: "Please try again later. If the issue persists, contact support.",
cause: error as Error,
});
}

if (!parsed || objectValues(parsed).length === 0) {
return;
}

if (!isNullOrUndefined(data.value) && objectValues(data.value).length !== 0) {
if (objectValues(parsed).length !== 0) {
if (!document.startViewTransition || effectiveReduceMotion.value) {
setGuildSettings(data.value!);
setGuildSettings(parsed);
setGuildSettingsChanges(undefined);
} else {
if (document.activeViewTransition) {
document.activeViewTransition.skipTransition();
}
document.startViewTransition(async () => {
setGuildSettings(data.value!);
setGuildSettings(parsed);
setGuildSettingsChanges(undefined);
await nextTick();
});
Expand Down
45 changes: 43 additions & 2 deletions app/plugins/api.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,63 @@
import type { CachedFetchFunction } from "#shared/utils/fetch-cache-config";

/**
* Provides `$api` for the WolfStar bot API (`NUXT_PUBLIC_API_BASE_URL`),
* matching the legacy dashboard `apiFetch` behavior: call the bot origin
* directly with `credentials: "include"`.
*
* On the server, when a Discord session exists, a sapphire `SAPPHIRE_AUTH`
* cookie is attached so SSR can authorize guild routes without relying on a
* browser cookie set on the API domain. The cookie is encrypted with
* `NUXT_OAUTH_DISCORD_CLIENT_SECRET` (same secret sapphire-plugin-api uses).
*
* Crypto helpers live in `server/utils/botApi` and are loaded only inside the
* `import.meta.server` branch so `node:crypto` never enters the client bundle.
* Private `discord.clientSecret` is also read only on the server.
*/
export default defineNuxtPlugin(() => {
const cachedFetch = useCachedFetch();
const runtimeConfig = useRuntimeConfig();
const apiBaseUrl = runtimeConfig.public.apiBaseUrl;

return {
provide: {
api: <T>(
api: async <T>(
url: Parameters<CachedFetchFunction>[0],
options?: Parameters<CachedFetchFunction>[1],
ttl?: Parameters<CachedFetchFunction>[2],
) => {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options?.headers as Record<string, string> | undefined),
};

if (import.meta.server) {
const event = useRequestEvent();
const authorization = event?.context.$authorization;
if (authorization) {
const [{ getOptionalBotApiAuthHeaders }, user] = await Promise.all([
import("~~/server/utils/botApi"),
authorization.resolveServerUser(),
]);
const tokens = user ? await authorization.resolveServerTokens() : null;
Object.assign(
headers,
getOptionalBotApiAuthHeaders({
accessToken: tokens?.access_token,
secret: runtimeConfig.discord?.clientSecret ?? "",
userId: user?.id,
}),
);
}
}

return cachedFetch<T>(
url,
{
...options,
baseURL: apiBaseUrl,
credentials: "include",
headers: { "Content-Type": "application/json", ...options?.headers },
headers,
},
ttl,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
);
Expand Down
21 changes: 21 additions & 0 deletions app/plugins/payload-cache.client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* When `_payload.json` returns an ISR/empty fallback, async data with defaults
* may skip refetch during hydration. Refresh after suspense resolves so pages
* re-fetch once the client is interactive.
*/
export default defineNuxtPlugin({
name: "payload-cache-refresh",
setup(nuxtApp) {
nuxtApp.payload.data ||= {};

if (
nuxtApp.isHydrating &&
nuxtApp.payload.serverRendered &&
!Object.keys(nuxtApp.payload.data).length
) {
nuxtApp.hooks.hookOnce("app:suspense:resolve", () => {
refreshNuxtData();
});
}
},
});
56 changes: 56 additions & 0 deletions app/plugins/payload-cache.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// oxlint-disable no-console -- dev-only payload-cache diagnostics
import { stringify } from "devalue";

/**
* Serializes the Nuxt payload after SSR and stashes it on the request event.
*
* The Nitro `payload-cache` plugin picks this up in `render:response` so a
* later `/_payload.json` request for the same route can be served from cache
* without a second full Vue SSR render (same approach as npmx.dev).
*/
export default defineNuxtPlugin({
name: "payload-cache",
setup(nuxtApp) {
if (import.meta.client) return;

nuxtApp.hooks.hook("app:rendered", () => {
const ssrContext = nuxtApp.ssrContext;
if (!ssrContext) return;

if (ssrContext.noSSR || ssrContext.error || ssrContext.payload?.error) return;

const payloadData = ssrContext.payload?.data;
if (!payloadData || Object.keys(payloadData).length === 0) return;

try {
const payload = {
data: ssrContext.payload.data,
prerenderedAt: ssrContext.payload.prerenderedAt,
};
const reducers =
(
ssrContext as typeof ssrContext & {
"~payloadReducers"?: Record<string, (value: unknown) => unknown>;
}
)["~payloadReducers"] ?? {};
const body = stringify(payload, reducers);

const event = ssrContext.event;
if (event) {
event.context._cachedPayloadResponse = {
body,
headers: {
"content-type": "application/json;charset=utf-8",
"x-powered-by": "Nuxt",
},
statusCode: 200,
};
}
} catch (error) {
if (import.meta.dev) {
console.warn("[payload-cache] Failed to serialize payload:", error);
}
}
});
},
});
Loading