refactor progress
This commit is contained in:
128
packages/admin/src/pages/guild_detail/Forms.tsx
Normal file
128
packages/admin/src/pages/guild_detail/Forms.tsx
Normal 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>
|
||||
);
|
||||
155
packages/admin/src/pages/guild_detail/tabs/EmojisTab.tsx
Normal file
155
packages/admin/src/pages/guild_detail/tabs/EmojisTab.tsx
Normal 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} />;
|
||||
}
|
||||
87
packages/admin/src/pages/guild_detail/tabs/FeaturesTab.tsx
Normal file
87
packages/admin/src/pages/guild_detail/tabs/FeaturesTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
327
packages/admin/src/pages/guild_detail/tabs/MembersTab.tsx
Normal file
327
packages/admin/src/pages/guild_detail/tabs/MembersTab.tsx
Normal 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
|
||||
}
|
||||
>
|
||||
← 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 →
|
||||
</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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
208
packages/admin/src/pages/guild_detail/tabs/ModerationTab.tsx
Normal file
208
packages/admin/src/pages/guild_detail/tabs/ModerationTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
310
packages/admin/src/pages/guild_detail/tabs/OverviewTab.tsx
Normal file
310
packages/admin/src/pages/guild_detail/tabs/OverviewTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
285
packages/admin/src/pages/guild_detail/tabs/SettingsTab.tsx
Normal file
285
packages/admin/src/pages/guild_detail/tabs/SettingsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
160
packages/admin/src/pages/guild_detail/tabs/StickersTab.tsx
Normal file
160
packages/admin/src/pages/guild_detail/tabs/StickersTab.tsx
Normal 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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user