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:
parent
84d7720819
commit
dc7fb514b8
2 changed files with 282 additions and 0 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue