refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,128 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {DISABLED_OPERATIONS, GUILD_FEATURES, SELF_HOSTED_GUILD_FEATURES} from '@fluxer/admin/src/AdminPackageConstants';
import {FormFieldGroup} from '@fluxer/admin/src/components/ui/Form/FormFieldGroup';
import {Input} from '@fluxer/admin/src/components/ui/Input';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Text} from '@fluxer/admin/src/components/ui/Typography';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {Button} from '@fluxer/ui/src/components/Button';
import {NativeCheckboxItem} from '@fluxer/ui/src/components/CheckboxForm';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import type {FC} from 'hono/jsx';
interface RenderFeaturesFormProps {
config: Config;
currentFeatures: Array<string>;
guildId: string;
csrfToken: string;
selfHosted: boolean;
}
export function RenderFeaturesForm({config, currentFeatures, guildId, csrfToken, selfHosted}: RenderFeaturesFormProps) {
const knownFeatureValues = selfHosted ? SELF_HOSTED_GUILD_FEATURES : GUILD_FEATURES;
const customFeatures = currentFeatures.filter(
(f) => !knownFeatureValues.includes(f as (typeof GUILD_FEATURES)[number]),
);
return (
<form
method="post"
action={`${config.basePath}/guilds/${guildId}?action=update_features&tab=features`}
id="features-form"
>
<CsrfInput token={csrfToken} />
<VStack gap={3}>
{knownFeatureValues.map((feature) => {
const isChecked = currentFeatures.includes(feature);
return (
<NativeCheckboxItem
name="features[]"
value={feature}
label={feature}
checked={isChecked}
saveButtonId="features-save-button"
/>
);
})}
</VStack>
<VStack gap={0} class="mt-6 border-neutral-200 border-t pt-6">
<FormFieldGroup label="Custom Features">
<Text color="muted" size="xs" class="mb-2">
Enter custom feature strings separated by commas (e.g., CUSTOM_FEATURE_1, CUSTOM_FEATURE_2)
</Text>
<Input
type="text"
name="custom_features"
placeholder="CUSTOM_FEATURE_1, CUSTOM_FEATURE_2"
value={customFeatures.join(', ')}
/>
</FormFieldGroup>
</VStack>
<div class="mt-6 border-neutral-200 border-t pt-6" id="features-save-button">
<Button type="submit" variant="primary">
Save Changes
</Button>
</div>
</form>
);
}
interface RenderDisabledOperationsFormProps {
config: Config;
currentDisabledOperations: number;
guildId: string;
csrfToken: string;
}
export const RenderDisabledOperationsForm: FC<RenderDisabledOperationsFormProps> = ({
config,
currentDisabledOperations,
guildId,
csrfToken,
}) => (
<form
method="post"
action={`${config.basePath}/guilds/${guildId}?action=update_disabled_operations&tab=settings`}
id="disabled-ops-form"
>
<CsrfInput token={csrfToken} />
<VStack gap={3}>
{DISABLED_OPERATIONS.map((operation) => (
<NativeCheckboxItem
name="disabled_operations[]"
value={operation.value.toString()}
label={operation.name}
checked={(currentDisabledOperations & operation.value) === operation.value}
saveButtonId="disabled-ops-save-button"
/>
))}
</VStack>
<div class="mt-6 hidden border-neutral-200 border-t pt-6" id="disabled-ops-save-button">
<Button type="submit" variant="primary">
Save Changes
</Button>
</div>
</form>
);

View File

