this is bennys version bitch
This commit is contained in:
14
app.py
Normal file
14
app.py
Normal 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
|
||||
|
||||
|
||||
44
templates/admin_ticket_edit.html
Normal file
44
templates/admin_ticket_edit.html
Normal 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 %}
|
||||
38
templates/admin_ticket_new.html
Normal file
38
templates/admin_ticket_new.html
Normal 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
80
templates/base.html
Normal 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
68
templates/join_form.html
Normal 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, you’ll: <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 %}
|
||||
"""
|
||||
11
templates/join_thanks.html
Normal file
11
templates/join_thanks.html
Normal 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 %}
|
||||
121
templates/ticket_detail.html
Normal file
121
templates/ticket_detail.html
Normal 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('&','&').replaceAll('<','<').replaceAll('>','>');}
|
||||
|
||||
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
57
templates/tickets.html
Normal 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 %}
|
||||
|
||||
Reference in New Issue
Block a user