Add ability to manage custom user titles and display them

Implements new API endpoints and frontend components for fetching, displaying, and managing user-assigned titles within guilds.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: aed2e46d-25bb-4b73-81a1-bb9e8437c261
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Event-Id: 0a92717b-79d5-4240-a961-0b8ba7d9616c
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3bdfff67-975a-46ad-9845-fbb6b4a4c4b5/aed2e46d-25bb-4b73-81a1-bb9e8437c261/bakeZwZ
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
sirpiglr 2025-12-12 23:33:11 +00:00
parent 84d7720819
commit dc7fb514b8
2 changed files with 282 additions and 0 deletions

View file

@ -1155,6 +1155,9 @@
<div class="nav-item" data-page="inventory">
<span class="nav-icon">🎒</span> Inventory
</div>
<div class="nav-item" data-page="titles">
<span class="nav-icon">🏷️</span> Titles
</div>
</div>
<div class="nav-section" id="adminSection" style="display:none">
@ -1330,6 +1333,43 @@
</div>
</div>
<div id="page-titles" class="page hidden">
<div class="page-header">
<h1 class="page-title"><span class="text-gradient">Titles</span></h1>
<p class="page-subtitle">Customize your display title that shows on your profile</p>
</div>
<div class="card" style="margin-bottom:1.5rem">
<div class="card-header">
<h3 class="card-title">Current Title</h3>
</div>
<div class="card-body">
<div id="currentTitleDisplay" style="display:flex;align-items:center;gap:1rem">
<div style="flex:1">
<div style="font-size:1.25rem;font-weight:600" id="activeTitleName">No title selected</div>
<div style="font-size:0.875rem;color:var(--muted)" id="activeTitleDesc">Select a title from your collection below</div>
</div>
<button class="btn btn-secondary" onclick="clearActiveTitle()" id="clearTitleBtn" style="display:none">Clear Title</button>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title">Your Titles</h3>
</div>
<div class="card-body">
<div id="titlesList" class="item-list">
<div class="empty-state">
<div class="empty-state-icon">🏷️</div>
<p>You don't have any titles yet</p>
<p style="font-size:0.875rem;color:var(--muted);margin-top:0.5rem">Purchase titles from the shop or earn them through achievements</p>
</div>
</div>
</div>
</div>
</div>
<div id="page-admin-xp" class="page hidden">
<div class="page-header">
<h1 class="page-title"><span class="text-gradient">XP Settings</span></h1>
@ -2163,6 +2203,7 @@
case 'quests': await loadQuests(); break;
case 'shop': await loadShop(); break;
case 'inventory': await loadInventory(); break;
case 'titles': await loadTitles(); break;
case 'admin-xp': await loadXpConfig(); break;
case 'admin-quests': await loadAdminQuests(); break;
case 'admin-achievements': await loadAdminAchievements(); break;
@ -3346,6 +3387,95 @@
}
}
// Titles Functions
async function loadTitles() {
if (!currentGuild) return;
const container = document.getElementById('titlesList');
try {
const res = await fetch('/api/guild/' + currentGuild + '/titles');
const data = await res.json();
// Update active title display
if (data.activeTitle) {
document.getElementById('activeTitleName').textContent = data.activeTitle.name;
document.getElementById('activeTitleDesc').textContent = data.activeTitle.description || 'Your current display title';
document.getElementById('clearTitleBtn').style.display = 'block';
} else {
document.getElementById('activeTitleName').textContent = 'No title selected';
document.getElementById('activeTitleDesc').textContent = 'Select a title from your collection below';
document.getElementById('clearTitleBtn').style.display = 'none';
}
if (!data.titles || data.titles.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">🏷️</div>
<p>You don't have any titles yet</p>
<p style="font-size:0.875rem;color:var(--muted);margin-top:0.5rem">Purchase titles from the shop or earn them through achievements</p>
</div>
`;
return;
}
container.innerHTML = data.titles.map(t => `
<div class="item-row">
<div style="width:40px;height:40px;background:linear-gradient(135deg,var(--primary),#3b82f6);border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:1.25rem">🏷️</div>
<div class="item-info">
<div class="item-name">
${escapeHtml(t.name)}
${t.is_active ? '<span class="item-badge active">Active</span>' : ''}
</div>
<div class="item-desc">${escapeHtml(t.description || 'Custom title')}</div>
</div>
<div class="item-actions">
${!t.is_active ? `<button class="btn btn-primary" style="padding:0.5rem 1rem" onclick="setActiveTitle('${t.id}')">Use</button>` : ''}
</div>
</div>
`).join('');
} catch (e) {
container.innerHTML = '<div class="empty-state">Failed to load titles</div>';
}
}
async function setActiveTitle(titleId) {
if (!currentGuild) return;
try {
const res = await fetch('/api/guild/' + currentGuild + '/titles/active', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title_id: titleId })
});
if (res.ok) {
await loadTitles();
} else {
alert('Failed to set title');
}
} catch (e) {
alert('Failed to set title');
}
}
async function clearActiveTitle() {
if (!currentGuild) return;
try {
const res = await fetch('/api/guild/' + currentGuild + '/titles/active', {
method: 'DELETE'
});
if (res.ok) {
await loadTitles();
} else {
alert('Failed to clear title');
}
} catch (e) {
alert('Failed to clear title');
}
}
init();
</script>
</body>

View file

@ -1779,6 +1779,158 @@ function createWebServer(discordClient, supabase, options = {}) {
}
});
// Titles API endpoints
app.get('/api/guild/:guildId/titles', async (req, res) => {
if (!supabase) return res.status(503).json({ error: 'Database not available' });
const userId = req.session.user?.id;
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
const { guildId } = req.params;
try {
// Get user's link
const { data: link } = await supabase
.from('discord_links')
.select('user_id')
.eq('discord_id', userId)
.maybeSingle();
if (!link) {
return res.json({ titles: [], activeTitle: null });
}
// Get user's inventory items that are titles
const { data: inventory } = await supabase
.from('user_inventory')
.select(`
id,
item_id,
purchased_at,
is_active,
shop_items!inner (id, name, description, item_type)
`)
.eq('user_id', link.user_id)
.eq('guild_id', guildId)
.eq('shop_items.item_type', 'title');
const titles = (inventory || []).map(inv => ({
id: inv.id,
item_id: inv.item_id,
name: inv.shop_items?.name || 'Unknown Title',
description: inv.shop_items?.description || '',
purchased_at: inv.purchased_at,
is_active: inv.is_active === true
}));
const activeTitle = titles.find(t => t.is_active) || null;
res.json({ titles, activeTitle });
} catch (error) {
console.error('Failed to fetch titles:', error);
res.status(500).json({ error: 'Failed to load titles', titles: [], activeTitle: null });
}
});
app.post('/api/guild/:guildId/titles/active', async (req, res) => {
if (!supabase) return res.status(503).json({ error: 'Database not available' });
const userId = req.session.user?.id;
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
const { guildId } = req.params;
const { title_id } = req.body;
if (!title_id) {
return res.status(400).json({ error: 'Title ID required' });
}
try {
const { data: link } = await supabase
.from('discord_links')
.select('user_id')
.eq('discord_id', userId)
.maybeSingle();
if (!link) {
return res.status(400).json({ error: 'Account not linked' });
}
// Get all title-type inventory items and clear their active status
const { data: titleItems } = await supabase
.from('user_inventory')
.select('id, shop_items!inner(item_type)')
.eq('user_id', link.user_id)
.eq('guild_id', guildId)
.eq('shop_items.item_type', 'title');
if (titleItems && titleItems.length > 0) {
const titleIds = titleItems.map(t => t.id);
await supabase
.from('user_inventory')
.update({ is_active: false })
.in('id', titleIds);
}
// Set the selected title as active
const { error } = await supabase
.from('user_inventory')
.update({ is_active: true })
.eq('id', title_id)
.eq('user_id', link.user_id)
.eq('guild_id', guildId);
if (error) throw error;
res.json({ success: true });
} catch (error) {
console.error('Failed to set active title:', error);
res.status(500).json({ error: 'Failed to set title' });
}
});
app.delete('/api/guild/:guildId/titles/active', async (req, res) => {
if (!supabase) return res.status(503).json({ error: 'Database not available' });
const userId = req.session.user?.id;
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
const { guildId } = req.params;
try {
const { data: link } = await supabase
.from('discord_links')
.select('user_id')
.eq('discord_id', userId)
.maybeSingle();
if (!link) {
return res.status(400).json({ error: 'Account not linked' });
}
// Get all title-type inventory items and clear their active status
const { data: titleItems } = await supabase
.from('user_inventory')
.select('id, shop_items!inner(item_type)')
.eq('user_id', link.user_id)
.eq('guild_id', guildId)
.eq('shop_items.item_type', 'title');
if (titleItems && titleItems.length > 0) {
const titleIds = titleItems.map(t => t.id);
await supabase
.from('user_inventory')
.update({ is_active: false })
.in('id', titleIds);
}
res.json({ success: true });
} catch (error) {
console.error('Failed to clear active title:', error);
res.status(500).json({ error: 'Failed to clear title' });
}
});
app.get('/health', (req, res) => {
res.json({
status: 'ok',