@@ -0,0 +1,155 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {hasPermission} from '@fluxer/admin/src/AccessControlList';
import {getErrorMessage} from '@fluxer/admin/src/api/Errors';
import {listGuildEmojis} from '@fluxer/admin/src/api/GuildAssets';
import {ErrorCard} from '@fluxer/admin/src/components/ErrorDisplay';
import {Badge} from '@fluxer/admin/src/components/ui/Badge';
import {HStack} from '@fluxer/admin/src/components/ui/Layout/HStack';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import type {GuildEmojiAsset} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import type {FC} from 'hono/jsx';
interface EmojisTabProps {
config: Config;
session: Session;
guildId: string;
adminAcls: Array<string>;
csrfToken: string;
}
const RenderPermissionNotice: FC = () => (
<Card padding="md">
<VStack gap={2}>
<Heading level={3} size="base">
Permission required
</Heading>
<Text size="sm" color="muted">
You need the {AdminACLs.ASSET_PURGE} ACL to manage guild emojis.
</Text>
</VStack>
</Card>
);
const RenderEmojiCard: FC<{config: Config; guildId: string; emoji: GuildEmojiAsset; csrfToken: string}> = ({
config,
guildId,
emoji,
csrfToken,
}) => {
return (
<Card padding="none" class="overflow-hidden shadow-sm">
<VStack gap={0}>
<VStack gap={0} class="h-32 items-center justify-center bg-neutral-100 p-6">
<img src={emoji.media_url} alt={emoji.name} class="max-h-full max-w-full object-contain" loading="lazy" />
</VStack>
<VStack gap={3} class="flex-1 px-4 py-3">
<HStack gap={2} justify="between">
<Text size="sm" weight="semibold">
{emoji.name}
</Text>
{emoji.animated && (
<Badge size="sm" variant="neutral">
Animated
</Badge>
)}
</HStack>
<Text size="xs" color="muted" class="break-words">
ID: {emoji.id}
</Text>
<a href={`${config.basePath}/users/${emoji.creator_id}`} class="text-blue-600 text-xs hover:underline">
Uploader: {emoji.creator_id}
</a>
<form action={`${config.basePath}/guilds/${guildId}?tab=emojis&action=delete_emoji`} method="post">
<CsrfInput token={csrfToken} />
<input type="hidden" name="emoji_id" value={emoji.id} />
<Button type="submit" variant="danger" size="small" fullWidth>
Delete Emoji
</Button>
</form>
</VStack>
</VStack>
</Card>
);
};
const RenderEmojis: FC<{config: Config; guildId: string; emojis: Array<GuildEmojiAsset>; csrfToken: string}> = ({
config,
guildId,
emojis,
csrfToken,
}) => {
return (
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
Emojis ({emojis.length})
</Heading>
{emojis.length === 0 ? (
<Text size="sm" color="muted">
No custom emojis found for this guild.
</Text>
) : (
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{emojis.map((emoji) => (
<RenderEmojiCard config={config} guildId={guildId} emoji={emoji} csrfToken={csrfToken} />
))}
</div>
)}
</VStack>
</Card>
);
};
export async function EmojisTab({config, session, guildId, adminAcls, csrfToken}: EmojisTabProps) {
const hasAssetPurge = hasPermission(adminAcls, AdminACLs.ASSET_PURGE);
if (!hasAssetPurge) {
return <RenderPermissionNotice />;
}
const result = await listGuildEmojis(config, session, guildId);
if (!result.ok) {
return (
<VStack gap={4}>
<ErrorCard title="Error" message={getErrorMessage(result.error)} />
<a
href={`${config.basePath}/guilds/${guildId}?tab=emojis`}
class="inline-block rounded bg-neutral-900 px-4 py-2 font-medium text-sm text-white transition-colors hover:bg-neutral-800"
>
Back to Guild
</a>
</VStack>
);
}
return <RenderEmojis config={config} guildId={guildId} emojis={result.data.emojis} csrfToken={csrfToken} />;
}

View File

@@ -0,0 +1,87 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {hasPermission} from '@fluxer/admin/src/AccessControlList';
import type {GuildLookupResult} from '@fluxer/admin/src/api/Guilds';
import {Badge} from '@fluxer/admin/src/components/ui/Badge';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Stack} from '@fluxer/admin/src/components/ui/Stack';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import {RenderFeaturesForm} from '@fluxer/admin/src/pages/guild_detail/Forms';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {Card} from '@fluxer/ui/src/components/Card';
interface FeaturesTabProps {
config: Config;
guild: GuildLookupResult;
guildId: string;
adminAcls: Array<string>;
csrfToken: string;
}
export function FeaturesTab({config, guild, guildId, adminAcls, csrfToken}: FeaturesTabProps) {
return (
<Stack gap="lg">
{hasPermission(adminAcls, AdminACLs.GUILD_UPDATE_FEATURES) ? (
<Card padding="md">
<Stack gap="md">
<VStack gap={1}>
<Heading level={2} size="base">
Guild Features
</Heading>
<Text size="sm" color="muted">
Select which features are enabled for this guild.
</Text>
</VStack>
<RenderFeaturesForm
config={config}
currentFeatures={guild.features}
guildId={guildId}
csrfToken={csrfToken}
selfHosted={config.selfHosted}
/>
</Stack>
</Card>
) : (
<Card padding="md">
<Stack gap="md">
<Heading level={2} size="base">
Guild Features
</Heading>
{guild.features.length === 0 ? (
<Text size="sm" color="muted">
No features enabled
</Text>
) : (
<div class="flex flex-wrap gap-2">
{guild.features.map((feature) => (
<Badge variant="info">{feature}</Badge>
))}
</div>
)}
</Stack>
</Card>
)}
</Stack>
);
}

View File

