Initial Commit
This commit is contained in:
76
.gitignore
vendored
Normal file
76
.gitignore
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
# ----------------------------
|
||||
# Python / Flask .gitignore
|
||||
# ----------------------------
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
.venv/
|
||||
.env/
|
||||
.venv*/
|
||||
env.*/
|
||||
venv.*/
|
||||
|
||||
# Environment & secrets
|
||||
*.env
|
||||
.env.*
|
||||
.envrc
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# Flask instance folder (per-app config, uploads, etc.)
|
||||
instance/
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
*.out
|
||||
*.err
|
||||
|
||||
# PyTest / coverage
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
|
||||
# MyPy / type checking
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
|
||||
# Jupyter
|
||||
.ipynb_checkpoints/
|
||||
|
||||
# OS metadata
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE / Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
*.code-workspace
|
||||
|
||||
# Werkzeug debug pin file
|
||||
*.pid
|
||||
|
||||
# Cached static builds (if you do asset builds)
|
||||
node_modules/
|
||||
*.map
|
||||
|
||||
5
config.py
Normal file
5
config.py
Normal file
@@ -0,0 +1,5 @@
|
||||
import os
|
||||
class Config:
|
||||
SECRET_KEY = os.getenv("APP_SECRET_KEY", "dev")
|
||||
SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", "sqlite:///portal.db")
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
9
core/__init_.py
Normal file
9
core/__init_.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from flask import Blueprint, render_template
|
||||
|
||||
|
||||
core_bp = Blueprint("core", __name__)
|
||||
|
||||
|
||||
@core_bp.get("/")
|
||||
def home():
|
||||
return render_template("core/home.html")
|
||||
10
core/__init__.py
Normal file
10
core/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
# /var/www/bennysboard/core/__init__.py
|
||||
from flask import Blueprint, render_template
|
||||
|
||||
core_bp = Blueprint("core", __name__)
|
||||
|
||||
@core_bp.get("/")
|
||||
def home():
|
||||
# Renders /var/www/bennysboard/core/templates/core/home.html
|
||||
return render_template("core/home.html")
|
||||
|
||||
127
core/auth.py
Normal file
127
core/auth.py
Normal file
@@ -0,0 +1,127 @@
|
||||
# /var/www/bennysboard/core/auth.py
|
||||
import os
|
||||
import requests
|
||||
from functools import wraps
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, session, flash
|
||||
from .models import db, User
|
||||
|
||||
auth_bp = Blueprint("auth", __name__, template_folder="templates")
|
||||
|
||||
# ---------- session helpers ----------
|
||||
def current_user():
|
||||
uid = session.get("uid")
|
||||
return User.query.get(uid) if uid else None
|
||||
|
||||
def login_user(user: User):
|
||||
session["uid"] = user.id
|
||||
session.permanent = True
|
||||
|
||||
def logout_user():
|
||||
session.pop("uid", None)
|
||||
|
||||
# ---------- decorators ----------
|
||||
def require_login(view):
|
||||
@wraps(view)
|
||||
def _wrap(*a, **k):
|
||||
if not current_user():
|
||||
return redirect(url_for("auth.login", next=request.path))
|
||||
return view(*a, **k)
|
||||
return _wrap
|
||||
|
||||
def require_perms(*perms):
|
||||
def deco(view):
|
||||
@wraps(view)
|
||||
def _wrap(*a, **k):
|
||||
u = current_user()
|
||||
if not u:
|
||||
return redirect(url_for("auth.login", next=request.path))
|
||||
if not any(u.has_perm(p) for p in perms):
|
||||
flash("You don’t have permission to view that.", "error")
|
||||
return redirect(url_for("core.home"))
|
||||
return view(*a, **k)
|
||||
return _wrap
|
||||
return deco
|
||||
|
||||
# ---------- local login ----------
|
||||
@auth_bp.get("/login")
|
||||
def login():
|
||||
return render_template("core/login.html", next=request.args.get("next", "/"))
|
||||
|
||||
@auth_bp.post("/login")
|
||||
def login_post():
|
||||
username = request.form.get("username", "")
|
||||
password = request.form.get("password", "")
|
||||
nxt = request.form.get("next") or url_for("core.home")
|
||||
|
||||
u = User.query.filter((User.email == username) | (User.username == username)).first()
|
||||
if not u or not u.check_password(password):
|
||||
flash("Invalid credentials", "error")
|
||||
return redirect(url_for("auth.login", next=nxt))
|
||||
|
||||
login_user(u)
|
||||
return redirect(nxt)
|
||||
|
||||
@auth_bp.post("/logout")
|
||||
@require_login
|
||||
def logout():
|
||||
logout_user()
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
# ---------- Discord OAuth (optional) ----------
|
||||
@auth_bp.get("/discord")
|
||||
def discord_start():
|
||||
cid = os.getenv("DISCORD_CLIENT_ID", "")
|
||||
redir = os.getenv("DISCORD_REDIRECT_URI", "http://localhost:5000/auth/discord/callback")
|
||||
scope = "identify"
|
||||
return redirect(
|
||||
"https://discord.com/oauth2/authorize"
|
||||
f"?client_id={cid}&response_type=code&redirect_uri={requests.utils.quote(redir)}&scope={scope}&prompt=none"
|
||||
)
|
||||
|
||||
@auth_bp.get("/discord/callback")
|
||||
def discord_cb():
|
||||
code = request.args.get("code")
|
||||
if not code:
|
||||
flash("Discord login failed.", "error")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
data = {
|
||||
"client_id": os.getenv("DISCORD_CLIENT_ID"),
|
||||
"client_secret": os.getenv("DISCORD_CLIENT_SECRET"),
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": os.getenv("DISCORD_REDIRECT_URI"),
|
||||
}
|
||||
tok = requests.post(
|
||||
"https://discord.com/api/v10/oauth2/token",
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=10,
|
||||
)
|
||||
if tok.status_code != 200:
|
||||
flash("Discord login failed.", "error")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
access_token = tok.json().get("access_token")
|
||||
me = requests.get(
|
||||
"https://discord.com/api/v10/users/@me",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
if me.status_code != 200:
|
||||
flash("Discord login failed.", "error")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
d = me.json()
|
||||
discord_id = d["id"]
|
||||
uname = d.get("global_name") or d.get("username") or f"user{discord_id[-4:]}"
|
||||
|
||||
u = User.query.filter_by(discord_id=discord_id).first()
|
||||
if not u:
|
||||
u = User(username=uname, discord_id=discord_id)
|
||||
db.session.add(u)
|
||||
db.session.commit()
|
||||
|
||||
login_user(u)
|
||||
return redirect(url_for("core.home"))
|
||||
|
||||
64
core/models.py
Normal file
64
core/models.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# /var/www/bennysboard/core/models.py
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from datetime import datetime
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
user_roles = db.Table(
|
||||
"user_roles",
|
||||
db.Column("user_id", db.Integer, db.ForeignKey("users.id"), primary_key=True),
|
||||
db.Column("role_id", db.Integer, db.ForeignKey("roles.id"), primary_key=True),
|
||||
)
|
||||
|
||||
role_perms = db.Table(
|
||||
"role_permissions",
|
||||
db.Column("role_id", db.Integer, db.ForeignKey("roles.id"), primary_key=True),
|
||||
db.Column("perm_id", db.Integer, db.ForeignKey("permissions.id"), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
class User(db.Model):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
email = db.Column(db.String(255), unique=True, index=True)
|
||||
username = db.Column(db.String(80), unique=True)
|
||||
password_h = db.Column(db.String(255)) # nullable for Discord-only accounts
|
||||
discord_id = db.Column(db.String(40), index=True)
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
roles = db.relationship("Role", secondary=user_roles, back_populates="users")
|
||||
|
||||
def set_password(self, raw: str) -> None:
|
||||
self.password_h = generate_password_hash(raw)
|
||||
|
||||
def check_password(self, raw: str) -> bool:
|
||||
return bool(self.password_h) and check_password_hash(self.password_h, raw)
|
||||
|
||||
def has_perm(self, code: str) -> bool:
|
||||
return any(code in r.perm_codes() for r in self.roles)
|
||||
|
||||
|
||||
class Role(db.Model):
|
||||
__tablename__ = "roles"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(80), unique=True)
|
||||
|
||||
users = db.relationship("User", secondary=user_roles, back_populates="roles")
|
||||
permissions = db.relationship("Permission", secondary=role_perms, back_populates="roles")
|
||||
|
||||
def perm_codes(self) -> set[str]:
|
||||
return {p.code for p in self.permissions}
|
||||
|
||||
|
||||
class Permission(db.Model):
|
||||
__tablename__ = "permissions"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
code = db.Column(db.String(120), unique=True, index=True)
|
||||
|
||||
roles = db.relationship("Role", secondary=role_perms, back_populates="permissions")
|
||||
|
||||
95
core/templates/core/base.html
Normal file
95
core/templates/core/base.html
Normal file
@@ -0,0 +1,95 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
||||
<!-- ✅ Viewport: prevent weird zoom/scaling on iOS -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
|
||||
<!-- iOS WebApp & theme tweaks -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="theme-color" content="#0b0d16" />
|
||||
|
||||
<title>{% block title %}Benny Portal{% endblock %}</title>
|
||||
|
||||
<!-- ✅ Tailwind -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- ✅ Your custom styles -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}" />
|
||||
|
||||
<style>
|
||||
/* Fix Safari overscroll bounce & height issues */
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overflow-x: hidden;
|
||||
background-color: #0b0d16;
|
||||
}
|
||||
|
||||
header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
padding-top: env(safe-area-inset-top, constant(safe-area-inset-top, 0));
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
|
||||
|
||||
main {
|
||||
padding-bottom: 4rem; /* avoids cutoff on iPhone bottom bar */
|
||||
}
|
||||
|
||||
/* Optional: mobile tweaks */
|
||||
nav a {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
nav a:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-neutral-950 text-neutral-100 antialiased">
|
||||
|
||||
<header class="border-b border-white/10 bg-black/60 backdrop-blur">
|
||||
<div class="max-w-7xl mx-auto px-4 py-3 flex items-center gap-4">
|
||||
<a class="font-bold text-lg" href="/">Benny’s Portal</a>
|
||||
<nav class="text-sm flex gap-3 overflow-x-auto whitespace-nowrap">
|
||||
<a href="/board/">Intercom</a>
|
||||
<a href="/quotes/">Quotes</a>
|
||||
<a href="/publish/">Publish Once</a>
|
||||
<a href="/memos/">Memos</a>
|
||||
</nav>
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
{% if session.uid %}
|
||||
<form method="post" action="{{ url_for('auth.logout') }}">
|
||||
<button class="px-3 py-1.5 rounded-lg border border-white/20">Logout</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<a class="px-3 py-1.5 rounded-lg bg-accent text-black font-semibold" href="{{ url_for('auth.login') }}">Login</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-7xl mx-auto px-4 py-8">
|
||||
{% with msgs = get_flashed_messages(with_categories=true) %}
|
||||
{% for cat, m in msgs %}
|
||||
<div class="mb-4 rounded border px-3 py-2 {{ 'border-emerald-400 bg-emerald-500/10' if cat=='ok' else 'border-red-400 bg-red-500/10' }}">
|
||||
{{ m }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
22
core/templates/core/home.html
Normal file
22
core/templates/core/home.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% block title %}Home — Portal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<a href="/board/" class="card glass p-6 block">
|
||||
<h2 class="font-semibold text-lg">Discord Intercom</h2>
|
||||
<p class="text-white/70 text-sm">Read the channel, post (admins), status updates.</p>
|
||||
</a>
|
||||
<a href="/quotes/" class="card glass p-6 block">
|
||||
<h2 class="font-semibold text-lg">Quotes</h2>
|
||||
<p class="text-white/70 text-sm">Public estimator + admin review.</p>
|
||||
</a>
|
||||
<a href="/publish/" class="card glass p-6 block">
|
||||
<h2 class="font-semibold text-lg">Publish Once</h2>
|
||||
<p class="text-white/70 text-sm">Compose once, copy everywhere.</p>
|
||||
</a>
|
||||
<a href="/memos/" class="card glass p-6 block">
|
||||
<h2 class="font-semibold text-lg">Memos & Notes</h2>
|
||||
<p class="text-white/70 text-sm">Personal memos, notes, journal.</p>
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
24
core/templates/core/login.html
Normal file
24
core/templates/core/login.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% block title %}Login — Portal{% endblock %}
|
||||
{% block content %}
|
||||
<section class="max-w-md mx-auto">
|
||||
<div class="card glass p-6">
|
||||
<h1 class="text-2xl font-bold">Sign in</h1>
|
||||
<form method="post" action="{{ url_for('auth.login') }}" class="mt-4 space-y-4">
|
||||
<input type="hidden" name="next" value="{{ next or '/' }}">
|
||||
<div>
|
||||
<label class="text-sm text-white/70">Email or username</label>
|
||||
<input name="username" class="w-full mt-1" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-white/70">Password</label>
|
||||
<input type="password" name="password" class="w-full mt-1" required>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="btn bg-accent font-semibold" type="submit">Login</button>
|
||||
<a class="text-sm underline" href="{{ url_for('auth.discord_start') }}">Sign in with Discord</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
41
freeze.py
Normal file
41
freeze.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# freeze.py
|
||||
import os, shutil, warnings
|
||||
from flask_frozen import Freezer, MimetypeMismatchWarning
|
||||
from app import app
|
||||
|
||||
warnings.filterwarnings("ignore", category=MimetypeMismatchWarning)
|
||||
|
||||
# ─── set explicit build directory ───────────────────────────────
|
||||
BUILD_DIR = "/var/www/bennysboard/build"
|
||||
|
||||
if os.path.exists(BUILD_DIR):
|
||||
shutil.rmtree(BUILD_DIR)
|
||||
|
||||
app.config.update(
|
||||
SERVER_NAME="bennysboard.local",
|
||||
PREFERRED_URL_SCHEME="https",
|
||||
FREEZER_DESTINATION=BUILD_DIR, # <── add this
|
||||
FREEZER_IGNORE_MIMETYPE_WARNINGS=True,
|
||||
FREEZER_REMOVE_EXTRA_FILES=True,
|
||||
FREEZER_FLAT_URLS=True,
|
||||
)
|
||||
|
||||
freezer = Freezer(app)
|
||||
|
||||
def manual_routes():
|
||||
from flask import url_for
|
||||
with app.app_context():
|
||||
yield url_for("index")
|
||||
yield url_for("login")
|
||||
yield url_for("info_page")
|
||||
|
||||
def only_manual_routes(self):
|
||||
for route in manual_routes():
|
||||
yield type("Page", (), {"url": route})()
|
||||
|
||||
freezer.freeze_yield = only_manual_routes.__get__(freezer, Freezer)
|
||||
|
||||
if __name__ == "__main__":
|
||||
freezer.freeze()
|
||||
print(f"\n✅ Freeze complete — static site built in {BUILD_DIR}\n")
|
||||
|
||||
2
modules/board/__init__.py
Normal file
2
modules/board/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from flask import Blueprint
|
||||
board_bp = Blueprint("board", __name__, template_folder="templates")
|
||||
52
modules/board/routes.py
Normal file
52
modules/board/routes.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import os, time, requests
|
||||
GUILD = os.getenv("DISCORD_GUILD_ID","")
|
||||
CHANNEL = os.getenv("DISCORD_CHANNEL_ID","")
|
||||
WEBHOOK = os.getenv("DISCORD_WEBHOOK_URL","")
|
||||
|
||||
|
||||
_cache = {"ts":0, "messages":[]}
|
||||
TTL = 10
|
||||
|
||||
|
||||
def _headers():
|
||||
if not BOT: raise RuntimeError("Missing DISCORD_BOT_TOKEN")
|
||||
return {"Authorization": f"Bot {BOT}"}
|
||||
|
||||
|
||||
@board_bp.get("/")
|
||||
@require_perms("board.view")
|
||||
def index():
|
||||
return render_template("board/index.html")
|
||||
|
||||
|
||||
@board_bp.get("/api/messages")
|
||||
@require_perms("board.view")
|
||||
def api_messages():
|
||||
now = time.time()
|
||||
if now - _cache["ts"] < TTL and _cache["messages"]:
|
||||
return jsonify(_cache["messages"])
|
||||
url = f"{DISCORD_API}/channels/{CHANNEL}/messages"
|
||||
r = requests.get(url, headers=_headers(), params={"limit":40}, timeout=10)
|
||||
msgs = []
|
||||
if r.status_code == 200:
|
||||
for m in reversed(r.json()):
|
||||
a = m.get("author", {})
|
||||
msgs.append({
|
||||
"id": m.get("id"),
|
||||
"content": m.get("content",""),
|
||||
"username": a.get("global_name") or a.get("username","user"),
|
||||
"timestamp": m.get("timestamp"),
|
||||
})
|
||||
_cache.update({"ts":now, "messages":msgs})
|
||||
return jsonify(msgs)
|
||||
|
||||
|
||||
@board_bp.post("/api/post")
|
||||
@require_perms("board.post")
|
||||
def api_post():
|
||||
data = request.get_json(force=True)
|
||||
content = (data.get("content") or "").strip()
|
||||
if not content: return jsonify({"ok":False,"error":"Empty"}),400
|
||||
if not WEBHOOK: return jsonify({"ok":False,"error":"No webhook"}),500
|
||||
r = requests.post(WEBHOOK, json={"content": content[:1800]}, timeout=10)
|
||||
return (jsonify({"ok":True}), 200) if r.status_code in (200,204) else (jsonify({"ok":False}), 502)
|
||||
41
modules/board/templates/board/index.html
Normal file
41
modules/board/templates/board/index.html
Normal file
@@ -0,0 +1,41 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% block title %}Intercom — Portal{% endblock %}
|
||||
{% block content %}
|
||||
<section class="max-w-4xl mx-auto">
|
||||
<header class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-2xl font-bold">Discord Intercom</h1>
|
||||
<button id="refresh" class="btn">Refresh</button>
|
||||
</header>
|
||||
<div id="list" class="space-y-3"></div>
|
||||
|
||||
|
||||
<div class="mt-6 card glass p-4">
|
||||
<textarea id="composer" rows="3" class="w-full"></textarea>
|
||||
<div class="mt-2 text-right"><button id="send" class="btn bg-accent font-semibold">Post</button></div>
|
||||
</div>
|
||||
</section>
|
||||
<script>
|
||||
async function load(){
|
||||
const r = await fetch('/board/api/messages');
|
||||
const data = await r.json();
|
||||
const root = document.getElementById('list');
|
||||
root.innerHTML = data.map(m=>`
|
||||
<article class="card glass p-4">
|
||||
<div class="text-sm text-white/60">${(m.timestamp||'').replace('T',' ').replace('Z',' UTC')}</div>
|
||||
<div class="font-semibold">${m.username||'user'}</div>
|
||||
<div class="mt-1 whitespace-pre-wrap">${m.content||''}</div>
|
||||
</article>`).join('');
|
||||
}
|
||||
async function post(){
|
||||
const v = document.getElementById('composer').value.trim();
|
||||
if(!v) return;
|
||||
const r = await fetch('/board/api/post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:v})});
|
||||
if(r.ok){ document.getElementById('composer').value=''; load(); }
|
||||
else alert('Not allowed or failed');
|
||||
}
|
||||
load();
|
||||
setInterval(load,15000);
|
||||
refresh.onclick = load;
|
||||
send.onclick = post;
|
||||
</script>
|
||||
{% endblock %}
|
||||
2
modules/memos/__init__.py
Normal file
2
modules/memos/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from flask import Blueprint
|
||||
memos_bp = Blueprint("memos", __name__, template_folder="templates")
|
||||
27
modules/memos/models.py
Normal file
27
modules/memos/models.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from core.models import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Memo(db.Model):
|
||||
__tablename__ = "memos"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
memo = db.Column(db.Text, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class Note(db.Model):
|
||||
__tablename__ = "notes"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(255), unique=True, nullable=False)
|
||||
slug = db.Column(db.String(255), unique=True, nullable=False)
|
||||
note = db.Column(db.Text, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class Journal(db.Model):
|
||||
__tablename__ = "journal"
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(255), unique=True, nullable=False)
|
||||
slug = db.Column(db.String(255), unique=True, nullable=False)
|
||||
entry = db.Column(db.Text, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
70
modules/memos/routes.py
Normal file
70
modules/memos/routes.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from flask import render_template, request, redirect, url_for
|
||||
from . import memos_bp
|
||||
from .models import db, Memo, Note, Journal
|
||||
from core.auth import require_perms
|
||||
|
||||
|
||||
@memos_bp.get("/")
|
||||
@require_perms("memos.read")
|
||||
def index():
|
||||
memos = Memo.query.order_by(Memo.created_at.desc()).all()
|
||||
return render_template("memos/index.html", memos=memos)
|
||||
|
||||
|
||||
@memos_bp.post("/add")
|
||||
@require_perms("memos.write")
|
||||
def add():
|
||||
v = (request.form.get("memo") or "").strip()
|
||||
if v:
|
||||
db.session.add(Memo(memo=v)); db.session.commit()
|
||||
return redirect(url_for("memos.index"))
|
||||
|
||||
|
||||
@memos_bp.get("/notes")
|
||||
@require_perms("memos.read")
|
||||
def notes():
|
||||
notes = Note.query.order_by(Note.created_at.desc()).all()
|
||||
return render_template("memos/notes.html", notes=notes)
|
||||
|
||||
|
||||
@memos_bp.post("/notes/new")
|
||||
@require_perms("memos.write")
|
||||
def notes_new():
|
||||
name = (request.form.get("name") or "").strip()
|
||||
text = (request.form.get("note") or "").strip()
|
||||
if name:
|
||||
slug = name.lower().replace(" ","-")
|
||||
db.session.add(Note(name=name, slug=slug, note=text)); db.session.commit()
|
||||
return redirect(url_for("memos.notes"))
|
||||
|
||||
|
||||
@memos_bp.get("/notes/<slug>")
|
||||
@require_perms("memos.read")
|
||||
def view_note(slug):
|
||||
n = Note.query.filter_by(slug=slug).first_or_404()
|
||||
return render_template("memos/view_note.html", note=n)
|
||||
|
||||
|
||||
@memos_bp.get("/journal")
|
||||
@require_perms("memos.read")
|
||||
def journal():
|
||||
entries = Journal.query.order_by(Journal.created_at.desc()).all()
|
||||
return render_template("memos/journal.html", journal=entries)
|
||||
|
||||
|
||||
@memos_bp.post("/journal/new")
|
||||
@require_perms("memos.write")
|
||||
def journal_new():
|
||||
name = (request.form.get("name") or "").strip()
|
||||
text = (request.form.get("entry") or "").strip()
|
||||
if name:
|
||||
slug = name.lower().replace(" ","-")
|
||||
db.session.add(Journal(name=name, slug=slug, entry=text)); db.session.commit()
|
||||
return redirect(url_for("memos.journal"))
|
||||
|
||||
|
||||
@memos_bp.get("/journal/<slug>")
|
||||
@require_perms("memos.read")
|
||||
def view_journal(slug):
|
||||
e = Journal.query.filter_by(slug=slug).first_or_404()
|
||||
return render_template("memos/view_journal.html", entry=e)
|
||||
18
modules/memos/templates/memos/index.html
Normal file
18
modules/memos/templates/memos/index.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% block title %}Memos — Portal{% endblock %}
|
||||
{% block content %}
|
||||
<section class="max-w-3xl mx-auto">
|
||||
<div class="card glass p-6">
|
||||
<h1 class="text-2xl font-bold">Memos</h1>
|
||||
<form method="post" action="/memos/add" class="mt-3 flex gap-2">
|
||||
<input name="memo" class="flex-1" placeholder="New memo…">
|
||||
<button class="btn">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="mt-6 space-y-3">
|
||||
{% for m in memos %}
|
||||
<article class="card glass p-4"><div class="text-sm text-white/60">{{ m.created_at }}</div><div class="mt-1">{{ m.memo }}</div></article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
22
modules/memos/templates/memos/journal.html
Normal file
22
modules/memos/templates/memos/journal.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% block title %}Journal — Portal{% endblock %}
|
||||
{% block content %}
|
||||
<section class="max-w-4xl mx-auto grid md:grid-cols-2 gap-6">
|
||||
<div class="card glass p-6">
|
||||
<h1 class="text-xl font-semibold">New Entry</h1>
|
||||
<form method="post" action="/memos/journal/new" class="mt-3 grid gap-3">
|
||||
<input name="name" placeholder="Entry title" required>
|
||||
<textarea name="entry" rows="8" placeholder="What’s up…"></textarea>
|
||||
<button class="btn bg-accent font-semibold">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
{% for e in journal %}
|
||||
<a href="/memos/journal/{{ e.slug }}" class="block card glass p-4">
|
||||
<div class="text-sm text-white/60">{{ e.created_at }}</div>
|
||||
<div class="font-semibold">{{ e.name }}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
22
modules/memos/templates/memos/notes.html
Normal file
22
modules/memos/templates/memos/notes.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% block title %}Notes — Portal{% endblock %}
|
||||
{% block content %}
|
||||
<section class="max-w-4xl mx-auto grid md:grid-cols-2 gap-6">
|
||||
<div class="card glass p-6">
|
||||
<h1 class="text-xl font-semibold">New Note</h1>
|
||||
<form method="post" action="/memos/notes/new" class="mt-3 grid gap-3">
|
||||
<input name="name" placeholder="Note title" required>
|
||||
<textarea name="note" rows="6" placeholder="Text…"></textarea>
|
||||
<button class="btn bg-accent font-semibold">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
{% for n in notes %}
|
||||
<a href="/memos/notes/{{ n.slug }}" class="block card glass p-4">
|
||||
<div class="text-sm text-white/60">{{ n.created_at }}</div>
|
||||
<div class="font-semibold">{{ n.name }}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
8
modules/memos/templates/memos/view_journal.html
Normal file
8
modules/memos/templates/memos/view_journal.html
Normal file
@@ -0,0 +1,8 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% block title %}{{ entry.name }} — Journal{% endblock %}
|
||||
{% block content %}
|
||||
<article class="max-w-3xl mx-auto card glass p-6">
|
||||
<h1 class="text-2xl font-bold">{{ entry.name }}</h1>
|
||||
<div class="mt-3 whitespace-pre-wrap">{{ entry.entry }}</div>
|
||||
</article>
|
||||
{% endblock %}
|
||||
8
modules/memos/templates/memos/view_note.html
Normal file
8
modules/memos/templates/memos/view_note.html
Normal file
@@ -0,0 +1,8 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% block title %}{{ note.name }} — Note{% endblock %}
|
||||
{% block content %}
|
||||
<article class="max-w-3xl mx-auto card glass p-6">
|
||||
<h1 class="text-2xl font-bold">{{ note.name }}</h1>
|
||||
<div class="mt-3 whitespace-pre-wrap">{{ note.note }}</div>
|
||||
</article>
|
||||
{% endblock %}
|
||||
2
modules/publish/__init__.py
Normal file
2
modules/publish/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from flask import Blueprint
|
||||
publish_bp = Blueprint("publish", __name__, template_folder="templates")
|
||||
9
modules/publish/routes.py
Normal file
9
modules/publish/routes.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from flask import render_template
|
||||
from . import publish_bp
|
||||
from core.auth import require_perms
|
||||
|
||||
|
||||
@publish_bp.get("/")
|
||||
@require_perms("publish.use")
|
||||
def index():
|
||||
return render_template("publish/index.html")
|
||||
51
modules/publish/templates/publish/index.html
Normal file
51
modules/publish/templates/publish/index.html
Normal file
@@ -0,0 +1,51 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% block title %}Publish Once — Portal{% endblock %}
|
||||
{% block content %}
|
||||
<section class="grid lg:grid-cols-3 gap-6">
|
||||
<div class="lg:col-span-2 card glass p-6">
|
||||
<h1 class="text-xl font-semibold">Compose</h1>
|
||||
<div class="mt-3 grid gap-3">
|
||||
<input id="title" placeholder="Title" class="w-full">
|
||||
<input id="hero" placeholder="Hero image URL (optional)" class="w-full">
|
||||
<input id="cta" placeholder="Canonical URL" class="w-full">
|
||||
<textarea id="body" rows="8" placeholder="Write your story…" class="w-full"></textarea>
|
||||
<input id="tags" placeholder="Tags (comma)" class="w-full">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<aside class="space-y-4">
|
||||
<div class="card glass p-4">
|
||||
<h2 class="font-semibold">Blog Front‑Matter</h2>
|
||||
<button class="btn mt-2" onclick="copy('front')">Copy</button>
|
||||
<pre id="front" class="mt-2 text-xs"></pre>
|
||||
</div>
|
||||
<div class="card glass p-4">
|
||||
<h2 class="font-semibold">Facebook (Group/Page)</h2>
|
||||
<button class="btn mt-2" onclick="copy('fb')">Copy</button>
|
||||
<pre id="fb" class="mt-2 text-xs"></pre>
|
||||
</div>
|
||||
<div class="card glass p-4">
|
||||
<h2 class="font-semibold">Instagram / TikTok</h2>
|
||||
<button class="btn mt-2" onclick="copy('ig')">Copy</button>
|
||||
<pre id="ig" class="mt-2 text-xs"></pre>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
<script>
|
||||
function slugify(t){return (t||'').toLowerCase().trim().replace(/[^a-z0-9\s-]/g,'').replace(/\s+/g,'-').replace(/-+/g,'-')}
|
||||
function yaml(s){return '"'+String(s||'').replaceAll('"','\\"')+'"'}
|
||||
function utm(u,src){try{const x=new URL(u);x.searchParams.set('utm_source',src);x.searchParams.set('utm_medium','social');x.searchParams.set('utm_campaign','portal');return x+''}catch(e){return u}}
|
||||
async function copy(id){const t=document.getElementById(id).innerText; await navigator.clipboard.writeText(t)}
|
||||
function render(){
|
||||
const title=t.value, body=bd.value, hero=h.value, cta=c.value, tags=tg.value
|
||||
const ex=(body||'').replace(/\s+/g,' ').trim(); const short=ex.length<=160?ex:ex.slice(0,159)+'…'
|
||||
const slug=slugify(title||'post')
|
||||
front.textContent = `---\ntitle: ${yaml(title)}\nslug: ${slug}\nhero: ${yaml(hero)}\ntags: [${(tags||'').split(',').map(s=>s.trim()).filter(Boolean).map(x=>yaml(x)).join(', ')}]\nexcerpt: ${yaml(short)}\n---`
|
||||
fb.textContent = `${title}\n\n${short}\n\nRead more → ${utm(cta,'facebook')}`
|
||||
const hashtags=(tags||'').split(',').map(s=>s.trim()).filter(Boolean).map(x=>'#'+slugify(x)).slice(0,12).join(' ')
|
||||
ig.textContent = `${title}\n\n${short}\n\n${hashtags}\n\nLink: ${utm(cta,'instagram')}`
|
||||
}
|
||||
const t=title, h=hero, c=cta, bd=body, tg=tags; [t,h,c,bd,tg].forEach(el=>el.addEventListener('input',render)); render()
|
||||
</script>
|
||||
{% endblock %}
|
||||
2
modules/quotes/__init__.py
Normal file
2
modules/quotes/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from flask import Blueprint
|
||||
quotes_bp = Blueprint("quotes", __name__, template_folder="templates")
|
||||
26
modules/quotes/routes.py
Normal file
26
modules/quotes/routes.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
from . import quotes_bp
|
||||
from core.auth import require_perms
|
||||
|
||||
|
||||
@quotes_bp.get("/")
|
||||
def index():
|
||||
return render_template("quotes/index.html")
|
||||
|
||||
|
||||
@quotes_bp.post("/estimate")
|
||||
def estimate():
|
||||
name = request.form.get("name","")
|
||||
email = request.form.get("email","")
|
||||
need = request.form.get("need","not-sure")
|
||||
size = request.form.get("size","small")
|
||||
hours = {"simple":10,"pro":18,"custom":28}.get(need,8) * {"small":1,"medium":1.4,"large":2}.get(size,1)
|
||||
cost = round(hours*95,2)
|
||||
flash(f"Estimated {hours:.1f}h — ${cost}", "ok")
|
||||
return redirect(url_for("quotes.index"))
|
||||
|
||||
|
||||
@quotes_bp.get("/admin")
|
||||
@require_perms("quotes.admin")
|
||||
def admin():
|
||||
return render_template("quotes/admin.html")
|
||||
6
modules/quotes/templates/quotes/admin.html
Normal file
6
modules/quotes/templates/quotes/admin.html
Normal file
@@ -0,0 +1,6 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% block title %}Quotes Admin — Portal{% endblock %}
|
||||
{% block content %}
|
||||
<h1 class="text-2xl font-bold mb-4">Quotes Admin</h1>
|
||||
<p class="text-white/70">Wire your DB-backed list here later.</p>
|
||||
{% endblock %}
|
||||
24
modules/quotes/templates/quotes/index.html
Normal file
24
modules/quotes/templates/quotes/index.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% block title %}Quotes — Portal{% endblock %}
|
||||
{% block content %}
|
||||
<section class="max-w-3xl mx-auto">
|
||||
<div class="card glass p-6">
|
||||
<h1 class="text-2xl font-bold">Quick Estimate</h1>
|
||||
<form method="post" action="/quotes/estimate" class="mt-4 grid gap-3">
|
||||
<input name="name" placeholder="Your name" required>
|
||||
<input name="email" type="email" placeholder="Email" required>
|
||||
<select name="need">
|
||||
<option value="simple">Simple site</option>
|
||||
<option value="pro">Pro site</option>
|
||||
<option value="custom">Custom app</option>
|
||||
</select>
|
||||
<select name="size">
|
||||
<option value="small">Small</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="large">Large</option>
|
||||
</select>
|
||||
<button class="btn bg-accent font-semibold">Estimate</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Flask==3.0.3
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
Werkzeug==3.0.3
|
||||
python-dotenv==1.0.1
|
||||
requests==2.32.3
|
||||
PyMySQL==1.1.1
|
||||
58
seed_roles.py
Normal file
58
seed_roles.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import os
|
||||
from app import create_app
|
||||
from core.models import db, User, Role, Permission
|
||||
|
||||
|
||||
PERMS = [
|
||||
"board.view", "board.post",
|
||||
"quotes.view", "quotes.admin",
|
||||
"publish.use",
|
||||
"memos.read", "memos.write", "memos.admin",
|
||||
]
|
||||
|
||||
|
||||
ROLE_MAP = {
|
||||
"admin": PERMS,
|
||||
"member": ["board.view","quotes.view","publish.use","memos.read"],
|
||||
"client": ["quotes.view"],
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
# permissions
|
||||
perm_objs = {}
|
||||
for code in PERMS:
|
||||
p = Permission.query.filter_by(code=code).first() or Permission(code=code)
|
||||
db.session.add(p); perm_objs[code] = p
|
||||
db.session.commit()
|
||||
|
||||
|
||||
# roles
|
||||
for rname, p_list in ROLE_MAP.items():
|
||||
r = Role.query.filter_by(name=rname).first() or Role(name=rname)
|
||||
r.permissions = [perm_objs[c] for c in p_list]
|
||||
db.session.add(r)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
# admin user
|
||||
email = os.getenv("ADMIN_EMAIL")
|
||||
username = os.getenv("ADMIN_USERNAME")
|
||||
password = os.getenv("ADMIN_PASSWORD")
|
||||
if email and username and password:
|
||||
u = User.query.filter_by(email=email).first()
|
||||
if not u:
|
||||
u = User(email=email, username=username)
|
||||
u.set_password(password)
|
||||
db.session.add(u)
|
||||
admin_role = Role.query.filter_by(name="admin").first()
|
||||
if admin_role not in u.roles:
|
||||
u.roles.append(admin_role)
|
||||
db.session.commit()
|
||||
print("Seeded admin:", email)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
8
static/app.css
Normal file
8
static/app.css
Normal file
@@ -0,0 +1,8 @@
|
||||
:root { --accent: {{ accent|default("#8a2be2") }}; }
|
||||
.btn { border:1px solid rgba(255,255,255,.15); padding:.4rem .8rem; border-radius:.6rem; }
|
||||
.card { border:1px solid rgba(255,255,255,.1); border-radius:1rem; }
|
||||
.glass { background: rgba(255,255,255,.05); backdrop-filter: blur(6px); }
|
||||
.bg-accent { background: var(--accent); color:#000; }
|
||||
input, select, textarea { background: rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.12); border-radius:.6rem; padding:.45rem .6rem; width:100%; }
|
||||
.size-9 { width:2.25rem; height:2.25rem; }
|
||||
|
||||
41
templates/base.html
Normal file
41
templates/base.html
Normal file
@@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>{% block title %}{{ brand }}{% endblock %}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}">
|
||||
</head>
|
||||
<body class="bg-neutral-950 text-neutral-100 antialiased">
|
||||
<header class="border-b border-white/10 bg-black/60 backdrop-blur">
|
||||
<div class="max-w-7xl mx-auto px-6 py-3 flex items-center gap-6">
|
||||
<a class="font-bold" href="/">{{ brand }}</a>
|
||||
<nav class="text-sm flex flex-wrap gap-4">
|
||||
<a href="/board/">Intercom</a>
|
||||
<a href="/quotes/">Quotes</a>
|
||||
<a href="/memos/">Memos</a>
|
||||
<a href="/notes/">Notes</a>
|
||||
<a href="/journal/">Journal</a>
|
||||
</nav>
|
||||
<div class="ml-auto">
|
||||
{% if cu %}
|
||||
<form method="post" action="/auth/logout"><button class="btn">Log out</button></form>
|
||||
{% else %}
|
||||
<a class="btn" href="/auth/login">Log in</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-7xl mx-auto px-6 py-8">
|
||||
{% with msgs = get_flashed_messages(with_categories=true) %}
|
||||
{% for cat,msg in msgs %}
|
||||
<div class="mb-4 rounded border px-3 py-2 {{ 'border-emerald-400 bg-emerald-500/10' if cat=='ok' else 'border-red-400 bg-red-500/10' }}">{{ msg }}</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
58
templates/board.html
Normal file
58
templates/board.html
Normal file
@@ -0,0 +1,58 @@
|
||||
{% extends 'base.html' %}{% block title %}Intercom — {{ brand }}{% endblock %}
|
||||
{% block content %}
|
||||
<section class="max-w-5xl mx-auto">
|
||||
<div class="card glass p-4">
|
||||
<textarea id="composer" rows="3" class="w-full" placeholder="Share an update…" {% if not can_post %}disabled{% endif %}></textarea>
|
||||
<div class="mt-3 flex items-center justify-between">
|
||||
<div class="text-xs text-white/60">{% if not can_post %}You don’t have permission to post.{% endif %}</div>
|
||||
<button id="sendBtn" class="btn bg-accent font-semibold" {% if not can_post %}disabled{% endif %}>Post</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 space-y-3" id="board">
|
||||
{% for m in messages %}
|
||||
<article class="card glass p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
{% if m.avatar %}<img src="{{ m.avatar }}" class="size-9 rounded-lg">{% else %}<div class="size-9 rounded-lg bg-white/10 grid place-items-center">🟣</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-4 text-center"><button id="refreshBtn" class="btn">Refresh</button></div>
|
||||
</section>
|
||||
<script>
|
||||
const board=document.getElementById('board'), sendBtn=document.getElementById('sendBtn'), composer=document.getElementById('composer');
|
||||
function esc(s){return (s||'').replaceAll('&','&').replaceAll('<','<').replaceAll('>','>');}
|
||||
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="card glass p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
${m.avatar?`<img src="${m.avatar}" class="size-9 rounded-lg">`:`<div class="size-9 rounded-lg bg-white/10 grid place-items-center">🟣</div>`}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="font-semibold">${esc(m.username)}</div>
|
||||
${m.timestamp?`<div class="text-xs text-white/50">${esc(m.timestamp.replace('T',' ').replace('Z',' UTC'))}</div>`:''}
|
||||
</div>
|
||||
<div class="mt-1 whitespace-pre-wrap text-white/90">${esc(m.content)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>`).join('');
|
||||
}
|
||||
async function postMessage(){
|
||||
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')}
|
||||
}
|
||||
document.getElementById('refreshBtn').addEventListener('click', fetchMessages);
|
||||
sendBtn?.addEventListener('click', postMessage);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
25
templates/home.html
Normal file
25
templates/home.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Home — {{ brand }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<a href="/board/" class="card glass p-6 block">
|
||||
<h2 class="font-semibold text-lg">Discord Intercom</h2>
|
||||
<p class="text-white/70 text-sm">Read channel, admins can post.</p>
|
||||
</a>
|
||||
<a href="/quotes/" class="card glass p-6 block">
|
||||
<h2 class="font-semibold text-lg">Quotes</h2>
|
||||
<p class="text-white/70 text-sm">Public estimator + admin panel.</p>
|
||||
</a>
|
||||
<a href="/memos/" class="card glass p-6 block">
|
||||
<h2 class="font-semibold text-lg">Memos</h2>
|
||||
<p class="text-white/70 text-sm">Reminders & quick tasks.</p>
|
||||
</a>
|
||||
<a href="/notes/" class="card glass p-6 block">
|
||||
<h2 class="font-semibold text-lg">Notes</h2>
|
||||
</a>
|
||||
<a href="/journal/" class="card glass p-6 block">
|
||||
<h2 class="font-semibold text-lg">Journal</h2>
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
19
templates/journal.html
Normal file
19
templates/journal.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{% extends 'base.html' %}{% block title %}Journal — {{ brand }}{% endblock %}
|
||||
{% block content %}
|
||||
<form class="card glass p-4 max-w-2xl" method="post" action="/journal/add">
|
||||
<label class="block mb-2">Title<input class="w-full" name="name" required></label>
|
||||
<label>Entry<textarea class="w-full" name="entry" rows="6"></textarea></label>
|
||||
<div class="mt-3 text-right"><button class="btn bg-accent font-semibold">Save</button></div>
|
||||
</form>
|
||||
<div class="grid md:grid-cols-2 gap-4 mt-6">
|
||||
{% for j in journal %}
|
||||
<article class="card glass p-4">
|
||||
<div class="text-sm text-white/60">{{ (j.created_at|string)[:19].replace('T',' ') }}</div>
|
||||
<h3 class="font-semibold">{{ j.name }}</h3>
|
||||
<div class="mt-1 whitespace-pre-wrap text-white/90">{{ j.entry }}</div>
|
||||
<form class="mt-3" method="post" action="/journal/{{j.slug}}/delete"><button class="btn text-xs">Delete</button></form>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
21
templates/login.html
Normal file
21
templates/login.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Log in — {{ brand }}{% endblock %}
|
||||
{% block content %}
|
||||
<form class="max-w-md mx-auto card glass p-6" method="post" action="/auth/login">
|
||||
<input type="hidden" name="next" value="{{ next or '/' }}">
|
||||
<h1 class="text-xl font-semibold mb-4">Sign in</h1>
|
||||
<label class="block mb-3">
|
||||
<span class="text-sm text-white/70">Username or email</span>
|
||||
<input class="w-full mt-1" name="username" required>
|
||||
</label>
|
||||
<label class="block mb-4">
|
||||
<span class="text-sm text-white/70">Password</span>
|
||||
<input class="w-full mt-1" type="password" name="password" required>
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="btn bg-accent font-semibold" type="submit">Log in</button>
|
||||
<a class="btn" href="/auth/discord">Sign in with Discord</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
21
templates/memos.html
Normal file
21
templates/memos.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{% extends 'base.html' %}{% block title %}Memos — {{ brand }}{% endblock %}
|
||||
{% block content %}
|
||||
<form class="card glass p-4 max-w-2xl" method="post" action="/memos/add">
|
||||
<label class="block mb-2">Memo<textarea class="w-full" name="memo" rows="2" required></textarea></label>
|
||||
<label>Remind at <input type="datetime-local" name="reminder_time"></label>
|
||||
<div class="mt-3 text-right"><button class="btn bg-accent font-semibold">Add</button></div>
|
||||
</form>
|
||||
<div class="grid md:grid-cols-2 gap-4 mt-6">
|
||||
{% for m in memos %}
|
||||
<article class="card glass p-4">
|
||||
<div class="text-sm text-white/60">{{ (m.reminder_time|string)[:16] if m.reminder_time else 'No reminder' }}</div>
|
||||
<div class="mt-1 whitespace-pre-wrap">{{ m.memo }}</div>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<form method="post" action="/memos/{{m.id}}/complete"><button class="btn text-xs">Complete</button></form>
|
||||
<form method="post" action="/memos/{{m.id}}/delete"><button class="btn text-xs">Delete</button></form>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
19
templates/notes.html
Normal file
19
templates/notes.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{% extends 'base.html' %}{% block title %}Notes — {{ brand }}{% endblock %}
|
||||
{% block content %}
|
||||
<form class="card glass p-4 max-w-2xl" method="post" action="/notes/add">
|
||||
<label class="block mb-2">Title<input class="w-full" name="name" required></label>
|
||||
<label>Note<textarea class="w-full" name="note" rows="4"></textarea></label>
|
||||
<div class="mt-3 text-right"><button class="btn bg-accent font-semibold">Save</button></div>
|
||||
</form>
|
||||
<div class="grid md:grid-cols-2 gap-4 mt-6">
|
||||
{% for n in notes %}
|
||||
<article class="card glass p-4">
|
||||
<div class="text-sm text-white/60">{{ (n.created_at|string)[:19].replace('T',' ') }}</div>
|
||||
<h3 class="font-semibold">{{ n.name }}</h3>
|
||||
<div class="mt-1 whitespace-pre-wrap text-white/90">{{ n.note }}</div>
|
||||
<form class="mt-3" method="post" action="/notes/{{n.slug}}/delete"><button class="btn text-xs">Delete</button></form>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
8
templates/quote_thanks.html
Normal file
8
templates/quote_thanks.html
Normal file
@@ -0,0 +1,8 @@
|
||||
{% extends 'base.html' %}{% block title %}Thanks — {{ brand }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="max-w-md mx-auto card glass p-6">
|
||||
<h1 class="text-xl font-semibold">Thanks!</h1>
|
||||
<p class="text-white/70 mt-2">We’ll review and email you shortly.</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
43
templates/quotes_admin.html
Normal file
43
templates/quotes_admin.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{% extends 'base.html' %}{% block title %}Quotes Admin — {{ brand }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-2xl font-bold">Quote Requests</h1>
|
||||
<nav class="text-sm space-x-2">
|
||||
{% for k,l in [('active','Active'),('open','Open'),('completed','Completed'),('deleted','Deleted'),('all','All')] %}
|
||||
<a class="px-3 py-1 rounded border {{ 'bg-white/10' if show==k else 'border-white/10 hover:border-white/30' }}" href="?show={{k}}">{{l}}</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
</div>
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{% for r in rows %}
|
||||
<article class="card glass p-5 flex flex-col gap-4 {{ 'opacity-60' if r.deleted_at }}">
|
||||
<header class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="text-xs text-white/60">#{{r.id}} • {{ (r.created_at|string)[:19].replace('T',' ') }}</div>
|
||||
<h2 class="text-lg font-semibold">{{ r.name }}</h2>
|
||||
<a class="text-sm underline" href="mailto:{{ r.email }}">{{ r.email }}</a>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{% set st = r.status or 'open' %}
|
||||
<span class="px-2 py-0.5 rounded text-[11px] border {{ 'border-emerald-400 text-emerald-200' if st=='completed' else 'border-sky-400 text-sky-200' }}">{{ st }}</span>
|
||||
<span class="px-2 py-0.5 rounded text-[11px] border">{{ r.timeline or '-' }}</span>
|
||||
</div>
|
||||
</header>
|
||||
<dl class="grid grid-cols-2 gap-2 text-sm">
|
||||
<div><dt class="text-white/60">Need</dt><dd class="font-medium">{{ r.need or '-' }}</dd></div>
|
||||
<div><dt class="text-white/60">Scope</dt><dd class="font-medium">{{ r.scope_size or '-' }}</dd></div>
|
||||
<div><dt class="text-white/60">Extras</dt><dd class="font-medium">{{ r.extras or '-' }}</dd></div>
|
||||
<div><dt class="text-white/60">Budget</dt><dd class="font-medium">{{ r.budget_feel or '-' }}</dd></div>
|
||||
<div><dt class="text-white/60">Est. Hours</dt><dd class="font-medium">{{ r.est_hours }}</dd></div>
|
||||
<div><dt class="text-white/60">Est. Cost</dt><dd class="font-medium">${{ '%.2f'|format(r.est_cost or 0) }}</dd></div>
|
||||
</dl>
|
||||
<div class="text-sm bg-black/30 rounded border border-white/10 p-3 max-h-28 overflow-auto">{{ r.description or '—' }}</div>
|
||||
<div class="mt-auto flex items-center justify-between gap-2">
|
||||
<form method="post" action="/admin/quotes/{{r.id}}/complete?show={{show}}"><button class="btn bg-emerald-600/70 text-xs">Complete</button></form>
|
||||
<form method="post" action="/admin/quotes/{{r.id}}/delete?show={{show}}"><button class="btn bg-red-600/70 text-xs">Delete</button></form>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
51
templates/quotes_public.html
Normal file
51
templates/quotes_public.html
Normal file
@@ -0,0 +1,51 @@
|
||||
{% extends 'base.html' %}{% block title %}Get a Quick Estimate — {{ brand }}{% endblock %}
|
||||
{% block content %}
|
||||
<form class="max-w-3xl mx-auto space-y-6" action="/quotes/submit" method="post">
|
||||
<div class="card glass p-6 grid sm:grid-cols-2 gap-4">
|
||||
<div><label>Name*<input name="name" class="w-full mt-1" required></label></div>
|
||||
<div><label>Email*<input name="email" type="email" class="w-full mt-1" required></label></div>
|
||||
<div><label>Phone<input name="phone" class="w-full mt-1"></label></div>
|
||||
<div><label>Company<input name="company" class="w-full mt-1"></label></div>
|
||||
</div>
|
||||
<div class="card glass p-6">
|
||||
<h2 class="font-semibold mb-3">What do you need?</h2>
|
||||
<div class="grid sm:grid-cols-2 gap-3">
|
||||
{% for v,l in [('simple-site','Basic site'),('pro-site','Site with extras'),('online-form','Online form'),('sell-online','Sell online'),('fix-or-improve','Fix/Improve'),('it-help','IT help'),('custom-app','Custom tool'),('not-sure','Not sure')] %}
|
||||
<label class="flex items-center gap-2"><input type="radio" name="need" value="{{v}}" required> {{l}}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card glass p-6 grid sm:grid-cols-3 gap-3">
|
||||
<label class="flex items-center gap-2"><input type="radio" name="scope_size" value="small" required> Small</label>
|
||||
<label class="flex items-center gap-2"><input type="radio" name="scope_size" value="medium" required> Medium</label>
|
||||
<label class="flex items-center gap-2"><input type="radio" name="scope_size" value="large" required> Large</label>
|
||||
</div>
|
||||
<div class="card glass p-6 grid sm:grid-cols-4 gap-3">
|
||||
{% for v,l in [('flexible','Flexible'),('soon','Soon'),('rush','Rush'),('critical','Urgent')] %}
|
||||
<label class="flex items-center gap-2"><input type="radio" name="timeline" value="{{v}}" required> {{l}}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="card glass p-6">
|
||||
<h2 class="font-semibold mb-3">Extras</h2>
|
||||
{% for v,l in [('content','Content help'),('branding','Branding'),('training','Training'),('care','Care plan')] %}
|
||||
<label class="mr-4"><input type="checkbox" name="extras" value="{{v}}"> {{l}}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="card glass p-6">
|
||||
<label>Budget comfort
|
||||
<select class="w-full mt-1" name="budget_feel">
|
||||
<option value="unsure">I’m not sure yet</option>
|
||||
<option value="under-2k">Under $2k</option>
|
||||
<option value="2k-5k">$2k–$5k</option>
|
||||
<option value="5k-10k">$5k–$10k</option>
|
||||
<option value="10k-plus">$10k+</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="card glass p-6">
|
||||
<label>Notes<textarea name="description" rows="4" class="w-full mt-1"></textarea></label>
|
||||
</div>
|
||||
<div class="text-right"><button class="btn bg-accent font-semibold">Get my estimate</button></div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user