Compare commits
2 Commits
782a138fd8
...
e56e133a69
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e56e133a69 | ||
|
|
0f871026eb |
@@ -16,16 +16,24 @@ export const quests = createCommand({
|
|||||||
const response = await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
const response = await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
|
||||||
const userId = interaction.user.id;
|
const userId = interaction.user.id;
|
||||||
|
let currentView: 'active' | 'available' = 'active';
|
||||||
|
let currentPage = 0;
|
||||||
|
|
||||||
|
const updateView = async (viewType: 'active' | 'available', page: number = 0) => {
|
||||||
|
currentView = viewType;
|
||||||
|
currentPage = page;
|
||||||
|
|
||||||
const updateView = async (viewType: 'active' | 'available') => {
|
|
||||||
const userQuests = await questService.getUserQuests(userId);
|
const userQuests = await questService.getUserQuests(userId);
|
||||||
const availableQuests = await questService.getAvailableQuests(userId);
|
const availableQuests = await questService.getAvailableQuests(userId);
|
||||||
|
|
||||||
const containers = viewType === 'active'
|
const activeQuests = userQuests.filter(entry => entry.completedAt === null);
|
||||||
? getQuestListComponents(userQuests)
|
const totalItems = viewType === 'active' ? activeQuests.length : availableQuests.length;
|
||||||
: getAvailableQuestsComponents(availableQuests);
|
|
||||||
|
|
||||||
const actionRows = getQuestActionRows(viewType);
|
const containers = viewType === 'active'
|
||||||
|
? getQuestListComponents(userQuests, page)
|
||||||
|
: getAvailableQuestsComponents(availableQuests, page);
|
||||||
|
|
||||||
|
const actionRows = getQuestActionRows(viewType, totalItems, page);
|
||||||
|
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
content: null,
|
content: null,
|
||||||
@@ -50,10 +58,16 @@ export const quests = createCommand({
|
|||||||
try {
|
try {
|
||||||
if (i.customId === "quest_view_active") {
|
if (i.customId === "quest_view_active") {
|
||||||
await i.deferUpdate();
|
await i.deferUpdate();
|
||||||
await updateView('active');
|
await updateView('active', 0);
|
||||||
} else if (i.customId === "quest_view_available") {
|
} else if (i.customId === "quest_view_available") {
|
||||||
await i.deferUpdate();
|
await i.deferUpdate();
|
||||||
await updateView('available');
|
await updateView('available', 0);
|
||||||
|
} else if (i.customId === "quest_page_prev") {
|
||||||
|
await i.deferUpdate();
|
||||||
|
await updateView(currentView, Math.max(0, currentPage - 1));
|
||||||
|
} else if (i.customId === "quest_page_next") {
|
||||||
|
await i.deferUpdate();
|
||||||
|
await updateView(currentView, currentPage + 1);
|
||||||
} else if (i.customId.startsWith("quest_accept:")) {
|
} else if (i.customId.startsWith("quest_accept:")) {
|
||||||
const questIdStr = i.customId.split(":")[1];
|
const questIdStr = i.customId.split(":")[1];
|
||||||
if (!questIdStr) return;
|
if (!questIdStr) return;
|
||||||
@@ -65,7 +79,8 @@ export const quests = createCommand({
|
|||||||
flags: MessageFlags.Ephemeral
|
flags: MessageFlags.Ephemeral
|
||||||
});
|
});
|
||||||
|
|
||||||
await updateView('active');
|
// Stay on current view/page but refresh (accepted quest disappears from available)
|
||||||
|
await updateView(currentView, currentPage);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Quest interaction error:", error);
|
console.error("Quest interaction error:", error);
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ const COLORS = {
|
|||||||
COMPLETED: 0xf1c40f // Gold - completed
|
COMPLETED: 0xf1c40f // Gold - completed
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Max quests per page (2 header + 1 page indicator + 7×5 components = 38, Discord max is 40 per container)
|
||||||
|
const QUESTS_PER_PAGE = 7;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats quest rewards object into a human-readable string
|
* Formats quest rewards object into a human-readable string
|
||||||
*/
|
*/
|
||||||
@@ -70,15 +73,22 @@ function renderProgressBar(current: number, total: number, size: number = 10): s
|
|||||||
/**
|
/**
|
||||||
* Creates Components v2 containers for the quest list (active quests only)
|
* Creates Components v2 containers for the quest list (active quests only)
|
||||||
*/
|
*/
|
||||||
export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuilder[] {
|
export function getQuestListComponents(userQuests: QuestEntry[], page: number = 0): ContainerBuilder[] {
|
||||||
// Filter to only show in-progress quests (not completed)
|
// Filter to only show in-progress quests (not completed)
|
||||||
const activeQuests = userQuests.filter(entry => entry.completedAt === null);
|
const activeQuests = userQuests.filter(entry => entry.completedAt === null);
|
||||||
|
const totalPages = Math.max(1, Math.ceil(activeQuests.length / QUESTS_PER_PAGE));
|
||||||
|
const safePage = Math.min(page, totalPages - 1);
|
||||||
|
const pageQuests = activeQuests.slice(safePage * QUESTS_PER_PAGE, (safePage + 1) * QUESTS_PER_PAGE);
|
||||||
|
|
||||||
const container = new ContainerBuilder()
|
const container = new ContainerBuilder()
|
||||||
.setAccentColor(COLORS.ACTIVE)
|
.setAccentColor(COLORS.ACTIVE)
|
||||||
.addTextDisplayComponents(
|
.addTextDisplayComponents(
|
||||||
new TextDisplayBuilder().setContent("# 📜 Quest Log"),
|
new TextDisplayBuilder().setContent("# 📜 Quest Log"),
|
||||||
new TextDisplayBuilder().setContent("-# Your active quests")
|
new TextDisplayBuilder().setContent(
|
||||||
|
totalPages > 1
|
||||||
|
? `-# Your active quests — Page ${safePage + 1}/${totalPages}`
|
||||||
|
: "-# Your active quests"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (activeQuests.length === 0) {
|
if (activeQuests.length === 0) {
|
||||||
@@ -89,7 +99,7 @@ export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuild
|
|||||||
return [container];
|
return [container];
|
||||||
}
|
}
|
||||||
|
|
||||||
activeQuests.forEach((entry) => {
|
pageQuests.forEach((entry) => {
|
||||||
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||||
|
|
||||||
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
|
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
|
||||||
@@ -113,12 +123,20 @@ export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuild
|
|||||||
/**
|
/**
|
||||||
* Creates Components v2 containers for available quests with inline accept buttons
|
* Creates Components v2 containers for available quests with inline accept buttons
|
||||||
*/
|
*/
|
||||||
export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]): ContainerBuilder[] {
|
export function getAvailableQuestsComponents(availableQuests: AvailableQuest[], page: number = 0): ContainerBuilder[] {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(availableQuests.length / QUESTS_PER_PAGE));
|
||||||
|
const safePage = Math.min(page, totalPages - 1);
|
||||||
|
const pageQuests = availableQuests.slice(safePage * QUESTS_PER_PAGE, (safePage + 1) * QUESTS_PER_PAGE);
|
||||||
|
|
||||||
const container = new ContainerBuilder()
|
const container = new ContainerBuilder()
|
||||||
.setAccentColor(COLORS.AVAILABLE)
|
.setAccentColor(COLORS.AVAILABLE)
|
||||||
.addTextDisplayComponents(
|
.addTextDisplayComponents(
|
||||||
new TextDisplayBuilder().setContent("# 🗺️ Available Quests"),
|
new TextDisplayBuilder().setContent("# 🗺️ Available Quests"),
|
||||||
new TextDisplayBuilder().setContent("-# Quests you can accept")
|
new TextDisplayBuilder().setContent(
|
||||||
|
totalPages > 1
|
||||||
|
? `-# Quests you can accept — Page ${safePage + 1}/${totalPages}`
|
||||||
|
: "-# Quests you can accept"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (availableQuests.length === 0) {
|
if (availableQuests.length === 0) {
|
||||||
@@ -129,10 +147,7 @@ export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]):
|
|||||||
return [container];
|
return [container];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limit to 10 quests (5 action rows max with 2 added for navigation)
|
pageQuests.forEach((quest) => {
|
||||||
const questsToShow = availableQuests.slice(0, 10);
|
|
||||||
|
|
||||||
questsToShow.forEach((quest) => {
|
|
||||||
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||||
|
|
||||||
const rewards = quest.rewards as { xp?: number, balance?: number };
|
const rewards = quest.rewards as { xp?: number, balance?: number };
|
||||||
@@ -163,11 +178,30 @@ export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]):
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns action rows for navigation only
|
* Returns action rows for navigation and pagination
|
||||||
*/
|
*/
|
||||||
export function getQuestActionRows(viewType: 'active' | 'available'): ActionRowBuilder<ButtonBuilder>[] {
|
export function getQuestActionRows(viewType: 'active' | 'available', totalItems: number, page: number): ActionRowBuilder<ButtonBuilder>[] {
|
||||||
// Navigation row
|
const totalPages = Math.max(1, Math.ceil(totalItems / QUESTS_PER_PAGE));
|
||||||
const navRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
|
||||||
|
|
||||||
|
// Pagination row (only if more than one page)
|
||||||
|
if (totalPages > 1) {
|
||||||
|
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId("quest_page_prev")
|
||||||
|
.setLabel("◀ Prev")
|
||||||
|
.setStyle(ButtonStyle.Secondary)
|
||||||
|
.setDisabled(page <= 0),
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId("quest_page_next")
|
||||||
|
.setLabel("Next ▶")
|
||||||
|
.setStyle(ButtonStyle.Secondary)
|
||||||
|
.setDisabled(page >= totalPages - 1)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab navigation row
|
||||||
|
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId("quest_view_active")
|
.setCustomId("quest_view_active")
|
||||||
.setLabel("📜 Active")
|
.setLabel("📜 Active")
|
||||||
@@ -178,9 +212,9 @@ export function getQuestActionRows(viewType: 'active' | 'available'): ActionRowB
|
|||||||
.setLabel("🗺️ Available")
|
.setLabel("🗺️ Available")
|
||||||
.setStyle(viewType === 'available' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
.setStyle(viewType === 'available' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
||||||
.setDisabled(viewType === 'available')
|
.setDisabled(viewType === 'available')
|
||||||
);
|
));
|
||||||
|
|
||||||
return [navRow];
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
198
docs/superpowers/specs/2026-03-26-impersonate-panel-design.md
Normal file
198
docs/superpowers/specs/2026-03-26-impersonate-panel-design.md
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# Impersonate Panel — Design Spec
|
||||||
|
|
||||||
|
A Discohook-style webhook message editor inside the Aurora admin panel for sending messages as custom characters, with reusable presets.
|
||||||
|
|
||||||
|
## Summary of Decisions
|
||||||
|
|
||||||
|
| Decision | Choice |
|
||||||
|
|----------|--------|
|
||||||
|
| Channel targeting | Pick channel each time (dropdown) |
|
||||||
|
| Preset storage | PostgreSQL (new table) |
|
||||||
|
| Editor layout | Side-by-side (builder left, preview right) |
|
||||||
|
| Component adding | Drag & drop from palette |
|
||||||
|
| Preset management | Separate "Presets" tab with card grid |
|
||||||
|
| JSON editing | Bidirectional visual ↔ JSON toggle |
|
||||||
|
| Format support | Classic (content + embeds) AND Components V2 |
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### New table: `webhook_presets`
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | serial PK | Auto-increment ID |
|
||||||
|
| `name` | varchar(100) | Preset display name |
|
||||||
|
| `username` | varchar(80) | Webhook display name |
|
||||||
|
| `avatar_url` | text, nullable | Avatar image URL |
|
||||||
|
| `payload` | jsonb | Full webhook payload (content, embeds, components) |
|
||||||
|
| `created_by` | bigint | Discord user ID of creator |
|
||||||
|
| `created_at` | timestamp | Creation time |
|
||||||
|
| `updated_at` | timestamp | Last modified |
|
||||||
|
|
||||||
|
The `payload` column stores the complete webhook JSON body and is the source of truth. The visual editor reads/writes this JSONB directly.
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- `created_by` uses `bigint('created_by', { mode: 'bigint' })` with a foreign key reference to `users.id` (`onDelete: CASCADE`)
|
||||||
|
- `created_at` uses `.defaultNow()`
|
||||||
|
- `updated_at` is set by the application on every write (no database trigger)
|
||||||
|
- No `guild_id` scoping — Aurora is a single-guild bot
|
||||||
|
- The new schema file must be re-exported from `shared/db/schema/index.ts`
|
||||||
|
- Backend validation: reject payloads larger than 100KB
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
All endpoints are protected behind existing admin auth.
|
||||||
|
|
||||||
|
### Presets CRUD
|
||||||
|
|
||||||
|
- `GET /api/impersonate/presets` — list all presets
|
||||||
|
- `POST /api/impersonate/presets` — create preset
|
||||||
|
- `PUT /api/impersonate/presets/:id` — update preset
|
||||||
|
- `DELETE /api/impersonate/presets/:id` — delete preset
|
||||||
|
|
||||||
|
### Sending
|
||||||
|
|
||||||
|
- `POST /api/impersonate/send` — send webhook message to a channel
|
||||||
|
- Body: `{ channelId: string, username: string, avatarUrl?: string, payload: object }`
|
||||||
|
- **Bridge pattern:** The route handler imports `BotClient` to resolve the channel by ID (`client.channels.fetch(channelId)`) and obtain the client user. These discord.js objects are passed to the existing `sendWebhookMessage` utility from `bot/lib/webhookUtils.ts`. This is acceptable because `api/` already runs in the same Bun process as the bot.
|
||||||
|
|
||||||
|
### Channels
|
||||||
|
|
||||||
|
- `GET /api/impersonate/channels` — fetch guild text channels for the channel picker
|
||||||
|
- Returns `{ id, name, parentName }` grouped by category
|
||||||
|
|
||||||
|
## Frontend Architecture
|
||||||
|
|
||||||
|
### Page Structure
|
||||||
|
|
||||||
|
Two tabs at the top of the Impersonate page: **Editor** and **Presets**.
|
||||||
|
|
||||||
|
### Editor Tab (Side-by-Side)
|
||||||
|
|
||||||
|
**Left pane — Builder:**
|
||||||
|
|
||||||
|
- **Top bar:** Username input, avatar URL input, channel dropdown, format toggle (Classic / Components V2), JSON/Visual toggle
|
||||||
|
- **Component palette:** Draggable component types. Components V2: Text Display, Section, Media Gallery, Separator, Container, File, Action Row. Classic: Content, Embed
|
||||||
|
- **Message canvas:** Drop zone where components are arranged. Each dropped component expands into an inline collapsible form editor. Drag to reorder via `@dnd-kit/core`
|
||||||
|
- **Bottom bar:** "Send" button and "Save as Preset" button
|
||||||
|
|
||||||
|
**Right pane — Preview:**
|
||||||
|
|
||||||
|
- Discord-styled message preview (dark theme, `#313338` background)
|
||||||
|
- Avatar circle + username + timestamp header
|
||||||
|
- Live-renders the current payload on every change
|
||||||
|
- Visual approximation of Discord's rendering, not pixel-perfect
|
||||||
|
|
||||||
|
### JSON Mode
|
||||||
|
|
||||||
|
Toggling to JSON replaces the visual builder with a monospace code editor. Edits sync bidirectionally. Invalid JSON shows an inline error and blocks switching back to visual mode until fixed.
|
||||||
|
|
||||||
|
### Presets Tab
|
||||||
|
|
||||||
|
- Card grid of saved presets showing avatar, name, and truncated payload preview
|
||||||
|
- Click a card to load it into the editor tab
|
||||||
|
- Edit/delete actions on each card
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
panel/src/
|
||||||
|
├── pages/
|
||||||
|
│ ├── Impersonate.tsx # Main page, tab switching, top-level state
|
||||||
|
│ └── impersonate/
|
||||||
|
│ ├── Editor.tsx # Side-by-side builder + preview layout
|
||||||
|
│ ├── Preview.tsx # Discord-style message renderer
|
||||||
|
│ ├── Presets.tsx # Preset card grid
|
||||||
|
│ ├── ComponentPalette.tsx # Draggable component type list
|
||||||
|
│ └── components/
|
||||||
|
│ ├── TextDisplayEditor.tsx
|
||||||
|
│ ├── SectionEditor.tsx
|
||||||
|
│ ├── MediaGalleryEditor.tsx
|
||||||
|
│ ├── SeparatorEditor.tsx
|
||||||
|
│ ├── ContainerEditor.tsx
|
||||||
|
│ ├── FileEditor.tsx
|
||||||
|
│ ├── ActionRowEditor.tsx
|
||||||
|
│ ├── EmbedEditor.tsx # Classic mode
|
||||||
|
│ └── ContentEditor.tsx # Classic mode
|
||||||
|
├── lib/
|
||||||
|
│ └── useImpersonate.ts # API hook for presets CRUD + send + channels
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
```
|
||||||
|
shared/db/schema/
|
||||||
|
│ └── webhook-presets.ts # New schema file (re-export from index.ts)
|
||||||
|
|
||||||
|
shared/modules/impersonate/
|
||||||
|
│ └── impersonate.service.ts # Preset CRUD + send logic
|
||||||
|
|
||||||
|
api/src/routes/
|
||||||
|
│ └── impersonate.routes.ts # Route handler (register in index.ts protectedRoutes)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Panel Wiring
|
||||||
|
|
||||||
|
- Add `"impersonate"` to the `Page` union type in `Layout.tsx`
|
||||||
|
- Add nav item to `navItems` array in `Layout.tsx` with appropriate Lucide icon
|
||||||
|
- Add conditional render branch in `App.tsx`
|
||||||
|
|
||||||
|
**Note:** The `pages/impersonate/` sub-directory is a new pattern — existing pages are flat files. This is justified by the complexity of this feature (9+ component files). Flat pages remain appropriate for simpler pages.
|
||||||
|
|
||||||
|
## Component Editors
|
||||||
|
|
||||||
|
Each component type gets an inline collapsible editor card on the canvas.
|
||||||
|
|
||||||
|
### Components V2
|
||||||
|
|
||||||
|
| Component | Editable Fields |
|
||||||
|
|-----------|----------------|
|
||||||
|
| **Text Display** | Markdown content textarea |
|
||||||
|
| **Section** | Text content, accessory type (button or thumbnail), accessory config (URL, label, style) |
|
||||||
|
| **Media Gallery** | List of media items: URL, alt text, spoiler toggle. Add/remove items |
|
||||||
|
| **Separator** | Spacing size toggle (small/large) |
|
||||||
|
| **Container** | Accent color picker, nested drop zone (accepts Text Display, Section, Media Gallery, Separator, Action Row, File) |
|
||||||
|
| **File** | URL input, filename |
|
||||||
|
| **Action Row** | Buttons: label, style (Primary/Secondary/Success/Danger/Link), URL/custom ID, emoji, disabled toggle. Select menus: placeholder, options list, min/max values |
|
||||||
|
|
||||||
|
### Classic Mode
|
||||||
|
|
||||||
|
| Component | Editable Fields |
|
||||||
|
|-----------|----------------|
|
||||||
|
| **Content** | Markdown textarea |
|
||||||
|
| **Embed** | Title, description, URL, color picker, timestamp, author (name, icon URL), footer (text, icon URL), image URL, thumbnail URL, fields (array of name, value, inline toggle) |
|
||||||
|
|
||||||
|
### Webhook-Level Options
|
||||||
|
|
||||||
|
- `tts` toggle
|
||||||
|
- `thread_name` input (for forum channels)
|
||||||
|
- `flags` (suppress embeds/notifications)
|
||||||
|
- When Components V2 format is selected, the payload must include `flags: 32768` (`IS_COMPONENTS_V2` flag, `1 << 15`). This is set automatically by the editor when the format toggle is on Components V2.
|
||||||
|
|
||||||
|
## Preview Renderer
|
||||||
|
|
||||||
|
Renders a Discord-style message mock in the right pane:
|
||||||
|
|
||||||
|
- Dark background (`#313338`)
|
||||||
|
- Avatar circle + username + "Today at HH:MM" timestamp header
|
||||||
|
- Components V2: containers with accent-colored left border, text blocks with markdown rendering, media gallery as responsive image grid, buttons as pill-shaped elements with Discord color scheme, separators as horizontal rules
|
||||||
|
- Classic: content as rendered markdown, embeds with colored left border, field grids, inline images
|
||||||
|
- Live updates on every change
|
||||||
|
|
||||||
|
This is a visual approximation for authoring purposes, not a 1:1 Discord replica.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Scenario | Behavior |
|
||||||
|
|----------|----------|
|
||||||
|
| Invalid JSON on toggle | Show inline error, block switch to visual until fixed |
|
||||||
|
| Send failure | Display Discord API error message inline (e.g., "Missing permissions") |
|
||||||
|
| Empty payload | Disable Send button |
|
||||||
|
| Discord payload limits | Validate against limits (6000 char embeds, 10 components per action row, 5 action rows) and show warnings |
|
||||||
|
| Channel permission errors | Surface "Bot lacks MANAGE_WEBHOOKS permission" clearly |
|
||||||
|
| Invalid avatar URL | Lightweight `https://` check; Discord rejects bad URLs on send |
|
||||||
|
| Preset name collision | Allowed — presets identified by ID |
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `@dnd-kit/core` + `@dnd-kit/sortable` — drag and drop
|
||||||
|
- No other new dependencies expected; existing stack (React, Tailwind, Lucide) covers the rest
|
||||||
Reference in New Issue
Block a user