Compare commits

1 Commits

Author SHA1 Message Date
Ben Mosley
68a6eae50b this is bennys version bitch 2025-11-30 22:23:48 -06:00
8 changed files with 433 additions and 0 deletions

14
app.py Normal file
View File

@@ -0,0 +1,14 @@
from __future__ import annotations
import os, json, time, requests
from datetime import datetime
from pathlib import Path
from typing import Optional
from flask import (
Flask, render_template, request, redirect, url_for, flash, session, jsonify, abort
)
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import case
from dotenv import load_dotenv

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 %}

80
templates/base.html Normal file
View File

@@ -0,0 +1,80 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}{{ brand }} · Hub{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
:root { --bt-accent: {{ accent }}; }
.bg-bt { background: radial-gradient(1200px 600px at 20% -20%, rgba(138,43,226,0.25), transparent 60%); }
.btn { @apply inline-flex items-center justify-center px-4 py-2 rounded-xl border border-white/20 hover:border-white/40 text-sm sm:text-[0.95rem]; }
.btn-block { @apply w-full sm:w-auto; }
.card { @apply rounded-2xl bg-white/5 border border-white/10; }
.accent { color: var(--bt-accent); }
.btn-accent { background: var(--bt-accent); color:#0a0a0a; @apply font-semibold rounded-xl px-4 py-2; }
.tag { @apply text-xs px-2 py-0.5 rounded border border-white/20 bg-white/5; }
@media (hover: hover) {.btn:hover { filter: brightness(1.05); }}
</style>
</head>
<body class="bg-slate-950 text-white min-h-screen">
{% set is_admin = (session.get('discord_user') and 'admin' in session.get('discord_user',{}).get('site_roles',[])) %}
<header class="bg-bt border-b border-white/10">
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-4 sm:py-5">
<div class="flex items-center justify-between">
<a href="/" class="text-lg sm:text-xl font-extrabold tracking-tight">{{ brand }}</a>
<!-- Desktop nav -->
<nav class="hidden md:flex items-center gap-2 text-sm">
<a href="{{ url_for('tickets') }}" class="btn">Tickets</a>
{% if is_admin %}
<a href="{{ url_for('admin_new_ticket') }}" class="btn">New Ticket</a>
{% if 'admin_join_requests' in current_app.view_functions %}
<a href="{{ url_for('admin_join_requests') }}" class="btn">VIP Requests</a>
{% endif %}
{% endif %}
{% if session.get('discord_user') %}
<span class="text-white/70 text-xs sm:text-sm">Signed in as <b>{{ session['discord_user']['username'] }}</b></span>
<a class="btn" href="{{ url_for('logout') }}">Log out</a>
{% else %}
<a class="btn-accent" href="{{ url_for('discord_login') }}">Sign in with Discord</a>
{% endif %}
</nav>
<!-- Mobile hamburger -->
<button id="navToggle" class="md:hidden btn" aria-label="Open menu" aria-expanded="false"></button>
</div>
<!-- Mobile menu -->
<nav id="mobileMenu" class="md:hidden hidden mt-3 grid gap-2">
<a href="{{ url_for('tickets') }}" class="btn btn-block">Tickets</a>
{% if is_admin %}
<a href="{{ url_for('admin_new_ticket') }}" class="btn btn-block">New Ticket</a>
{% if 'admin_join_requests' in current_app.view_functions %}
<a href="{{ url_for('admin_join_requests') }}" class="btn btn-block">VIP Requests</a>
{% endif %}
{% endif %}
{% if session.get('discord_user') %}
<span class="text-white/70 text-sm">Signed in as <b>{{ session['discord_user']['username'] }}</b></span>
<a class="btn btn-block" href="{{ url_for('logout') }}">Log out</a>
{% else %}
<a class="btn-accent btn-block" href="{{ url_for('discord_login') }}">Sign in with Discord</a>
{% endif %}
</nav>
</div>
</header>
<main class="max-w-6xl mx-auto px-4 sm:px-6 py-6 sm:py-8">
{% block content %}{% endblock %}
</main>
<script>
const t = document.getElementById('navToggle');
const m = document.getElementById('mobileMenu');
t?.addEventListener('click', () => {
const isOpen = !m.classList.contains('hidden');
m.classList.toggle('hidden');
t.setAttribute('aria-expanded', String(!isOpen));
});
</script>
</body>
</html>

68
templates/join_form.html Normal file
View File

@@ -0,0 +1,68 @@
JOIN_FORM_HTML = r"""{% 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-1 sm: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-3 text-base" 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-3 text-base" 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-3 text-base" required />
</div>
<div>
<label class="text-xs text-white/60">Student Email</label>
<input type="email" inputmode="email" autocomplete="email" name="student_email" value="{{ student_email or '' }}" class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-3 text-base" 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-3 text-base" 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-3 text-base" placeholder='feat: ready to ship greatness 🚀' />
</div>
<button class="btn-accent btn-block 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 VIP 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 %}

View File

@@ -0,0 +1,121 @@
TICKET_DETAIL_HTML = r"""{% 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 text-white/60">
<span class="tag">{{ t.status.replace('_',' ').title() }}</span>
<span class="tag">Priority: {{ t.priority }}</span>
{% 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>
<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">· {{ c.created_at.strftime('%Y-%m-%d %H:%M') }} UTC</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="4" class="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-sm sm:text-base" placeholder="Write a comment…"></textarea>
<div class="mt-2 flex justify-end"><button id="commentBtn" class="btn-accent btn-block sm:btn">Post Comment</button></div>
</div>
{% endif %}
</section>
<aside class="card p-5">
<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 sm:w-auto">Assign</button>
</div>
{% endif %}
<h3 class="font-semibold mt-6">Status</h3>
<div class="mt-2 grid grid-cols-2 sm:grid-cols-4 gap-2">
{% for s in ['open','in_progress','done','cancelled'] %}
<button class="btn {% if t.status==s %}border-white/60{% endif %} btn-block" data-status="{{s}}" {% if not can_update_status %}disabled{% endif %}>
{{ s.replace('_',' ').title() }}
</button>
{% endfor %}
</div>
<div class="text-xs text-white/50 mt-6">
<div>Created: {{ t.created_at.strftime('%Y-%m-%d %H:%M') }} UTC</div>
<div>Updated: {{ t.updated_at.strftime('%Y-%m-%d %H:%M') }} UTC</div>
<div>By: {{ t.created_by_name or t.created_by_id }}</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/{{ t.id }}/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 %}
"""

57
templates/tickets.html Normal file
View File

@@ -0,0 +1,57 @@
{% 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>
<form method="get" class="mt-4 flex flex-wrap items-end gap-3">
<div>
<label class="text-xs text-white/60">Status</label>
<select name="status" class="block bg-black/40 border border-white/10 rounded-lg px-2 py-1.5">
<option value="">Any</option>
{% for s in ['open','in_progress','done','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="bg-black/40 border border-white/10 rounded-lg px-2 py-1.5" placeholder="1234567890" />
</div>
<div class="flex-1 min-w-[200px]">
<label class="text-xs text-white/60">Search</label>
<input name="q" value="{{ request.args.get('q','') }}" class="w-full bg-black/40 border border-white/10 rounded-lg px-2 py-1.5" placeholder="title/description…" />
</div>
<button class="btn">Apply</button>
</form>
<div class="mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{% for t in tickets %}
<a href="{{ url_for('ticket_detail', ticket_id=t.id) }}" class="card p-4 sm:p-5 block hover:border-white/20">
<div class="flex items-start justify-between gap-3">
<h3 class="font-semibold text-base sm:text-lg leading-snug">#{{t.id}} · {{ t.title }}</h3>
<span class="tag whitespace-nowrap">{{ t.status.replace('_',' ').title() }}</span>
</div>
<!-- Line clamp fallback (3 lines) -->
<p class="mt-2 text-white/80 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 text-white/60">
<span class="tag">Priority: {{ t.priority }}</span>
{% for label in t.label_list() %}<span class="tag">{{ label }}</span>{% endfor %}
</div>
{% if t.assignee_name or t.assignee_id %}
<div class="mt-3 text-sm text-white/70">Assigned to: <b>{{ t.assignee_name or ('<@' ~ t.assignee_id ~ '>') }}</b></div>
{% endif %}
<div class="mt-2 text-xs text-white/50">Updated {{ t.updated_at.strftime('%Y-%m-%d %H:%M') }} UTC</div>
</a>
{% else %}
<div class="text-white/60">No tickets found.</div>
{% endfor %}
</div>
{% endblock %}