Initial Commit

This commit is contained in:
2025-11-20 15:49:45 +00:00
commit b6dd8b8fe2
1530 changed files with 602744 additions and 0 deletions

45
templates/about.html Normal file
View File

@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block content %}
<section class="py-16 md:py-24">
<div class="max-w-4xl mx-auto px-6 space-y-8">
<header>
<h1 class="font-bebas text-5xl">About Bennys House</h1>
<p class="mt-4 text-white/80 max-w-prose">Bennys House is an Amarillohomed IT & Web services studio serving the Texas Panhandle and the greater Texas area. We build and run practical systems for small businesses and individuals, and we can operate at enterprise scale when needed.</p>
</header>
<section class="glass rounded-2xl p-6 space-y-4">
<h2 class="text-xl font-semibold">What we believe</h2>
<ul class="card-list !pl-0 list-none space-y-2">
<li class="dot">Localfirst when it makes sense, cloud when it earns the right.</li>
<li class="dot">Simple, reliable stacks beat flashy complexity.</li>
<li class="dot">Own your infra where possible; avoid lockin.</li>
</ul>
</section>
<section class="grid md:grid-cols-2 gap-6">
<article class="card">
<h3 class="card-title">Service Area</h3>
<p class="card-body">Based in Amarillo, we happily travel anywhere in the Texas Panhandle — and consult across the greater Texas region. Remote support available.</p>
</article>
<article class="card">
<h3 class="card-title">Stack & Focus</h3>
<p class="card-body">Proxmox • Windows Server/AD • Linux • Flask/Node • Tailwind • MariaDB • WireGuard • Nginx. We ship secure, maintainable, nononsense solutions.</p>
</article>
</section>
<section class="glass rounded-2xl p-6">
<h2 class="text-xl font-semibold">Who we help</h2>
<p class="card-body">Local shops, offices, and individuals who need dependable systems and clean websites. Need enterprise rigor? We can engage via BrookHaven for larger programs.</p>
</section>
<div class="pt-2">
<a href="/contact" class="btn-primary">Start a project</a>
<a href="/services" class="btn-ghost ml-2">Explore services</a>
</div>
</div>
</section>
{% endblock %}

119
templates/admin.html Normal file
View File