@@ -0,0 +1,327 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {hasPermission} from '@fluxer/admin/src/AccessControlList';
import {getErrorMessage, getErrorTitle} from '@fluxer/admin/src/api/Errors';
import {listGuildMembers} from '@fluxer/admin/src/api/Guilds';
import {ErrorCard} from '@fluxer/admin/src/components/ErrorDisplay';
import {Badge} from '@fluxer/admin/src/components/ui/Badge';
import {HStack} from '@fluxer/admin/src/components/ui/Layout/HStack';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import {buildPaginationUrl} from '@fluxer/admin/src/hooks/usePaginationUrl';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import type {ListGuildMembersResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import {extractTimestampFromSnowflakeAsDate} from '@fluxer/snowflake/src/SnowflakeUtils';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import {formatDiscriminator, getUserAvatarUrl} from '@fluxer/ui/src/utils/FormatUser';
import type {FC} from 'hono/jsx';
import type {z} from 'zod';
interface MembersTabProps {
config: Config;
session: Session;
guildId: string;
adminAcls: Array<string>;
page: number;
assetVersion: string;
csrfToken: string;
}
function formatDate(isoDate: string): string {
const parts = isoDate.split('T');
return parts[0] ?? isoDate;
}
function extractTimestamp(snowflakeId: string): string | null {
try {
const date = extractTimestampFromSnowflakeAsDate(snowflakeId);
return date.toISOString().split('T')[0] ?? null;
} catch {
return null;
}
}
const RenderPaginationInfo: FC<{offset: number; limit: number; total: number}> = ({offset, limit, total}) => {
const start = offset + 1;
const end = offset + limit > total ? total : offset + limit;
return (
<Text size="sm" color="muted">
Showing {start}-{end} of {total}
</Text>
);
};
const RenderPagination: FC<{
config: Config;
guildId: string;
currentPage: number;
total: number;
limit: number;
}> = ({config, guildId, currentPage, total, limit}) => {
const totalPages = Math.ceil(total / limit);
const hasPrevious = currentPage > 0;
const hasNext = currentPage < totalPages - 1;
if (totalPages <= 1) {
return null;
}
return (
<HStack gap={4} justify="between" align="center" class="mt-4 border-t pt-4">
<Button
variant={hasPrevious ? 'brand' : 'secondary'}
size="small"
disabled={!hasPrevious}
href={
hasPrevious
? `${config.basePath}/guilds/${guildId}${buildPaginationUrl(currentPage - 1, {tab: 'members'})}`
: undefined
}
>
&larr; Previous
</Button>
<Text size="sm" color="muted">
Page {currentPage + 1} of {totalPages}
</Text>
<Button
variant={hasNext ? 'brand' : 'secondary'}
size="small"
disabled={!hasNext}
href={
hasNext
? `${config.basePath}/guilds/${guildId}${buildPaginationUrl(currentPage + 1, {tab: 'members'})}`
: undefined
}
>
Next &rarr;
</Button>
</HStack>
);
};
const RenderMemberActions: FC<{
config: Config;
guildId: string;
userId: string;
canBanMember: boolean;
canKickMember: boolean;
csrfToken: string;
}> = ({config, guildId, userId, canBanMember, canKickMember, csrfToken}) => {
if (!canBanMember && !canKickMember) {
return null;
}
return (
<HStack gap={2} justify="end" class="flex-wrap">
{canBanMember && (
<form
method="post"
action={`${config.basePath}/guilds/${guildId}?tab=members&action=ban_member`}
onsubmit="return confirm('Are you sure you want to ban this member?')"
>
<CsrfInput token={csrfToken} />
<input type="hidden" name="user_id" value={userId} />
<Button type="submit" variant="danger" size="small">
Ban Member
</Button>
</form>
)}
{canKickMember && (
<form
method="post"
action={`${config.basePath}/guilds/${guildId}?tab=members&action=kick_member`}
onsubmit="return confirm('Are you sure you want to kick this member from the guild?')"
>
<CsrfInput token={csrfToken} />
<input type="hidden" name="user_id" value={userId} />
<Button type="submit" variant="secondary" size="small">
Kick Member
</Button>
</form>
)}
</HStack>
);
};
const RenderMember: FC<{
config: Config;
guildId: string;
adminAcls: Array<string>;
member: GuildMemberResponse;
assetVersion: string;
csrfToken: string;
}> = ({config, guildId, adminAcls, member, assetVersion, csrfToken}) => {
const canBan = hasPermission(adminAcls, AdminACLs.GUILD_BAN_MEMBER);
const canKick = hasPermission(adminAcls, AdminACLs.GUILD_KICK_MEMBER);
const createdAt = extractTimestamp(member.user.id);
const discriminatorDisplay =
typeof member.user.discriminator === 'number'
? formatDiscriminator(member.user.discriminator)
: member.user.discriminator;
return (
<Card padding="md" class="transition-colors hover:border-neutral-300">
<HStack gap={4} align="center">
<img
src={getUserAvatarUrl(
config.mediaEndpoint,
config.staticCdnEndpoint,
member.user.id,
member.user.avatar,
true,
assetVersion,
)}
alt={member.user.username}
class="h-16 w-16 flex-shrink-0 rounded-full"
/>
<VStack gap={1} class="min-w-0 flex-1">
<HStack gap={2} align="center" class="mb-1">
<Heading level={2} size="base">
{member.user.username}#{discriminatorDisplay}
</Heading>
{member.user.bot && <Badge variant="info">Bot</Badge>}
{member.nick && (
<Text size="sm" color="muted" class="ml-2">
({member.nick})
</Text>
)}
</HStack>
<VStack gap={0.5}>
<Text size="sm" color="muted">
ID: {member.user.id}
</Text>
{createdAt && (
<Text size="sm" color="muted">
Created: {createdAt}
</Text>
)}
<Text size="sm" color="muted">
Joined: {formatDate(member.joined_at)}
</Text>
{member.roles.length > 0 && (
<Text size="sm" color="muted">
{member.roles.length} roles
</Text>
)}
</VStack>
</VStack>
<VStack gap={2} align="end">
<Button variant="primary" size="small" href={`${config.basePath}/users/${member.user.id}`}>
View Details
</Button>
<RenderMemberActions
config={config}
guildId={guildId}
userId={member.user.id}
canBanMember={canBan}
canKickMember={canKick}
csrfToken={csrfToken}
/>
</VStack>
</HStack>
</Card>
);
};
const RenderMembersList: FC<{
config: Config;
guildId: string;
adminAcls: Array<string>;
response: z.infer<typeof ListGuildMembersResponse>;
page: number;
limit: number;
assetVersion: string;
csrfToken: string;
}> = ({config, guildId, adminAcls, response, page, limit, assetVersion, csrfToken}) => {
return (
<VStack gap={4}>
<HStack gap={4} justify="between" align="center">
<Heading level={3} size="base">
Guild Members ({response.total})
</Heading>
<RenderPaginationInfo offset={response.offset} limit={response.limit} total={response.total} />
</HStack>
{response.members.length === 0 ? (
<Text size="sm" color="muted">
No members found.
</Text>
) : (
<VStack gap={2}>
{response.members.map((member) => (
<RenderMember
config={config}
guildId={guildId}
adminAcls={adminAcls}
member={member}
assetVersion={assetVersion}
csrfToken={csrfToken}
/>
))}
</VStack>
)}
<RenderPagination config={config} guildId={guildId} currentPage={page} total={response.total} limit={limit} />
</VStack>
);
};
export async function MembersTab({
config,
session,
guildId,
adminAcls,
page,
assetVersion,
csrfToken,
}: MembersTabProps) {
const limit = 50;
const offset = page * limit;
if (!hasPermission(adminAcls, AdminACLs.GUILD_LIST_MEMBERS)) {
return <ErrorCard title="Permission Denied" message="You don't have permission to view guild members." />;
}
const result = await listGuildMembers(config, session, guildId, limit, offset);
if (!result.ok) {
return <ErrorCard title={getErrorTitle(result.error)} message={getErrorMessage(result.error)} />;
}
return (
<RenderMembersList
config={config}
guildId={guildId}
adminAcls={adminAcls}
response={result.data}
page={page}
limit={limit}
assetVersion={assetVersion}
csrfToken={csrfToken}
/>
);
}

View File

@@ -0,0 +1,208 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {hasPermission} from '@fluxer/admin/src/AccessControlList';
import type {GuildLookupResult} from '@fluxer/admin/src/api/Guilds';
import {Input} from '@fluxer/admin/src/components/ui/Input';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Stack} from '@fluxer/admin/src/components/ui/Stack';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
interface ModerationTabProps {
config: Config;
guild: GuildLookupResult;
guildId: string;
adminAcls: Array<string>;
csrfToken: string;
}
export function ModerationTab({config, guild: _guild, guildId, adminAcls, csrfToken}: ModerationTabProps) {
const canUpdateName = hasPermission(adminAcls, AdminACLs.GUILD_UPDATE_NAME);
const canUpdateVanity = hasPermission(adminAcls, AdminACLs.GUILD_UPDATE_VANITY);
const canTransferOwnership = hasPermission(adminAcls, AdminACLs.GUILD_TRANSFER_OWNERSHIP);
const canForceAddUser = hasPermission(adminAcls, AdminACLs.GUILD_FORCE_ADD_MEMBER);
const canReload = hasPermission(adminAcls, AdminACLs.GUILD_RELOAD);
const canShutdown = hasPermission(adminAcls, AdminACLs.GUILD_SHUTDOWN);
const canDelete = hasPermission(adminAcls, AdminACLs.GUILD_DELETE);
return (
<Stack gap="lg">
{canUpdateName && (
<Card padding="md">
<Stack gap="md">
<Heading level={2} size="base">
Update Guild Name
</Heading>
<form
method="post"
action={`${config.basePath}/guilds/${guildId}?action=update_name&tab=moderation`}
onsubmit="return confirm('Are you sure you want to change this guild\\'s name?')"
>
<CsrfInput token={csrfToken} />
<Stack gap="sm">
<Input type="text" name="name" placeholder="New guild name" required fullWidth />
<Button type="submit" variant="primary">
Update Name
</Button>
</Stack>
</form>
</Stack>
</Card>
)}
{canUpdateVanity && (
<Card padding="md">
<Stack gap="md">
<Heading level={2} size="base">
Update Vanity URL
</Heading>
<form
method="post"
action={`${config.basePath}/guilds/${guildId}?action=update_vanity&tab=moderation`}
onsubmit="return confirm('Are you sure you want to change this guild\\'s vanity URL?')"
>
<CsrfInput token={csrfToken} />
<Stack gap="sm">
<Input type="text" name="vanity_url_code" placeholder="vanity-code (leave empty to remove)" fullWidth />
<Button type="submit" variant="primary">
Update Vanity URL
</Button>
</Stack>
</form>
</Stack>
</Card>
)}
{canTransferOwnership && (
<Card padding="md">
<Stack gap="md">
<Heading level={2} size="base">
Transfer Ownership
</Heading>
<form
method="post"
action={`${config.basePath}/guilds/${guildId}?action=transfer_ownership&tab=moderation`}
onsubmit="return confirm('Are you sure you want to transfer ownership of this guild? This action cannot be easily undone.')"
>
<CsrfInput token={csrfToken} />
<Stack gap="sm">
<Input type="text" name="new_owner_id" placeholder="New owner user ID" required fullWidth />
<Button type="submit" variant="danger">
Transfer Ownership
</Button>
</Stack>
</form>
</Stack>
</Card>
)}
{canForceAddUser && (
<Card padding="md">
<Stack gap="md">
<Heading level={2} size="base">
Force Add User to Guild
</Heading>
<form
method="post"
action={`${config.basePath}/guilds/${guildId}?action=force_add_user&tab=moderation`}
onsubmit="return confirm('Are you sure you want to force add this user to the guild?')"
>
<CsrfInput token={csrfToken} />
<Stack gap="sm">
<Input type="text" name="user_id" placeholder="User ID to add" required fullWidth />
<Button type="submit" variant="primary">
Add User
</Button>
</Stack>
</form>
</Stack>
</Card>
)}
{(canReload || canShutdown) && (
<Card padding="md">
<Stack gap="md">
<Heading level={2} size="base">
Guild Process Controls
</Heading>
<div class="flex flex-wrap gap-3">
{canReload && (
<form
method="post"
action={`${config.basePath}/guilds/${guildId}?action=reload&tab=moderation`}
onsubmit="return confirm('Are you sure you want to reload this guild process?')"
>
<CsrfInput token={csrfToken} />
<Button type="submit" variant="success">
Reload Guild
</Button>
</form>
)}
{canShutdown && (
<form
method="post"
action={`${config.basePath}/guilds/${guildId}?action=shutdown&tab=moderation`}
onsubmit="return confirm('Are you sure you want to shutdown this guild process?')"
>
<CsrfInput token={csrfToken} />
<Button type="submit" variant="danger">
Shutdown Guild
</Button>
</form>
)}
</div>
</Stack>
</Card>
)}
{canDelete && (
<Card padding="md">
<Stack gap="md">
<VStack gap={1}>
<Heading level={2} size="base">
Delete Guild
</Heading>
<Text size="sm" color="muted">
Deleting a guild permanently removes it and all associated data. This action cannot be undone.
</Text>
</VStack>
<form
method="post"
action={`${config.basePath}/guilds/${guildId}?action=delete_guild&tab=moderation`}
onsubmit="return confirm('Are you sure you want to permanently delete this guild? This action cannot be undone.')"
>
<CsrfInput token={csrfToken} />
<Button type="submit" variant="danger">
Delete Guild
</Button>
</form>
</Stack>
</Card>
)}
</Stack>
);
}

