Fixes duplicate placeholder cover image on the 3 newest blog posts, broken aspect-ratio classes on the albums listing page, and a non-responsive fixed sidebar on the chat page.
361 lines
16 KiB
HTML
361 lines
16 KiB
HTML
{{ define "main" }}
|
|
<div class="bg-gray-900 min-h-screen py-16 px-4 sm:px-6 lg:px-8" x-data="adminPanel()" x-init="init()">
|
|
<div class="max-w-6xl mx-auto">
|
|
<!-- Login Screen -->
|
|
<div x-show="!authenticated" class="max-w-md mx-auto">
|
|
<div class="bg-gray-800 rounded-lg p-8 border border-gray-700">
|
|
<h1 class="text-3xl font-bold text-gray-100 mb-6 text-center">Admin Login</h1>
|
|
<form @submit.prevent="adminLogin()" class="space-y-6">
|
|
<div>
|
|
<label for="admin-password" class="block text-sm font-medium text-gray-300 mb-2">Password</label>
|
|
<input type="password" id="admin-password" x-model="adminPassword" required
|
|
class="w-full px-4 py-3 bg-gray-900 border border-gray-700 rounded-lg text-gray-100 focus:ring-2 focus:ring-primary focus:border-transparent">
|
|
</div>
|
|
<div x-show="loginError" class="bg-red-900/50 border border-red-700 rounded-lg p-4 text-red-200">
|
|
<p x-text="loginError"></p>
|
|
</div>
|
|
<button type="submit"
|
|
:disabled="loginLoading"
|
|
class="w-full px-6 py-3 bg-primary hover:bg-emerald-700 text-white font-semibold rounded-lg transition-colors disabled:opacity-50">
|
|
<span x-show="!loginLoading">Login</span>
|
|
<span x-show="loginLoading">Logging in...</span>
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Admin Dashboard -->
|
|
<div x-show="authenticated" x-transition>
|
|
<div class="flex justify-between items-center mb-8">
|
|
<h1 class="text-3xl font-bold text-gray-100">Admin Dashboard</h1>
|
|
<button @click="logout()" class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors">
|
|
Logout
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Tabs -->
|
|
<div class="border-b border-gray-700 mb-6 flex">
|
|
<button @click="activeTab = 'members'; loadPendingMembers()"
|
|
:class="activeTab === 'members' ? 'border-b-2 border-primary text-primary' : 'text-gray-400 hover:text-gray-300'"
|
|
class="px-6 py-3 font-medium transition-colors">
|
|
Pending Members
|
|
</button>
|
|
<button @click="activeTab = 'testimonials'; loadTestimonials()"
|
|
:class="activeTab === 'testimonials' ? 'border-b-2 border-primary text-primary' : 'text-gray-400 hover:text-gray-300'"
|
|
class="px-6 py-3 font-medium transition-colors">
|
|
Testimonials
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Pending Members Tab -->
|
|
<div x-show="activeTab === 'members'" x-transition>
|
|
<div x-show="loadingMembers" class="text-center text-gray-400 py-12">
|
|
Loading...
|
|
</div>
|
|
<div x-show="membersError" class="bg-red-900/50 border border-red-700 rounded-lg p-4 text-red-200 mb-6">
|
|
<p x-text="membersError"></p>
|
|
</div>
|
|
<div class="space-y-4">
|
|
<template x-for="member in pendingMembers" :key="member.id">
|
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
|
<div class="flex justify-between items-start">
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-gray-100" x-text="member.name"></h3>
|
|
<p class="text-gray-400" x-text="member.email"></p>
|
|
<p class="text-sm text-gray-500 mt-2" x-text="'Signed up: ' + new Date(member.created_at).toLocaleString()"></p>
|
|
</div>
|
|
<div class="flex gap-3">
|
|
<button @click="approveMember(member.id)"
|
|
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors">
|
|
Approve
|
|
</button>
|
|
<button @click="denyMember(member.id)"
|
|
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors">
|
|
Deny
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div x-show="!loadingMembers && !membersError && pendingMembers.length === 0" class="text-center text-gray-400 py-12">
|
|
<p>No pending memberships</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Testimonials Tab -->
|
|
<div x-show="activeTab === 'testimonials'" x-transition>
|
|
<div x-show="loadingTestimonials" class="text-center text-gray-400 py-12">
|
|
Loading...
|
|
</div>
|
|
<div x-show="testimonialsError" class="bg-red-900/50 border border-red-700 rounded-lg p-4 text-red-200 mb-6">
|
|
<p x-text="testimonialsError"></p>
|
|
</div>
|
|
<div class="space-y-4">
|
|
<template x-for="testimonial in allTestimonials" :key="testimonial.id">
|
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
|
<div class="flex justify-between items-start mb-4">
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-gray-100" x-text="testimonial.author_name"></h3>
|
|
<p class="text-sm text-gray-400" x-text="new Date(testimonial.created_at).toLocaleString()"></p>
|
|
<span x-show="testimonial.approved" class="inline-block mt-2 px-2 py-1 bg-green-600/20 text-green-300 text-xs rounded">
|
|
Approved
|
|
</span>
|
|
<span x-show="!testimonial.approved" class="inline-block mt-2 px-2 py-1 bg-yellow-600/20 text-yellow-300 text-xs rounded">
|
|
Pending
|
|
</span>
|
|
</div>
|
|
<div class="flex gap-3">
|
|
<button x-show="!testimonial.approved"
|
|
@click="approveTestimonial(testimonial.id)"
|
|
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors">
|
|
Approve
|
|
</button>
|
|
<button @click="deleteTestimonial(testimonial.id)"
|
|
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors">
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<p class="text-gray-300 leading-relaxed" x-text="testimonial.content"></p>
|
|
</div>
|
|
</template>
|
|
<div x-show="!loadingTestimonials && !testimonialsError && allTestimonials.length === 0" class="text-center text-gray-400 py-12">
|
|
<p>No testimonials</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const getAdminApiBase = () => {
|
|
const host = window.location.hostname;
|
|
const isLocal = host === 'localhost' || host === '127.0.0.1' || host.startsWith('10.');
|
|
return isLocal ? 'http://' + host + ':5001' : window.location.origin.replace(/\/$/, '');
|
|
};
|
|
const ADMIN_API_BASE = getAdminApiBase() + '/api';
|
|
|
|
function adminPanel() {
|
|
return {
|
|
authenticated: false,
|
|
adminToken: null,
|
|
adminPassword: '',
|
|
loginError: '',
|
|
loginLoading: false,
|
|
activeTab: 'members',
|
|
pendingMembers: [],
|
|
loadingMembers: false,
|
|
membersError: '',
|
|
allTestimonials: [],
|
|
loadingTestimonials: false,
|
|
testimonialsError: '',
|
|
|
|
init() {
|
|
const token = localStorage.getItem('admin_token');
|
|
if (token) {
|
|
this.adminToken = token;
|
|
this.authenticated = true;
|
|
this.loadPendingMembers();
|
|
}
|
|
},
|
|
|
|
async adminLogin() {
|
|
this.loginError = '';
|
|
this.loginLoading = true;
|
|
|
|
try {
|
|
const response = await fetch(`${ADMIN_API_BASE}/admin/login`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password: this.adminPassword })
|
|
});
|
|
|
|
const data = await response.json();
|
|
this.loginLoading = false;
|
|
|
|
if (response.ok) {
|
|
this.adminToken = data.token;
|
|
localStorage.setItem('admin_token', data.token);
|
|
this.authenticated = true;
|
|
this.adminPassword = '';
|
|
this.loadPendingMembers();
|
|
} else {
|
|
this.loginError = data.error || 'Login failed';
|
|
}
|
|
} catch (error) {
|
|
this.loginLoading = false;
|
|
this.loginError = 'Login failed. Please try again.';
|
|
}
|
|
},
|
|
|
|
logout() {
|
|
localStorage.removeItem('admin_token');
|
|
this.adminToken = null;
|
|
this.authenticated = false;
|
|
},
|
|
|
|
async loadPendingMembers() {
|
|
this.loadingMembers = true;
|
|
this.membersError = '';
|
|
|
|
try {
|
|
const response = await fetch(`${ADMIN_API_BASE}/admin/pending`, {
|
|
headers: { 'Authorization': `Bearer ${this.adminToken}` }
|
|
});
|
|
|
|
const data = await response.json();
|
|
this.loadingMembers = false;
|
|
|
|
if (response.ok) {
|
|
this.pendingMembers = data.members || [];
|
|
} else {
|
|
if (response.status === 401) {
|
|
this.logout();
|
|
this.loginError = 'Session expired. Please login again.';
|
|
} else {
|
|
this.membersError = data.error || 'Failed to load members';
|
|
}
|
|
}
|
|
} catch (error) {
|
|
this.loadingMembers = false;
|
|
this.membersError = 'Failed to load members';
|
|
}
|
|
},
|
|
|
|
async approveMember(userId) {
|
|
try {
|
|
const response = await fetch(`${ADMIN_API_BASE}/admin/approve`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${this.adminToken}`
|
|
},
|
|
body: JSON.stringify({ user_id: userId })
|
|
});
|
|
|
|
const data = await response.json();
|
|
if (response.ok) {
|
|
this.loadPendingMembers();
|
|
alert('Member approved successfully');
|
|
} else {
|
|
if (response.status === 401) {
|
|
this.logout();
|
|
} else {
|
|
alert(data.error || 'Failed to approve member');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
alert('Failed to approve member');
|
|
}
|
|
},
|
|
|
|
async denyMember(userId) {
|
|
if (!confirm('Are you sure you want to deny this membership?')) return;
|
|
|
|
try {
|
|
const response = await fetch(`${ADMIN_API_BASE}/admin/deny`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${this.adminToken}`
|
|
},
|
|
body: JSON.stringify({ user_id: userId })
|
|
});
|
|
|
|
const data = await response.json();
|
|
if (response.ok) {
|
|
this.loadPendingMembers();
|
|
alert('Member denied');
|
|
} else {
|
|
if (response.status === 401) {
|
|
this.logout();
|
|
} else {
|
|
alert(data.error || 'Failed to deny member');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
alert('Failed to deny member');
|
|
}
|
|
},
|
|
|
|
async loadTestimonials() {
|
|
this.loadingTestimonials = true;
|
|
this.testimonialsError = '';
|
|
|
|
try {
|
|
const response = await fetch(`${ADMIN_API_BASE}/admin/testimonials`, {
|
|
headers: { 'Authorization': `Bearer ${this.adminToken}` }
|
|
});
|
|
|
|
const data = await response.json();
|
|
this.loadingTestimonials = false;
|
|
|
|
if (response.ok) {
|
|
this.allTestimonials = data.testimonials || [];
|
|
} else {
|
|
if (response.status === 401) {
|
|
this.logout();
|
|
} else {
|
|
this.testimonialsError = data.error || 'Failed to load testimonials';
|
|
}
|
|
}
|
|
} catch (error) {
|
|
this.loadingTestimonials = false;
|
|
this.testimonialsError = 'Failed to load testimonials';
|
|
}
|
|
},
|
|
|
|
async approveTestimonial(testimonialId) {
|
|
try {
|
|
const response = await fetch(`${ADMIN_API_BASE}/admin/testimonials/${testimonialId}/approve`, {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${this.adminToken}` }
|
|
});
|
|
|
|
const data = await response.json();
|
|
if (response.ok) {
|
|
this.loadTestimonials();
|
|
} else {
|
|
if (response.status === 401) {
|
|
this.logout();
|
|
} else {
|
|
alert(data.error || 'Failed to approve testimonial');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
alert('Failed to approve testimonial');
|
|
}
|
|
},
|
|
|
|
async deleteTestimonial(testimonialId) {
|
|
if (!confirm('Are you sure you want to delete this testimonial?')) return;
|
|
|
|
try {
|
|
const response = await fetch(`${ADMIN_API_BASE}/admin/testimonials/${testimonialId}/delete`, {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${this.adminToken}` }
|
|
});
|
|
|
|
const data = await response.json();
|
|
if (response.ok) {
|
|
this.loadTestimonials();
|
|
} else {
|
|
if (response.status === 401) {
|
|
this.logout();
|
|
} else {
|
|
alert(data.error || 'Failed to delete testimonial');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
alert('Failed to delete testimonial');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
</script>
|
|
{{ end }}
|
|
|