@@ -0,0 +1,119 @@
{% extends "base.html" %}
{% block content %}
<section class="max-w-6xl mx-auto py-10">
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Quote Requests</h1>
<!-- simple client-side search -->
<input id="q" placeholder="Search name/email/need…" class="w-64"
oninput="filterRows(this.value)" />
</div>
<div class="overflow-x-auto card glass">
<table id="reqTable" class="min-w-full text-sm">
<thead class="text-left text-white/70">
<tr>
<th class="p-3">ID</th>
<th class="p-3">Date</th>
<th class="p-3">Name</th>
<th class="p-3">Email</th>
<th class="p-3">What They Need</th>
<th class="p-3">Scope Size</th>
<th class="p-3">Timeline</th>
<th class="p-3">Extras</th>
<th class="p-3">Budget</th>
<th class="p-3">Est. Hours</th>
<th class="p-3">Est. Cost</th>
<th class="p-3">Notes</th>
</tr>
</thead>
<tbody id="reqBody">
{% for r in rows %}
<tr class="border-t border-white/10">
<td class="p-3">{{ r.id }}</td>
<td class="p-3 whitespace-nowrap">{{ r.created_at[:19].replace('T',' ') }}</td>
<td class="p-3">{{ r.name }}</td>
<td class="p-3"><a class="underline" href="mailto:{{ r.email }}">{{ r.email }}</a></td>
<!-- These columns reuse existing DB fields with friendlier labels -->
<td class="p-3">{{ r.project_type or '-' }}</td>
<td class="p-3">{{ r.complexity or '-' }}</td>
<td class="p-3">
{% set tl = (r.urgency or '-') %}
<span class="px-2 py-0.5 rounded text-xs
{% if tl == 'critical' or tl == 'rush' %} bg-red-500/20 border border-red-500/30 text-red-200
{% elif tl == 'soon' %} bg-yellow-500/20 border border-yellow-500/30 text-yellow-200
{% else %} bg-emerald-500/20 border border-emerald-500/30 text-emerald-200
{% endif %}
">{{ tl }}</span>
</td>
<td class="p-3">{{ r.features or '-' }}</td>
<td class="p-3">{{ r.budget_range or '-' }}</td>
<td class="p-3">{{ r.est_hours }}</td>
<td class="p-3">${{ '%.2f'|format(r.est_cost or 0) }}</td>
<td class="p-3">
<button class="btn" type="button"
data-notes="{{ (r.description or '') | e }}"
data-json='{{ (r.json_payload or "{}") | e }}'
data-meta='{{ (r.name ~ " • " ~ (r.email or "") ~ " • #" ~ r.id) | e }}'
onclick="openNotes(this)">
View notes
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Modal -->
<dialog id="notesModal" class="backdrop:bg-black/70 rounded-xl w-[min(90vw,720px)]">
<form method="dialog" class="glass card p-0 overflow-hidden">
<header class="flex items-center justify-between px-5 py-3 border-b border-white/10">
<h2 id="modalTitle" class="font-semibold">Notes</h2>
<button class="btn" value="close">Close</button>
</header>
<div class="p-5 space-y-5">
<section>
<h3 class="text-sm font-semibold text-white/70 mb-2">Client notes</h3>
<pre id="notesText" class="whitespace-pre-wrap text-sm bg-black/40 p-3 rounded border border-white/10"></pre>
</section>
<section>
<h3 class="text-sm font-semibold text-white/70 mb-2">Raw submission (JSON)</h3>
<pre id="jsonText" class="overflow-auto text-xs bg-black/40 p-3 rounded border border-white/10 max-h-64"></pre>
</section>
</div>
<footer class="px-5 py-3 border-t border-white/10 text-right">
<button class="btn bg-accent font-semibold" value="close">Done</button>
</footer>
</form>
</dialog>
</section>
<script>
function openNotes(btn) {
const modal = document.getElementById('notesModal');
const notes = btn.getAttribute('data-notes') || '';
const raw = btn.getAttribute('data-json') || '{}';
const meta = btn.getAttribute('data-meta') || 'Notes';
document.getElementById('modalTitle').textContent = meta;
document.getElementById('notesText').textContent = notes.trim() || '—';
try {
const obj = JSON.parse(raw);
document.getElementById('jsonText').textContent = JSON.stringify(obj, null, 2);
} catch {
document.getElementById('jsonText').textContent = raw;
}
modal.showModal();
}
// simple in-table filter
function filterRows(q) {
q = (q || '').toLowerCase();
document.querySelectorAll('#reqBody tr').forEach(tr => {
const text = tr.innerText.toLowerCase();
tr.style.display = text.includes(q) ? '' : 'none';
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}Edit Ticket — {{ brand }}{% endblock %}
{% block content %}
<h1 class="text-2xl font-bold">Edit Ticket #{{ t.id }}</h1>
<form method="post" class="mt-4 grid gap-3 max-w-2xl">
<div>
<label class="text-xs text-white/60">Title</label>
<input name="title" value="{{ t.title }}" class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2" required />
</div>
<div>
<label class="text-xs text-white/60">Description</label>
<textarea name="description" rows="8" class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2" required>{{ t.description }}</textarea>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-white/60">Priority</label>
<select name="priority" class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2">
{% for p in ['low','normal','high','urgent'] %}<option value="{{p}}" {{ 'selected' if t.priority==p else '' }}>{{p.title()}}</option>{% endfor %}
</select>
</div>
<div>
<label class="text-xs text-white/60">Labels (comma-sep)</label>
<input name="labels" value="{{ ', '.join(t.label_list()) }}" class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2" />
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-white/60">Assignee Discord ID</label>
<input name="assignee_id" value="{{ t.assignee_id or '' }}" class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2" />
</div>
<div>
<label class="text-xs text-white/60">Assignee Display Name</label>
<input name="assignee_name" value="{{ t.assignee_name or '' }}" class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2" />
</div>
</div>
<div>
<label class="text-xs text-white/60">Status</label>
<select name="status" class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2">
{% for s in ['open','in_progress','done','cancelled'] %}<option value="{{s}}" {{ 'selected' if t.status==s else '' }}>{{ s.replace('_',' ').title() }}</option>{% endfor %}
</select>
</div>
<div class="mt-2"><button class="btn-accent">Save Changes</button></div>
</form>
{% endblock %}

View File

@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}New Ticket — {{ brand }}{% endblock %}
{% block content %}
<h1 class="text-2xl font-bold">Create Ticket</h1>
<form method="post" class="mt-4 grid gap-3 max-w-2xl">
<div>
<label class="text-xs text-white/60">Title</label>
<input name="title" class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2" required />
</div>
<div>
<label class="text-xs text-white/60">Description</label>
<textarea name="description" rows="8" class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2" required></textarea>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-white/60">Priority</label>
<select name="priority" class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2">
{% for p in ['low','normal','high','urgent'] %}<option value="{{p}}">{{p.title()}}</option>{% endfor %}
</select>
</div>
<div>
<label class="text-xs text-white/60">Labels (comma-sep)</label>
<input name="labels" class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2" placeholder="frontend, bug, outreach" />
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-white/60">Assignee Discord ID (optional)</label>
<input name="assignee_id" class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2" />
</div>
<div>
<label class="text-xs text-white/60">Assignee Display Name (optional)</label>
<input name="assignee_name" class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2" />
</div>
</div>
<div class="mt-2"><button class="btn-accent">Create Ticket</button></div>
</form>
{% endblock %}

101
templates/base.html Normal file
View File

@@ -0,0 +1,101 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>{% block title %}{{ brand }}{% endblock %}</title>
<link rel="icon" href="{{ url_for('favicon') }}">
<script src="https://cdn.tailwindcss.com"></script>
<style>
:root { --bt-accent: {{ accent|default('#9b5cf6') }}; }
.nav-link { @apply text-white/80 text-sm font-medium px-3 py-2 rounded-lg transition hover:text-white hover:bg-white/10; }
.nav-cta { @apply font-semibold text-black px-3 py-2 rounded-lg; background: var(--bt-accent); }
.nav-cta:hover { filter: brightness(0.95); }
.nav-active { @apply text-white bg-white/10; }
</style>
</head>
<body class="bg-[#080812] text-white min-h-screen flex flex-col">
<!-- NAVBAR -->
<header class="sticky top-0 z-40 backdrop-blur-md bg-[#0a0a15]/80 border-b border-white/10">
<div class="max-w-7xl mx-auto px-4 md:px-6">
<!-- increased height + breathing room -->
<div class="h-16 flex items-center justify-between gap-4">
<!-- Brand -->
<a href="{{ url_for('tickets') }}" class="shrink-0 text-lg md:text-xl font-bold tracking-tight flex items-center gap-1">
<span>Buff</span><span style="color:var(--bt-accent)">TEKS</span>
</a>
{% set u = session.get('discord_user') %}
<!-- Desktop nav -->
<nav class="hidden md:flex items-center gap-3">
<a href="{{ url_for('tickets') }}" class="nav-link {% if request.endpoint == 'tickets' %}nav-active{% endif %}">Tickets</a>
{% if u and 'admin' in (u.get('site_roles') or []) %}
<a href="{{ url_for('admin_new_ticket') }}" class="nav-link {% if request.endpoint == 'admin_new_ticket' %}nav-active{% endif %}">New Ticket</a>
{% endif %}
<!-- visual divider before auth area -->
<span class="h-6 w-px bg-white/10 mx-1"></span>
{% if u %}
<span class="text-white/60 text-sm px-1 md:px-2">Signed in as <b class="font-semibold" style="color:var(--bt-accent)">{{ u.username }}</b></span>
<a href="{{ url_for('logout') }}" class="nav-link">Log out</a>
{% else %}
<a href="{{ url_for('discord_login') }}" class="nav-cta">Sign in</a>
{% endif %}
</nav>
<!-- Mobile hamburger (force visible) -->
<button id="navToggle" class="md:hidden inline-flex items-center justify-center w-10 h-10 rounded-lg text-white hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-white/20" aria-label="Toggle menu" aria-expanded="false">
<!-- use stroked paths so they pop on dark bg -->
<svg id="iconOpen" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
<svg id="iconClose" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
<!-- Mobile menu -->
<div id="mobileMenu" class="hidden md:hidden border-t border-white/10 bg-[#0b0b17]/95 shadow-2xl">
<div class="max-w-7xl mx-auto px-4 md:px-6 py-4 flex flex-col gap-2">
<a href="{{ url_for('tickets') }}" class="nav-link {% if request.endpoint == 'tickets' %}nav-active{% endif %}">Tickets</a>
{% if u and 'admin' in (u.get('site_roles') or []) %}
<a href="{{ url_for('admin_new_ticket') }}" class="nav-link {% if request.endpoint == 'admin_new_ticket' %}nav-active{% endif %}">New Ticket</a>
{% endif %}
<span class="h-px bg-white/10 my-1"></span>
{% if u %}
<div class="px-3 text-sm text-white/60">Signed in as <b class="text-white">{{ u.username }}</b></div>
<a href="{{ url_for('logout') }}" class="nav-link">Log out</a>
{% else %}
<a href="{{ url_for('discord_login') }}" class="nav-cta w-fit">Sign in</a>
{% endif %}
</div>
</div>
</header>
<!-- MAIN (added a touch more top padding so content isn't crowded) -->
<main class="flex-1 max-w-7xl w-full mx-auto px-4 md:px-6 py-10">
{% block content %}{% endblock %}
</main>
<footer class="text-center py-6 text-white/40 text-sm border-t border-white/10">
&copy; {{ brand }} · Built in-house by student engineers
</footer>
<script>
const toggle = document.getElementById('navToggle');
const menu = document.getElementById('mobileMenu');
const openIcon = document.getElementById('iconOpen');
const closeIcon = document.getElementById('iconClose');
toggle?.addEventListener('click', () => {
menu.classList.toggle('hidden');
const expanded = toggle.getAttribute('aria-expanded') === 'true';
toggle.setAttribute('aria-expanded', (!expanded).toString());
openIcon.classList.toggle('hidden');
closeIcon.classList.toggle('hidden');
});
</script>
</body>
</html>

179
templates/board.html Normal file
View File

@@ -0,0 +1,179 @@
{% extends "base.html" %}
{% block title %}Message Board — {{ brand }}{% endblock %}
{% block content %}
<section class="max-w-5xl mx-auto px-6 py-10">
<!-- Header -->
<header class="flex items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-bold">Message Board</h1>
<p class="text-white/70 text-sm">Mirror of the Discord channel.</p>
</div>
<div class="flex items-center gap-3">
{% if user %}
<span class="text-white/80 text-sm">Signed in as <strong>{{ user.username }}</strong></span>
<a href="{{ url_for('logout') }}" class="px-3 py-1.5 rounded-lg border border-white/20 hover:border-white/40 text-sm">Log out</a>
{% else %}
<a href="{{ url_for('discord_login') }}" class="px-3 py-1.5 rounded-lg bg-bt-accent/90 text-black font-semibold">Sign in with Discord</a>
{% endif %}
</div>
</header>
<!-- Composer (Admins only) -->
<div class="mt-6 rounded-2xl bg-white/5 border border-white/10 p-4">
<textarea
id="composer" rows="3" placeholder="Share an update…"
class="w-full rounded-xl bg-black/40 border border-white/10 px-3 py-2 outline-none focus:ring-2 focus:ring-bt-accent/60"
{% if not can_post %}disabled aria-disabled="true"{% endif %}
{% if not user %}title="Sign in to post"{% elif not can_post %}title="Admins only"{% endif %}></textarea>
<div class="mt-3 flex items-center justify-between">
<div class="text-white/60 text-xs">
{% if not user %}Sign in to post.{% elif not can_post %}You dont have permission to post.{% endif %}
</div>
<button
id="sendBtn"
class="px-4 py-2 rounded-xl bg-bt-accent/90 text-black font-semibold shadow-glow disabled:opacity-50 disabled:cursor-not-allowed"
{% if not can_post %}disabled aria-disabled="true"{% endif %}>
Post
</button>
</div>
</div>
<!-- Member Status (Members & Admins) -->
{% if user %}
<div class="mt-4 rounded-2xl bg-white/5 border border-white/10 p-4">
<div class="flex items-center justify-between">
<h2 class="font-semibold">Your Status</h2>
<span class="text-xs text-white/60">Visible in Discord via webhook</span>
</div>
<textarea
id="statusBox" rows="2" maxlength="140" placeholder="What are you working on? (max 140)"
class="mt-2 w-full rounded-xl bg-black/40 border border-white/10 px-3 py-2 outline-none focus:ring-2 focus:ring-bt-accent/60"></textarea>
<div class="mt-3 flex items-center justify-between">
<div class="text-white/60 text-xs"><span id="statusCount">0</span>/140</div>
<button
id="statusBtn"
class="px-3 py-1.5 rounded-lg border border-white/20 hover:border-white/40 text-sm">
Update Status
</button>
</div>
</div>
{% endif %}
<!-- Messages -->
<div id="board" class="mt-6 space-y-3">
{% for m in messages %}
<article class="rounded-2xl bg-white/5 border border-white/10 p-4">
<div class="flex items-start gap-3">
{% if m.avatar %}
<img src="{{ m.avatar }}" alt="" class="size-9 rounded-lg">
{% else %}
<div class="size-9 rounded-lg bg-white/10 grid place-items-center" aria-hidden="true">🟣</div>
{% endif %}
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<div class="font-semibold">{{ m.username }}</div>
{% if m.timestamp %}
<div class="text-xs text-white/50">{{ m.timestamp | replace('T',' ') | replace('Z',' UTC') }}</div>
{% endif %}
</div>
<div class="mt-1 whitespace-pre-wrap text-white/90">{{ m.content }}</div>
</div>
</div>
</article>
{% endfor %}
</div>
<div class="mt-6 text-center">
<button id="refreshBtn" class="px-3 py-1.5 rounded-lg border border-white/20 hover:border-white/40 text-sm">Refresh</button>
</div>
</section>
<script>
const board = document.getElementById('board');
const sendBtn = document.getElementById('sendBtn');
const composer = document.getElementById('composer');
const refreshBtn = document.getElementById('refreshBtn');
const statusBox = document.getElementById('statusBox');
const statusBtn = document.getElementById('statusBtn');
const statusCount = document.getElementById('statusCount');
function escapeHtml(s){
return (s||'')
.replaceAll('&','&amp;')
.replaceAll('<','&lt;')
.replaceAll('>','&gt;');
}
async function fetchMessages(){
const r = await fetch('/api/board/messages');
if(!r.ok) return;
const data = await r.json();
board.innerHTML = data.map(m => `
<article class="rounded-2xl bg-white/5 border border-white/10 p-4">
<div class="flex items-start gap-3">
${m.avatar ? `<img src="${m.avatar}" class="size-9 rounded-lg" alt="">`
: `<div class="size-9 rounded-lg bg-white/10 grid place-items-center" aria-hidden="true">🟣</div>`}
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<div class="font-semibold">${escapeHtml(m.username)}</div>
${m.timestamp ? `<div class="text-xs text-white/50">${escapeHtml(m.timestamp.replace('T',' ').replace('Z',' UTC'))}</div>` : ``}
</div>
<div class="mt-1 whitespace-pre-wrap text-white/90">${escapeHtml(m.content)}</div>
</div>
</div>
</article>
`).join('');
}
async function postMessage(){
if (!sendBtn || sendBtn.hasAttribute('disabled')) return; // guard for non-admins
const content = composer.value.trim();
if(!content) return;
const r = await fetch('/api/board/post', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({content})
});
if(r.ok){
composer.value='';
fetchMessages();
}else{
const e = await r.json().catch(()=>({error:'Failed'}));
alert(e.error || 'Failed to post');
}
}
async function updateStatus(){
if (!statusBtn) return;
const status = statusBox.value.trim();
if(!status) return;
const r = await fetch('/api/me/status', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({status})
});
if(r.ok){
statusBox.value='';
if(statusCount) statusCount.textContent = '0';
alert('Status updated!');
}else{
const e = await r.json().catch(()=>({error:'Failed'}));
alert(e.error || 'Failed to update status');
}
}
sendBtn?.addEventListener('click', postMessage);
refreshBtn?.addEventListener('click', fetchMessages);
statusBtn?.addEventListener('click', updateStatus);
statusBox?.addEventListener('input', () => {
if(statusCount) statusCount.textContent = String(statusBox.value.length);
});
// initial + polling
fetchMessages();
setInterval(fetchMessages, 15000);
</script>
{% endblock %}