View File

@@ -0,0 +1,310 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import type {GuildChannel, GuildLookupResult, GuildRole} from '@fluxer/admin/src/api/Guilds';
import {Badge} from '@fluxer/admin/src/components/ui/Badge';
import {EmptyState} from '@fluxer/admin/src/components/ui/EmptyState';
import {HStack} from '@fluxer/admin/src/components/ui/Layout/HStack';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {FLUXER_EPOCH} from '@fluxer/constants/src/Core';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import {InfoGrid, InfoItem} from '@fluxer/ui/src/components/Layout';
import {
getGuildBannerUrl,
getGuildEmbedSplashUrl,
getGuildIconUrl,
getGuildSplashUrl,
} from '@fluxer/ui/src/utils/FormatUser';
import type {FC} from 'hono/jsx';
function getCurrentSnowflake(): string {
const now = Date.now();
const timestampOffset = now - FLUXER_EPOCH;
const snowflake = BigInt(timestampOffset) * 4_194_304n;
return snowflake.toString();
}
function channelTypeToString(type: number): string {
switch (type) {
case 0:
return 'Text';
case 2:
return 'Voice';
case 4:
return 'Category';
default:
return `Unknown (${type})`;
}
}
function intToHex(i: number): string {
if (i === 0) return '000000';
const r = Math.floor(i / 65536) % 256;
const g = Math.floor(i / 256) % 256;
const b = i % 256;
return byteToHex(r) + byteToHex(g) + byteToHex(b);
}
function byteToHex(byte: number): string {
const hexDigits = '0123456789ABCDEF';
const high = Math.floor(byte / 16);
const low = byte % 16;
return (hexDigits[high] ?? '0') + (hexDigits[low] ?? '0');
}
interface OverviewTabProps {
config: Config;
guild: GuildLookupResult;
csrfToken: string;
}
const RenderChannel: FC<{config: Config; channel: GuildChannel}> = ({config, channel}) => {
const currentSnowflake = getCurrentSnowflake();
return (
<a
href={`${config.basePath}/messages?channel_id=${channel.id}&message_id=${currentSnowflake}&context_limit=50`}
class="flex items-center gap-3 rounded border border-neutral-200 bg-neutral-50 p-3 transition-colors hover:bg-neutral-100"
>
<VStack gap={0} class="flex-1">
<Text size="sm" weight="semibold">
{channel.name}
</Text>
<Text size="sm" color="muted">
{channel.id}
</Text>
</VStack>
<Text size="sm" color="muted">
{channelTypeToString(channel.type)}
</Text>
</a>
);
};
const RenderRole: FC<{role: GuildRole}> = ({role}) => {
const colorHex = intToHex(role.color);
return (
<HStack gap={3} align="center" class="rounded border border-neutral-200 bg-neutral-50 p-3">
<div class="h-4 w-4 rounded" style={`background-color: #${colorHex}`} />
<VStack gap={0} class="flex-1">
<Text size="sm" weight="semibold">
{role.name}
</Text>
<Text size="sm" color="muted">
{role.id}
</Text>
</VStack>
<HStack gap={2}>
{role.hoist && <Badge variant="info">Hoisted</Badge>}
{role.mentionable && <Badge variant="success">Mentionable</Badge>}
</HStack>
</HStack>
);
};
const RenderSearchIndexButton: FC<{
config: Config;
guildId: string;
title: string;
indexType: string;
csrfToken: string;
}> = ({config, guildId, title, indexType, csrfToken}) => {
return (
<form method="post" action={`${config.basePath}/guilds/${guildId}?action=refresh_search_index`} class="w-full">
<CsrfInput token={csrfToken} />
<input type="hidden" name="index_type" value={indexType} />
<input type="hidden" name="guild_id" value={guildId} />
<Button type="submit" variant="secondary" fullWidth>
Refresh {title}
</Button>
</form>
);
};
const AssetPreview: FC<{
label: string;
url: string | null;
hash: string | null;
variant: 'square' | 'wide';
}> = ({label, url, hash, variant}) => {
const imageClass =
variant === 'square'
? 'h-24 w-24 rounded bg-neutral-100 object-cover'
: 'h-36 w-full rounded bg-neutral-100 object-cover';
return (
<VStack gap={2} class="rounded-lg border border-neutral-200 bg-white p-3">
<Text size="sm" weight="semibold">
{label}
</Text>
{url ? (
<a href={url} target="_blank" rel="noreferrer noopener" class="block">
<img src={url} alt={`${label} preview`} class={imageClass} loading="lazy" />
</a>
) : (
<div
class={`flex items-center justify-center rounded bg-neutral-100 text-neutral-500 text-sm ${
variant === 'square' ? 'h-24 w-24' : 'h-36 w-full'
}`}
>
Not set
</div>
)}
<Text size="xs" color="muted" class="break-all font-mono">
Hash: {hash ?? 'null'}
</Text>
</VStack>
);
};
export function OverviewTab({config, guild, csrfToken}: OverviewTabProps) {
const sortedChannels = [...guild.channels].sort((a, b) => a.position - b.position);
const sortedRoles = [...guild.roles].sort((a, b) => b.position - a.position);
const iconUrl = getGuildIconUrl(config.mediaEndpoint, guild.id, guild.icon, true);
const bannerUrl = getGuildBannerUrl(config.mediaEndpoint, guild.id, guild.banner, true);
const splashUrl = getGuildSplashUrl(config.mediaEndpoint, guild.id, guild.splash);
const embedSplashUrl = getGuildEmbedSplashUrl(config.mediaEndpoint, guild.id, guild.embed_splash);
return (
<VStack gap={6}>
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
Assets
</Heading>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<AssetPreview label="Icon" url={iconUrl} hash={guild.icon} variant="square" />
<AssetPreview label="Banner" url={bannerUrl} hash={guild.banner} variant="wide" />
<AssetPreview label="Splash" url={splashUrl} hash={guild.splash} variant="wide" />
<AssetPreview label="Embed Splash" url={embedSplashUrl} hash={guild.embed_splash} variant="wide" />
</div>
</VStack>
</Card>
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
Guild Information
</Heading>
<InfoGrid>
<InfoItem label="Guild ID" value={guild.id} />
<InfoItem label="Name" value={guild.name} />
<InfoItem label="Member Count" value={String(guild.member_count)} />
<InfoItem label="Vanity URL" value={guild.vanity_url_code ?? 'None'} />
<VStack gap={1}>
<Text size="sm" weight="semibold" color="muted">
Owner ID
</Text>
<a
href={`${config.basePath}/users/${guild.owner_id}`}
class="text-neutral-900 text-sm hover:text-blue-600 hover:underline"
>
{guild.owner_id}
</a>
</VStack>
</InfoGrid>
</VStack>
</Card>
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
Features
</Heading>
{guild.features.length === 0 ? (
<EmptyState variant="empty">No features enabled</EmptyState>
) : (
<HStack gap={2} class="flex-wrap">
{guild.features.map((feature) => (
<Badge variant="info">{feature}</Badge>
))}
</HStack>
)}
</VStack>
</Card>
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
Channels ({guild.channels.length})
</Heading>
{guild.channels.length === 0 ? (
<EmptyState variant="empty">No channels</EmptyState>
) : (
<VStack gap={2}>
{sortedChannels.map((channel) => (
<RenderChannel config={config} channel={channel} />
))}
</VStack>
)}
</VStack>
</Card>
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
Roles ({guild.roles.length})
</Heading>
{guild.roles.length === 0 ? (
<EmptyState variant="empty">No roles</EmptyState>
) : (
<VStack gap={2}>
{sortedRoles.map((role) => (
<RenderRole role={role} />
))}
</VStack>
)}
</VStack>
</Card>
<Card padding="md">
<VStack gap={4}>
<VStack gap={1}>
<Heading level={3} size="base">
Search Index Management
</Heading>
<Text size="sm" color="muted">
Refresh search indexes for this guild.
</Text>
</VStack>
<RenderSearchIndexButton
config={config}
guildId={guild.id}
title="Channel Messages"
indexType="channel_messages"
csrfToken={csrfToken}
/>
<RenderSearchIndexButton
config={config}
guildId={guild.id}
title="Guild Members"
indexType="guild_members"
csrfToken={csrfToken}
/>
</VStack>
</Card>
</VStack>
);
}

View File

@@ -0,0 +1,285 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {hasPermission} from '@fluxer/admin/src/AccessControlList';
import type {GuildLookupResult} from '@fluxer/admin/src/api/Guilds';
import {FormFieldGroup} from '@fluxer/admin/src/components/ui/Form/FormFieldGroup';
import {Grid} from '@fluxer/admin/src/components/ui/Grid';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Select} from '@fluxer/admin/src/components/ui/Select';
import {Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import {RenderDisabledOperationsForm} from '@fluxer/admin/src/pages/guild_detail/Forms';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import {Checkbox} from '@fluxer/ui/src/components/Form';
import {InfoGrid, InfoItem} from '@fluxer/ui/src/components/Layout';
interface SettingsTabProps {
config: Config;
guild: GuildLookupResult;
guildId: string;
adminAcls: Array<string>;
csrfToken: string;
}
function verificationLevelToString(level: number): string {
switch (level) {
case 0:
return 'None';
case 1:
return 'Low (verified email)';
case 2:
return 'Medium (registered for 5 minutes)';
case 3:
return 'High (member for 10 minutes)';
case 4:
return 'Very High (verified phone)';
default:
return `Unknown (${level})`;
}
}
function mfaLevelToString(level: number): string {
switch (level) {
case 0:
return 'None';
case 1:
return 'Elevated';
default:
return `Unknown (${level})`;
}
}
function nsfwLevelToString(level: number): string {
switch (level) {
case 0:
return 'Default';
case 1:
return 'Explicit';
case 2:
return 'Safe';
case 3:
return 'Age Restricted';
default:
return `Unknown (${level})`;
}
}
function contentFilterToString(level: number): string {
switch (level) {
case 0:
return 'Disabled';
case 1:
return 'Members without roles';
case 2:
return 'All members';
default:
return `Unknown (${level})`;
}
}
function notificationLevelToString(level: number): string {
switch (level) {
case 0:
return 'All messages';
case 1:
return 'Only mentions';
default:
return `Unknown (${level})`;
}
}
export function SettingsTab({config, guild, guildId, adminAcls, csrfToken}: SettingsTabProps) {
const canUpdateSettings = hasPermission(adminAcls, AdminACLs.GUILD_UPDATE_SETTINGS);
return (
<VStack gap={6}>
{canUpdateSettings ? (
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
Guild Settings
</Heading>
<form method="post" action={`${config.basePath}/guilds/${guildId}?action=update_settings&tab=settings`}>
<CsrfInput token={csrfToken} />
<Grid cols={2} gap="md">
<FormFieldGroup label="Verification Level" htmlFor="guild-verification-level">
<Select
id="guild-verification-level"
name="verification_level"
value={String(guild.verification_level)}
options={[
{value: '0', label: 'None'},
{value: '1', label: 'Low (verified email)'},
{value: '2', label: 'Medium (5+ minutes)'},
{value: '3', label: 'High (10+ minutes)'},
{value: '4', label: 'Very High (verified phone)'},
]}
size="sm"
fullWidth
/>
</FormFieldGroup>
<FormFieldGroup label="MFA Level" htmlFor="guild-mfa-level">
<Select
id="guild-mfa-level"
name="mfa_level"
value={String(guild.mfa_level)}
options={[
{value: '0', label: 'None'},
{value: '1', label: 'Elevated'},
]}
size="sm"
fullWidth
/>
</FormFieldGroup>
<FormFieldGroup label="NSFW Level" htmlFor="guild-nsfw-level">
<Select
id="guild-nsfw-level"
name="nsfw_level"
value={String(guild.nsfw_level)}
options={[
{value: '0', label: 'Default'},
{value: '1', label: 'Explicit'},
{value: '2', label: 'Safe'},
{value: '3', label: 'Age Restricted'},
]}
size="sm"
fullWidth
/>
</FormFieldGroup>
<FormFieldGroup label="Explicit Content Filter" htmlFor="guild-explicit-content-filter">
<Select
id="guild-explicit-content-filter"
name="explicit_content_filter"
value={String(guild.explicit_content_filter)}
options={[
{value: '0', label: 'Disabled'},
{value: '1', label: 'Members without roles'},
{value: '2', label: 'All members'},
]}
size="sm"
fullWidth
/>
</FormFieldGroup>
<FormFieldGroup label="Default Notifications" htmlFor="guild-default-notifications">
<Select
id="guild-default-notifications"
name="default_message_notifications"
value={String(guild.default_message_notifications)}
options={[
{value: '0', label: 'All messages'},
{value: '1', label: 'Only mentions'},
]}
size="sm"
fullWidth
/>
</FormFieldGroup>
</Grid>
<VStack gap={0} class="mt-6 border-neutral-200 border-t pt-6">
<Button type="submit" variant="primary">
Save Settings
</Button>
</VStack>
</form>
</VStack>
</Card>
) : (
<Card padding="md">
<VStack gap={4}>
<Text size="base" weight="semibold">
Guild Settings
</Text>
<InfoGrid>
<InfoItem label="Verification Level" value={verificationLevelToString(guild.verification_level)} />
<InfoItem label="MFA Level" value={mfaLevelToString(guild.mfa_level)} />
<InfoItem label="NSFW Level" value={nsfwLevelToString(guild.nsfw_level)} />
<InfoItem label="Explicit Content Filter" value={contentFilterToString(guild.explicit_content_filter)} />
<InfoItem
label="Default Notifications"
value={notificationLevelToString(guild.default_message_notifications)}
/>
<InfoItem label="AFK Timeout" value={`${guild.afk_timeout} seconds`} />
</InfoGrid>
</VStack>
</Card>
)}
{canUpdateSettings ? (
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
Disabled Operations
</Heading>
<RenderDisabledOperationsForm
config={config}
currentDisabledOperations={guild.disabled_operations}
guildId={guildId}
csrfToken={csrfToken}
/>
</VStack>
</Card>
) : (
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
Disabled Operations
</Heading>
<Text size="sm" color="muted">
Bitfield value: {guild.disabled_operations}
</Text>
</VStack>
</Card>
)}
{canUpdateSettings && (
<Card padding="md">
<VStack gap={4}>
<Heading level={3} size="base">
Clear Guild Fields
</Heading>
<form
method="post"
action={`${config.basePath}/guilds/${guildId}?action=clear_fields&tab=settings`}
onsubmit="return confirm('Are you sure you want to clear these fields?')"
>
<CsrfInput token={csrfToken} />
<VStack gap={3}>
<VStack gap={2}>
<Checkbox name="fields[]" value="icon" label="Icon" />
<Checkbox name="fields[]" value="banner" label="Banner" />
<Checkbox name="fields[]" value="splash" label="Splash" />
<Checkbox name="fields[]" value="embed_splash" label="Embed Splash" />
</VStack>
<Button type="submit" variant="danger">
Clear Selected Fields
</Button>
</VStack>
</form>
</VStack>
</Card>
)}
</VStack>
);
}

View File

@@ -0,0 +1,160 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {hasPermission} from '@fluxer/admin/src/AccessControlList';
import {getErrorMessage} from '@fluxer/admin/src/api/Errors';
import {listGuildStickers} from '@fluxer/admin/src/api/GuildAssets';
import {ErrorCard} from '@fluxer/admin/src/components/ErrorDisplay';
import {Badge} from '@fluxer/admin/src/components/ui/Badge';
import {HStack} from '@fluxer/admin/src/components/ui/Layout/HStack';
import {VStack} from '@fluxer/admin/src/components/ui/Layout/VStack';
import {Stack} from '@fluxer/admin/src/components/ui/Stack';
import {Caption, Heading, Text} from '@fluxer/admin/src/components/ui/Typography';
import type {Session} from '@fluxer/admin/src/types/App';
import type {AdminConfig as Config} from '@fluxer/admin/src/types/Config';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import type {GuildStickerAsset} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {Button} from '@fluxer/ui/src/components/Button';
import {Card} from '@fluxer/ui/src/components/Card';
import {CsrfInput} from '@fluxer/ui/src/components/CsrfInput';
import type {FC} from 'hono/jsx';
interface StickersTabProps {
config: Config;
session: Session;
guildId: string;
adminAcls: Array<string>;
csrfToken: string;
}
function stickerAnimatedLabel(animated: boolean): string {
return animated ? 'Animated' : 'Static';
}
const RenderPermissionNotice: FC = () => (
<Card padding="md">
<Stack gap="md">
<Heading level={2} size="base">
Permission required
</Heading>
<Text size="sm" color="muted">
You need the {AdminACLs.ASSET_PURGE} ACL to manage guild stickers.
</Text>
</Stack>
</Card>
);
const RenderStickerCard: FC<{config: Config; guildId: string; sticker: GuildStickerAsset; csrfToken: string}> = ({
config,
guildId,
sticker,
csrfToken,
}) => {
return (
<Card padding="none" class="overflow-hidden shadow-sm">
<VStack gap={0}>
<VStack gap={0} class="h-32 items-center justify-center bg-neutral-100 p-6">
<img src={sticker.media_url} alt={sticker.name} class="max-h-full max-w-full object-contain" loading="lazy" />
</VStack>
<VStack gap={1} class="flex-1 px-4 py-3">
<HStack gap={2} justify="between" align="center">
<Text size="sm" weight="semibold">
{sticker.name}
</Text>
<Badge size="sm" variant="neutral">
{stickerAnimatedLabel(sticker.animated)}
</Badge>
</HStack>
<Caption class="break-words">ID: {sticker.id}</Caption>
<a href={`${config.basePath}/users/${sticker.creator_id}`} class="text-blue-600 text-xs hover:underline">
Uploader: {sticker.creator_id}
</a>
<form
action={`${config.basePath}/guilds/${guildId}?tab=stickers&action=delete_sticker`}
method="post"
class="mt-4"
>
<CsrfInput token={csrfToken} />
<input type="hidden" name="sticker_id" value={sticker.id} />
<Button type="submit" variant="danger" size="small" fullWidth>
Delete Sticker
</Button>
</form>
</VStack>
</VStack>
</Card>
);
};
const RenderStickers: FC<{config: Config; guildId: string; stickers: Array<GuildStickerAsset>; csrfToken: string}> = ({
config,
guildId,
stickers,
csrfToken,
}) => {
return (
<Card padding="md">
<Stack gap="md">
<Heading level={2} size="base">
Stickers ({stickers.length})
</Heading>
{stickers.length === 0 ? (
<Text size="sm" color="muted">
No stickers found for this guild.
</Text>
) : (
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{stickers.map((sticker) => (
<RenderStickerCard config={config} guildId={guildId} sticker={sticker} csrfToken={csrfToken} />
))}
</div>
)}
</Stack>
</Card>
);
};
export async function StickersTab({config, session, guildId, adminAcls, csrfToken}: StickersTabProps) {
const hasAssetPurge = hasPermission(adminAcls, AdminACLs.ASSET_PURGE);
if (!hasAssetPurge) {
return <RenderPermissionNotice />;
}
const result = await listGuildStickers(config, session, guildId);
if (!result.ok) {
return (
<VStack gap={4}>
<ErrorCard title="Error" message={getErrorMessage(result.error)} />
<a
href={`${config.basePath}/guilds/${guildId}?tab=stickers`}
class="inline-block rounded bg-neutral-900 px-4 py-2 font-medium text-sm text-white transition-colors hover:bg-neutral-800"
>
Back to Guild
</a>
</VStack>
);
}
return <RenderStickers config={config} guildId={guildId} stickers={result.data.stickers} csrfToken={csrfToken} />;
}