41
templates/contact.html Normal file
View File

@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block content %}
<section class="py-16 md:py-24">
<div class="max-w-4xl mx-auto px-6 grid md:grid-cols-2 gap-10 items-start">
<div>
<h1 class="font-bebas text-5xl">Contact</h1>
<div class="mt-6 glass rounded-2xl p-6 space-y-2">
<div class="text-xl font-semibold">{{ contact.name }}</div>
<div class="text-white/70">{{ contact.title }}</div>
<div class="text-white/80">{{ contact.city }}</div>
<div class="text-white/80">Hours: {{ contact.hours }}</div>
<div class="pt-4 flex flex-col gap-2 text-sm">
<a class="link" href="mailto:{{ contact.email }}">{{ contact.email }}</a>
<a class="link" href="tel:{{ contact.phone }}">{{ contact.phone }}</a>
<a class="link" href="{{ contact.cal }}">Book a call</a>
<a class="link" href="/benjamin.vcf">Download vCard</a>
<a class="link" href="{{ contact.link }}">LinkedIn</a>
</div>
</div>
</div>
<form action="https://formspree.io/f/your-id" method="POST" class="glass rounded-2xl p-6 space-y-4">
<div>
<label class="label">Name</label>
<input required name="name" class="input" placeholder="Jane Doe" />
</div>
<div>
<label class="label">Email</label>
<input required name="email" type="email" class="input" placeholder="you@company.com" />
</div>
<div>
<label class="label">What do you need?</label>
<textarea required name="message" rows="6" class="textarea" placeholder="Briefly describe your project..."></textarea>
</div>
<button class="btn-primary w-full">Send</button>
<p class="text-xs text-white/50">By sending, you consent to be contacted about this request.</p>
</form>
</div>
</section>
{% endblock %}

24
templates/events.html Normal file
View File

@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}Events — {{ brand }}{% endblock %}
{% block content %}
<section class="max-w-5xl mx-auto px-6 py-16">
<h1 class="text-4xl font-bold">Events</h1>
<p class="mt-2 text-white/75">Meetings, workshops, and client check-ins.</p>
<div class="mt-8 space-y-4">
{% if not events %}
<div class="text-white/70">No events yet. Check back soon.</div>
{% endif %}
{% for e in events %}
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
<div class="text-lg font-semibold">{{ e.title }}</div>
<div class="text-white/80">
{{ (e.start_dt or e.start)|string }}{% if e.location %} • {{ e.location }}{% endif %}
</div>
{% if e.desc %}<p class="mt-2 text-white/75">{{ e.desc }}</p>{% endif %}
{% if e.rsvp %}<a href="{{ e.rsvp }}" class="inline-block mt-3 underline hover:text-bt-accent">RSVP</a>{% endif %}
</div>
{% endfor %}
</div>
</section>
{% endblock %}

48
templates/index.html Normal file
View File

@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block content %}
<section class="relative overflow-hidden">
<div class="absolute inset-0 [background:radial-gradient(600px_circle_at_20%_20%,rgba(138,43,226,.12),transparent_60%),radial-gradient(400px_circle_at_80%_0%,rgba(59,130,246,.10),transparent_60%)]"></div>
<div class="relative max-w-7xl mx-auto px-6 py-20 sm:py-28">
<div class="max-w-3xl">
<h1 class="font-display tracking-wide text-5xl sm:text-7xl leading-[0.95]">
{{ brand }}<span class="text-bt-accent"> builds</span> for the community.
</h1>
<p class="mt-6 text-lg text-white/80 max-w-2xl">
{{ tagline }} From websites and dashboards to event tech and client projects, we learn by shipping.
</p>
{% if next_event %}
<div class="mt-8 rounded-2xl border border-bt-accent/30 bg-bt-accent/10 p-4 sm:p-5">
<div class="text-sm uppercase tracking-wide text-white/70">Next meeting</div>
<div class="mt-1 text-xl font-semibold">
{{ next_event.title }}
</div>
<div class="text-white/80">
{{ next_event.start_dt.strftime('%a, %b %d @ %I:%M %p') }} • {{ next_event.location }}
</div>
{% if next_event.rsvp %}<a class="inline-block mt-3 underline hover:text-bt-accent" href="{{ next_event.rsvp }}">RSVP</a>{% endif %}
</div>
{% endif %}
<div class="mt-10 flex gap-4">
<a href="{{ url_for('join') }}" class="px-5 py-3 rounded-xl bg-bt-accent/90 text-black font-semibold shadow-glow">Join BuffTEKS</a>
<a href="{{ url_for('projects') }}" class="px-5 py-3 rounded-xl border border-white/20 hover:border-white/40">See projects</a>
</div>
</div>
</div>
</section>
<section class="max-w-7xl mx-auto px-6 py-16 grid md:grid-cols-3 gap-6">
{% for blurb in [
{'t':'Real Clients','d':'Local orgs + campus teams. Small scope, real stakes.'},
{'t':'Hands-on Learning','d':'Version control, reviews, deployments, docs.'},
{'t':'Open Source','d':'We default to public repos and reusable templates.'}
] %}
<div class="rounded-2xl bg-white/5 border border-white/10 p-6">
<h3 class="text-xl font-semibold">{{ blurb.t }}</h3>
<p class="mt-2 text-white/75">{{ blurb.d }}</p>
</div>
{% endfor %}
</section>
{% endblock %}

29
templates/join.html Normal file
View File

@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}Join — {{ brand }}{% endblock %}
{% block content %}
<section class="max-w-3xl mx-auto px-6 py-16">
<h1 class="text-4xl font-bold">Join BuffTEKS</h1>
<p class="mt-2 text-white/75">Tell us a bit about you and what you want to build.</p>
<form method="post" class="mt-8 space-y-4 rounded-2xl bg-white/5 border border-white/10 p-6">
<div>
<label class="block text-sm text-white/70">Name</label>
<input name="name" required class="mt-1 w-full rounded-xl bg-black/40 border border-white/10 px-3 py-2 outline-none focus:ring-2 focus:ring-bt-accent/60">
</div>
<div>
<label class="block text-sm text-white/70">Email</label>
<input type="email" name="email" required class="mt-1 w-full rounded-xl bg-black/40 border border-white/10 px-3 py-2 outline-none focus:ring-2 focus:ring-bt-accent/60">
</div>
<div>
<label class="block text-sm text-white/70">What are you interested in?</label>
<textarea name="interest" rows="4" class="mt-1 w-full rounded-xl bg-black/40 border border-white/10 px-3 py-2 outline-none focus:ring-2 focus:ring-bt-accent/60" placeholder="Web, data, app dev, events…"></textarea>
</div>
<button class="px-5 py-3 rounded-xl bg-bt-accent/90 text-black font-semibold shadow-glow">Submit</button>
</form>
<div class="mt-8 text-white/70 text-sm">
Prefer Discord or email? Drop a line: <span class="underline">contact@buffteks.org</span>
</div>
</section>
{% endblock %}

67
templates/join_form.html Normal file
View File

@@ -0,0 +1,67 @@
{% extends "base.html" %}
{% block title %}BuffTEKS VIP Server Access — {{ brand }}{% endblock %}
{% block content %}
<div class="max-w-xl mx-auto card p-6">
<h1 class="text-2xl font-bold">BuffTEKS VIP Server Access</h1>
<p class="text-white/70 mt-1">
Hi <b>{{ user.username }}</b>! The <span class="font-semibold text-purple-400">BuffTEKS VIP Server</span> is our private collaboration space for active members.
</p>
<p class="mt-2 text-white/60 text-sm">
To gain access, youll: <b>1)</b> join BuffTEKS, <b>2)</b> perform the
<span class="font-semibold text-purple-400">Git Commit Ritual</span>, and <b>3)</b> commit to a project team.
</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mt-3 space-y-2">
{% for cat,msg in messages %}
<div class="rounded-lg px-3 py-2 text-sm {{ 'bg-red-500/20 border border-red-400/40' if cat=='error' else 'bg-white/10 border border-white/20' }}">{{ msg }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="post" class="mt-4 grid gap-3">
<input type="hidden" name="next" value="{{ next_url }}" />
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-white/60">First name</label>
<input name="first_name" value="{{ first_name or '' }}" class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2" required />
</div>
<div>
<label class="text-xs text-white/60">Last name</label>
<input name="last_name" value="{{ last_name or '' }}" class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2" required />
</div>
</div>
<div>
<label class="text-xs text-white/60">Major</label>
<input name="major" value="{{ major or '' }}" class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2" required />
</div>
<div>
<label class="text-xs text-white/60">Student Email</label>
<input type="email" name="student_email" value="{{ student_email or '' }}" class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2" placeholder="you@buffs.wtamu.edu" required />
</div>
<div>
<label class="text-xs text-white/60">Which BuffTEKS project/team are you joining?</label>
<input name="commitment" value="{{ commitment or '' }}" class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2" placeholder="Web Dev, Outreach, AI Research, Infrastructure…" required />
</div>
<div>
<label class="text-xs text-white/60">Describe your energy in a single commit message (optional)</label>
<input name="commit_message" value="{{ commit_message or '' }}" class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2" placeholder='feat: ready to ship greatness 🚀' />
</div>
<button class="btn-accent mt-2">Request VIP Access</button>
</form>
<pre class="mt-4 text-xs text-white/40 bg-black/30 rounded-xl p-3 overflow-auto">
$ git add me
$ git commit -m "{{ commit_message or 'chore: joined BuffTEKS, ready to contribute' }}"
$ git push origin greatness
</pre>
</div>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Thanks — {{ brand }}{% endblock %}
{% block content %}
<div class="max-w-xl mx-auto card p-6 text-center">
<h1 class="text-2xl font-bold">Thanks!</h1>
<p class="text-white/70 mt-1">Your request has been submitted. A BuffTEKS officer will contact you soon.</p>
<div class="mt-4">
<a href="{{ url_for('tickets') }}" class="btn">Back to Home</a>
</div>
</div>
{% endblock %}

42
templates/login.html Normal file
View File

@@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block content %}
<section class="min-h-[60vh] grid place-items-center">
<div class="w-full max-w-md card glass p-8">
<h1 class="text-2xl font-bold mb-2">Admin Sign In</h1>
<p class="text-white/70 mb-6 text-sm">Enter your credentials to access the dashboard.</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="space-y-2 mb-4">
{% for category, msg in messages %}
<div class="p-3 rounded border {{ 'border-red-400 text-red-300' if category=='error' else 'border-green-400 text-green-300' }}">{{ msg }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="post" action="{{ url_for('admin_login') }}" class="space-y-4">
<input type="hidden" name="_csrf" value="{{ csrf }}">
<input type="hidden" name="next" value="{{ next }}">
<div>
<label for="username">Username</label>
<input id="username" name="username" required type="text" class="w-full mt-1" placeholder="admin" autocomplete="username">
</div>
<div>
<label for="password">Password</label>
<input id="password" name="password" required type="password" class="w-full mt-1" placeholder="••••••••" autocomplete="current-password">
</div>
<label class="flex items-center gap-2 text-sm text-white/80">
<input type="checkbox" name="remember">
<span>Remember me for 30 days</span>
</label>
<button class="btn bg-accent font-semibold w-full" type="submit">Sign in</button>
</form>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,41 @@
<!-- Email to Admin: New Request Summary -->
<table width="100%" cellpadding="0" cellspacing="0" style="font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial; color:#0f172a; background:#f8fafc; padding:24px;">
<tr>
<td align="center">
<table width="640" cellpadding="0" cellspacing="0" style="background:#ffffff; border:1px solid #e5e7eb; border-radius:12px;">
<tr>
<td style="padding:24px; border-bottom:1px solid #e5e7eb;">
<h1 style="margin:0; font-size:20px;">New Quote Request</h1>
<p style="margin:4px 0 0; color:#6b7280; font-size:14px;">{{ payload.name }} ({{ payload.email }})</p>
</td>
</tr>
<tr>
<td style="padding:24px;">
<h2 style="font-size:16px; margin:0 0 8px;">Project Summary</h2>
<ul style="margin:0 0 16px; padding-left:18px; color:#374151; font-size:14px;">
<li><strong>Type:</strong> {{ payload.project_type }}</li>
<li><strong>Complexity:</strong> {{ payload.complexity }}</li>
<li><strong>Urgency:</strong> {{ payload.urgency }}</li>
<li><strong>Features:</strong> {{ payload.features|join(', ') if payload.features else 'None' }}</li>
<li><strong>Budget:</strong> {{ payload.budget_range or 'n/a' }}</li>
</ul>
<h3 style="font-size:14px; margin:0 0 6px; color:#111827;">Description</h3>
<p style="margin:0 0 16px; color:#374151; white-space:pre-line;">{{ payload.description or '—' }}</p>
<h3 style="font-size:14px; margin:0 0 6px; color:#111827;">Auto-Estimate</h3>
<p style="margin:0; color:#111827; font-size:14px;">
~{{ est_hours }} hours @ ${{ '%.2f'|format(hourly_rate) }}/hr → <strong>${{ '%.2f'|format(est_cost) }}</strong>
</p>
</td>
</tr>
<tr>
<td style="padding:16px 24px; background:#f8fafc; border-top:1px solid #e5e7eb; color:#6b7280; font-size:12px;">
<a href="{{ base_url }}/admin?p={{ config('ADMIN_DASH_PASSWORD', 'changeme') if false else '' }}" style="color:#2563eb;text-decoration:underline;">Open Admin</a>
&nbsp;&nbsp; <a href="{{ base_url }}/preview-client-email" style="color:#2563eb;text-decoration:underline;">Preview Client Email Template</a>
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

@@ -0,0 +1,7 @@
<footer class="border-t border-white/10">
<div class="max-w-7xl mx-auto px-6 py-10 flex flex-col sm:flex-row items-center justify-between gap-4">
<p class="text-white/70">© {{ brand }} {{ now().year if false else '' }}</p>
<div class="text-sm text-white/60">Made with Flask + Tailwind.</div>
</div>
</footer>

View File

@@ -0,0 +1,18 @@
<header class="sticky top-0 z-50 border-b border-white/10 bg-black/60 backdrop-blur">
<div class="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<a href="{{ url_for('home') }}" class="flex items-center gap-3">
<div class="size-8 rounded-lg bg-white text-black grid place-items-center font-bold">BT</div>
<span class="font-display tracking-wider text-2xl">{{ brand }}</span>
</a>
<nav class="hidden md:flex items-center gap-6 text-sm">
<a class="hover:text-bt-accent" href="{{ url_for('projects') }}">Projects</a>
<a class="hover:text-bt-accent" href="{{ url_for('events') }}">Events</a>
<a class="hover:text-bt-accent" href="{{ url_for('team') }}">Team</a>
<a class="hover:text-bt-accent" href="{{ url_for('sponsors') }}">Sponsors</a>
<a class="hover:text-bt-accent" href="{{ url_for('view_board') }}">Board</a>
<a class="px-4 py-2 rounded-xl bg-bt-accent/20 border border-bt-accent/40 hover:bg-bt-accent/30" href="{{ url_for('join') }}">Join</a>
</nav>
</div>
</header>

20
templates/projects.html Normal file
View File

@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}Projects — {{ brand }}{% endblock %}
{% block content %}
<section class="max-w-7xl mx-auto px-6 py-16">
<h1 class="text-4xl font-bold">Projects</h1>
<p class="mt-2 text-white/75">A rotating sample of what members ship.</p>
<div class="mt-8 grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
{% for p in projects %}
<article class="rounded-2xl bg-white/5 border border-white/10 p-5">
<h3 class="text-xl font-semibold">{{ p.title }}</h3>
<p class="mt-2 text-white/80">{{ p.blurb }}</p>
<div class="mt-3 flex flex-wrap gap-2">
{% for tag in p.tags %}<span class="text-xs px-2 py-1 rounded bg-white/10 border border-white/10">{{ tag }}</span>{% endfor %}
</div>
</article>
{% endfor %}
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,55 @@
<!-- Email to Client: Professional Quote Template -->
<table width="100%" cellpadding="0" cellspacing="0" style="font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial; color:#0f172a; background:#f5f5f5; padding:24px;">
<tr>
<td align="center">
<table width="640" cellpadding="0" cellspacing="0" style="background:#ffffff; border:1px solid #e5e7eb; border-radius:12px; overflow:hidden;">
<tr>
<td style="background:#111827; color:#fff; padding:24px;">
<h1 style="margin:0; font-size:20px;">Your Project Quote</h1>
<p style="margin:6px 0 0; color:#e5e7eb; font-size:14px;">{{ company_name }}</p>
</td>
</tr>
<tr>
<td style="padding:24px;">
<p style="margin:0 0 12px;">Hi {{ client_name }},</p>
<p style="margin:0 0 12px;">Thanks for reaching out! Heres our estimate for <strong>{{ project_title }}</strong>.</p>
<table width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb; border-radius:8px; margin:16px 0;">
<tr>
<td style="padding:12px; font-size:14px; border-bottom:1px solid #e5e7eb;"><strong>Overview</strong></td>
</tr>
<tr>
<td style="padding:12px; color:#374151; font-size:14px;">{{ proposal_summary }}</td>
</tr>
<tr>
<td style="padding:12px; font-size:14px; border-top:1px solid #e5e7eb;">
Estimated Hours: <strong>{{ est_hours }}</strong> &nbsp;|&nbsp; Hourly Rate: <strong>${{ '%.2f'|format(hourly_rate|float) }}</strong> &nbsp;&nbsp;
Estimated Cost: <strong>${{ '%.2f'|format(est_cost|float) }}</strong>
</td>
</tr>
</table>
<p style="margin:0 0 12px; color:#374151; font-size:14px;">
This quote is valid until <strong>{{ valid_until }}</strong>. If everything looks good, the next step is to reply to this email or visit the link below so we can finalize scope and timeline.
</p>
{% if next_steps_url %}
<p style="margin:16px 0;">
<a href="{{ next_steps_url }}" style="display:inline-block; background:#fde047; color:#111827; padding:10px 16px; border-radius:8px; text-decoration:none; font-weight:600;">Proceed</a>
</p>
{% endif %}
<p style="margin:12px 0 0; color:#6b7280; font-size:12px;">
Questions? Reach us at <a href="mailto:{{ contact_email }}" style="color:#2563eb;">{{ contact_email }}</a>.
</p>
</td>
</tr>
<tr>
<td style="background:#111827; color:#9ca3af; padding:16px; font-size:12px;">
© {{ 2025 }} {{ company_name }}. All rights reserved.
</td>
</tr>
</table>
</td>
</tr>
</table>

67
templates/services.html Normal file
View File

@@ -0,0 +1,67 @@
{% extends "base.html" %}
{% block content %}
<section class="py-16 md:py-24">
<div class="max-w-6xl mx-auto px-6">
<header class="max-w-3xl">
<h1 class="font-bebas text-5xl">Services</h1>
<p class="mt-4 text-white/80">From local sysadmin to modern web, we tailor the stack to your constraints and budget — including refurbpowered discounts to keep costs sensible.</p>
</header>
<div class="mt-10 grid md:grid-cols-2 gap-6">
<article class="card">
<h3 class="card-title">Local System Administration</h3>
<p class="card-body">Onsite/remote support for small businesses and individuals. Proxmox clusters, Windows Server/Active Directory, Linux, backups, VLANs, DNS, VPN, and monitoring.</p>
<ul class="card-list">
<li>User & identity management (AD)</li>
<li>Secure file servers (Samba/NFS)</li>
<li>Automated backups & recovery drills</li>
</ul>
</article>
<article class="card">
<h3 class="card-title">Web Development & Hosting</h3>
<p class="card-body">Flask/Node apps or static sites with Tailwind frontends. SSL, backups, staging, wildcard subdomains — and Stripe/domain upsells when youre ready.</p>
<ul class="card-list">
<li>New builds or refactors</li>
<li>Observability baked in</li>
<li>Fast, accessible UI</li>
</ul>
</article>
<article class="card">
<h3 class="card-title">App Deployment & Integrations</h3>
<p class="card-body">Want a custom Obsidian server for your office? Need a selfhosted wiki, dashboard, or existing tool stood up securely? We handle the infra, auth, and logistics.</p>
<ul class="card-list">
<li>Install, configure, and harden</li>
<li>Singlesignon options</li>
<li>Backups & updates scheduled</li>
</ul>
</article>
<article class="card">
<h3 class="card-title">Refurb & Recycle Discounts</h3>
<p class="card-body">Lower your quote by letting us refurb quality used hardware into dependable local servers. Burnin tests and warranty options included.</p>
<ul class="card-list">
<li>Costeffective nodes</li>
<li>Green and budgetfriendly</li>
<li>Onsite support options</li>
</ul>
</article>
<article class="card md:col-span-2">
<h3 class="card-title">Enterprise via BrookHaven</h3>
<p class="card-body">If you need largerscale rollouts, compliance, or crosssite coordination, we can deliver through our enterprise channel (BrookHaven) while keeping execution pragmatic.</p>
<div class="mt-4">
<a href="/contact" class="btn-primary">Tell us what you need</a>
<a href="/about" class="btn-ghost ml-2">Learn about us</a>
</div>
</article>
</div>
</div>
</section>
{% endblock %}

25
templates/sponsors.html Normal file
View File

@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Sponsors — {{ brand }}{% endblock %}
{% block content %}
<section class="max-w-7xl mx-auto px-6 py-16">
<div class="flex items-end justify-between gap-6">
<div>
<h1 class="text-4xl font-bold">Sponsors</h1>
<p class="mt-2 text-white/75">Support student-led tech for the Panhandle.</p>
</div>
<a href="{{ url_for('join') }}" class="hidden sm:inline-block px-4 py-2 rounded-xl border border-white/20 hover:border-white/40">Get in touch</a>
</div>
{% for tier, companies in tiers.items() %}
<h2 class="mt-10 mb-4 text-2xl font-semibold">{{ tier }}</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-6">
{% for c in companies %}
<a href="{{ c.url }}" class="aspect-[3/2] rounded-2xl border border-white/10 bg-white/5 grid place-items-center">
<img src="{{ c.logo }}" alt="{{ c.name }}" class="max-h-10 opacity-80">
</a>
{% endfor %}
</div>
{% endfor %}
</section>
{% endblock %}

21
templates/team.html Normal file
View File

@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}Team — {{ brand }}{% endblock %}
{% block content %}
<section class="max-w-6xl mx-auto px-6 py-16">
<h1 class="text-4xl font-bold">Team</h1>
<p class="mt-2 text-white/75">Officers & advisors.</p>
<div class="mt-8 grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
{% for o in officers %}
<div class="rounded-2xl bg-white/5 border border-white/10 p-5">
<div class="text-xl font-semibold">{{ o.name }}</div>
<div class="text-white/80">{{ o.role }}</div>
<div class="mt-3 flex gap-3 text-sm">
{% if o.links.github %}<a class="underline hover:text-bt-accent" href="{{ o.links.github }}">GitHub</a>{% endif %}
{% if o.links.linkedin %}<a class="underline hover:text-bt-accent" href="{{ o.links.linkedin }}">LinkedIn</a>{% endif %}
</div>
</div>
{% endfor %}
</div>
</section>
{% endblock %}

8
templates/thanks.html Normal file
View File

@@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block content %}
<div class="card glass p-10 text-center grid place-items-center">
<div class="size-14 rounded-full bg-accent grid place-items-center text-black font-black text-2xl mb-4"></div>
<h1 class="text-2xl font-bold mb-2">Thanks! We received your request.</h1>
<p class="text-white/70">Well review your requirements and email you a quote soon.</p>
</div>
{% endblock %}

View File

@@ -0,0 +1,153 @@
{% extends "base.html" %}
{% block title %}#{{ t.id }} — {{ t.title }} · {{ brand }}{% endblock %}
{% block content %}
<div class="grid lg:grid-cols-3 gap-6">
<section class="lg:col-span-2 card p-5">
<header class="flex items-start justify-between gap-3">
<div>
<h1 class="text-2xl font-bold">#{{ t.id }} · {{ t.title }}</h1>
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs">
<span class="tag {{ status_class(t.status) }}">{{ t.status.replace('_',' ').title() }}</span>
<span class="tag {{ priority_class(t.priority) }}">Priority: {{ t.priority }}</span>
{% if t.sprint %}<span class="tag">Sprint: {{ t.sprint }}</span>{% endif %}
{% for label in t.label_list() %}<span class="tag">{{ label }}</span>{% endfor %}
</div>
</div>
{% if can_manage %}
<a class="btn" href="{{ url_for('admin_edit_ticket', ticket_id=t.id) }}">Edit</a>
{% endif %}
</header>
<article class="prose prose-invert max-w-none mt-4">
<p class="whitespace-pre-wrap">{{ t.description }}</p>
</article>
{% set done,total = checklist_progress(t) %}
{% if total %}
<hr class="my-5 border-white/10" />
<h2 class="font-semibold">Checklist</h2>
<div class="mt-2">
<div class="flex items-center justify-between text-xs text-white/60">
<span>Progress</span><span>{{ done }}/{{ total }}</span>
</div>
<div class="h-2 bg-white/10 rounded-full mt-1 overflow-hidden">
<div class="h-2 bg-emerald-500/70" style="width: {{ (done/total*100)|round(0) }}%"></div>
</div>
</div>
<ul class="mt-3 space-y-2 text-sm">
{% for i in t.checklist_items() %}
<li class="flex items-start gap-2">
<span class="mt-1 inline-block w-3 h-3 rounded border {{ 'bg-emerald-500/80 border-emerald-400/70' if i.checked else 'border-white/30' }}"></span>
<span class="{{ 'text-white' if i.checked else 'text-white/80' }}">{{ i.text }}</span>
</li>
{% endfor %}
</ul>
{% endif %}
<hr class="my-5 border-white/10" />
<h2 class="font-semibold">Comments</h2>
<div id="comments" class="mt-3 space-y-3">
{% for c in t.comments %}
<div class="card p-3">
<div class="text-sm"><b>{{ c.author_name }}</b> <span class="text-white/50">· {{ reltime(c.created_at) }}</span></div>
<div class="mt-1 whitespace-pre-wrap">{{ c.body }}</div>
</div>
{% else %}
<div class="text-white/60">No comments yet.</div>
{% endfor %}
</div>
{% if user %}
<div class="mt-4">
<textarea id="commentBox" rows="3" class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2" placeholder="Write a comment…"></textarea>
<div class="mt-2 flex justify-end"><button id="commentBtn" class="btn-accent">Post Comment</button></div>
</div>
{% endif %}
</section>
<aside class="card p-5 space-y-4">
<div>
<h3 class="font-semibold">Assignee</h3>
<p class="mt-1 text-white/80">{{ t.assignee_name or (t.assignee_id and ('<@' ~ t.assignee_id ~ '>')) or 'Unassigned' }}</p>
{% if can_manage %}
<div class="mt-3">
<label class="text-xs text-white/60">Discord ID</label>
<input id="assigneeId" value="{{ t.assignee_id or '' }}" class="w-full bg-black/40 border border-white/10 rounded-lg px-2 py-1.5" />
<label class="text-xs text-white/60 mt-2 block">Display Name</label>
<input id="assigneeName" value="{{ t.assignee_name or '' }}" class="w-full bg-black/40 border border-white/10 rounded-lg px-2 py-1.5" />
<button id="assignBtn" class="btn-accent mt-2 w-full">Assign</button>
</div>
{% endif %}
</div>
<div>
<h3 class="font-semibold">Dates</h3>
<div class="text-xs text-white/60 mt-1 space-y-1">
<div>Created: {{ t.created_at.strftime('%Y-%m-%d %H:%M') }} UTC ({{ reltime(t.created_at) }})</div>
<div>Updated: {{ t.updated_at.strftime('%Y-%m-%d %H:%M') }} UTC ({{ reltime(t.updated_at) }})</div>
{% if t.due_at %}<div class="text-red-200">Due: {{ t.due_at.strftime('%Y-%m-%d') }} ({{ reltime(t.due_at) }})</div>{% endif %}
</div>
</div>
<div>
<h3 class="font-semibold">Meta</h3>
<div class="text-xs text-white/70 mt-1 space-y-1">
<div>Priority: <span class="tag {{ priority_class(t.priority) }}">{{ t.priority }}</span></div>
{% if t.points is not none %}<div>Points: {{ t.points }}</div>{% endif %}
{% if t.sprint %}<div>Sprint: {{ t.sprint }}</div>{% endif %}
{% set labels = t.label_list() %}
{% if labels %}
<div class="flex flex-wrap gap-1 items-center">
<span>Labels:</span>
{% for label in labels %}<span class="tag">{{ label }}</span>{% endfor %}
</div>
{% endif %}
</div>
</div>
</aside>
</div>
<script>
const ticketId = {{ t.id }};
const commentBtn = document.getElementById('commentBtn');
const commentBox = document.getElementById('commentBox');
const comments = document.getElementById('comments');
const assignBtn = document.getElementById('assignBtn');
const assigneeId = document.getElementById('assigneeId');
const assigneeName = document.getElementById('assigneeName');
function esc(s){return (s||'').replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;');}
commentBtn?.addEventListener('click', async () => {
const body = commentBox.value.trim();
if(!body) return;
const r = await fetch(`/api/tickets/${ticketId}/comment`, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({body})});
const data = await r.json();
if(!r.ok){ alert(data.error||'Failed'); return; }
commentBox.value='';
const c = data.comment;
const el = document.createElement('div');
el.className='card p-3';
el.innerHTML = `<div class="text-sm"><b>${esc(c.author_name)}</b> <span class="text-white/50">· ${(new Date(c.created_at)).toISOString().slice(0,16).replace('T',' ')} UTC</span></div><div class="mt-1 whitespace-pre-wrap">${esc(c.body)}</div>`;
comments.prepend(el);
});
assignBtn?.addEventListener('click', async () => {
const payload = { assignee_id: assigneeId.value.trim(), assignee_name: assigneeName.value.trim() };
const r = await fetch(`/api/tickets/${ticketId}/assign`, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload)});
const data = await r.json();
if(!r.ok){ alert(data.error||'Failed'); return; }
location.reload();
});
document.querySelectorAll('[data-status]')?.forEach(btn => {
btn.addEventListener('click', async () => {
const status = btn.dataset.status;
const r = await fetch(`/api/tickets/${ticketId}/status`, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({status})});
const data = await r.json();
if(!r.ok){ alert(data.error||'Failed'); return; }
location.reload();
});
});
</script>
{% endblock %}

130
templates/tickets.html Normal file
View File

@@ -0,0 +1,130 @@
{% extends "base.html" %}
{% block title %}Tickets — {{ brand }}{% endblock %}
{% block content %}
<h1 class="text-3xl font-bold">Tickets</h1>
<p class="text-white/60 text-sm">{{ tagline }}</p>
{# ---------------- Filter Panel (dark) ---------------- #}
<section class="mt-5 rounded-2xl overflow-hidden border shadow-lg"
style="border-color: color-mix(in oklab, var(--bt-accent) 35%, transparent);">
<div class="h-1.5" style="background: var(--bt-accent);"></div>
<div class="bg-slate-900/60 backdrop-blur-sm text-white p-4 sm:p-5">
<h2 class="text-base font-semibold flex items-center gap-2">
<span class="inline-block w-2.5 h-2.5 rounded-full" style="background: var(--bt-accent);"></span>
Filter & Search
</h2>
<form method="get" class="mt-3 grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<label class="text-xs text-white/60">Status</label>
<select name="status"
class="block w-full bg-slate-900/70 text-white border border-white/15 rounded-lg px-2 py-2
focus:outline-none focus:ring-2"
style="--tw-ring-color: color-mix(in oklab, var(--bt-accent) 60%, transparent);">
<option value="">Any</option>
{% for s in ['submitted','triage','in_progress','awaiting_review','done','blocked','needs_more_info','cancelled'] %}
<option value="{{s}}" {{ 'selected' if request.args.get('status')==s else '' }}>{{ s.replace('_',' ').title() }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="text-xs text-white/60">Assignee (Discord ID)</label>
<input name="assignee" value="{{ request.args.get('assignee','') }}"
class="w-full bg-slate-900/70 text-white placeholder-white/40 border border-white/15 rounded-lg px-3 py-2
focus:outline-none focus:ring-2"
style="--tw-ring-color: color-mix(in oklab, var(--bt-accent) 60%, transparent);"
placeholder="1234567890" />
</div>
<div class="md:col-span-2">
<label class="text-xs text-white/60">Search</label>
<div class="flex gap-2">
<input name="q" value="{{ request.args.get('q','') }}"
class="w-full bg-slate-900/70 text-white placeholder-white/40 border border-white/15 rounded-lg px-3 py-2
focus:outline-none focus:ring-2"
style="--tw-ring-color: color-mix(in oklab, var(--bt-accent) 60%, transparent);"
placeholder="title, description, labels…" />
<button type="submit"
class="px-4 py-2 rounded-lg border border-white/15"
style="background: var(--bt-accent); color:#0a0a0a;">Apply</button>
</div>
</div>
</form>
</div>
</section>
{# ---------------- Card helpers ---------------- #}
{% macro status_badge(s) -%}
{% set label = s.replace('_',' ').title() %}
{% if s == 'done' %}
<span class="px-2 py-0.5 text-xs rounded-full bg-emerald-500/15 text-emerald-300 border border-emerald-400/30">{{ label }}</span>
{% elif s in ['blocked','cancelled'] %}
<span class="px-2 py-0.5 text-xs rounded-full bg-rose-500/15 text-rose-300 border border-rose-400/30">{{ label }}</span>
{% elif s in ['awaiting_review','needs_more_info'] %}
<span class="px-2 py-0.5 text-xs rounded-full bg-amber-500/15 text-amber-300 border border-amber-400/30">{{ label }}</span>
{% elif s in ['in_progress','triage'] %}
<span class="px-2 py-0.5 text-xs rounded-full bg-sky-500/15 text-sky-300 border border-sky-400/30">{{ label }}</span>
{% else %}
<span class="px-2 py-0.5 text-xs rounded-full bg-white/10 text-white/80 border border-white/20">{{ label }}</span>
{% endif %}
{%- endmacro %}
{% macro priority_chip(p) -%}
{% if p == 'urgent' %}
<span class="tag bg-rose-500/20 border-rose-400/30 text-rose-200">Priority: Urgent</span>
{% elif p == 'high' %}
<span class="tag bg-amber-500/20 border-amber-400/30 text-amber-100">Priority: High</span>
{% elif p == 'low' %}
<span class="tag bg-slate-500/20 border-slate-400/30 text-slate-200">Priority: Low</span>
{% else %}
<span class="tag">Priority: Normal</span>
{% endif %}
{%- endmacro %}
{# ---------------- Ticket Grid ---------------- #}
<div class="mt-6 grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-5">
{% for t in tickets %}
<a href="{{ url_for('ticket_detail', ticket_id=t.id) }}"
class="group relative block rounded-2xl border border-white/10 bg-gradient-to-b from-white/5 to-white/[0.02] p-4 sm:p-5
shadow-[0_10px_30px_-10px_rgba(0,0,0,.4)] hover:border-white/20 hover:shadow-[0_18px_40px_-12px_rgba(0,0,0,.6)] transition">
<span class="pointer-events-none absolute inset-0 rounded-2xl ring-1 ring-transparent group-hover:ring-[color:var(--bt-accent)]/40"></span>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h3 class="font-semibold text-base sm:text-lg leading-snug truncate">
<span class="text-white/50">#{{ t.id }}</span> · {{ t.title }}
</h3>
<div class="mt-1 text-xs text-white/60">Opened by {{ t.created_by_name or t.created_by_id or 'Unknown' }}</div>
</div>
{{ status_badge(t.status) }}
</div>
<p class="mt-3 text-white/85 text-sm sm:text-[0.95rem]"
style="display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;">
{{ t.description }}
</p>
<div class="mt-3 flex flex-wrap items-center gap-2 text-xs">
{{ priority_chip(t.priority) }}
{% if t.sprint %}<span class="tag">Sprint: {{ t.sprint }}</span>{% endif %}
{% if t.due_at %}<span class="tag bg-rose-500/15 border-rose-400/30 text-rose-200">Due: {{ t.due_at.strftime('%Y-%m-%d') }}</span>{% endif %}
{% for label in t.label_list() %}<span class="tag">{{ label }}</span>{% endfor %}
</div>
<div class="mt-4 flex items-center justify-between text-xs text-white/60">
<div>
{% if t.assignee_name or t.assignee_id %}
Assigned to <b class="text-white/80">{{ t.assignee_name or ('<@' ~ t.assignee_id ~ '>') }}</b>
{% else %}
<span class="text-white/40 italic">Unassigned</span>
{% endif %}
</div>
<div class="whitespace-nowrap">Updated {{ t.updated_at.strftime('%Y-%m-%d %H:%M') }} UTC</div>
</div>
</a>
{% else %}
<div class="text-white/60">No tickets found.</div>
{% endfor %}
</div>
{% endblock %}