Initial Commit

This commit is contained in:
2025-11-27 00:00:50 +00:00
commit b7e68a9057
43 changed files with 3445 additions and 0 deletions

76
.gitignore vendored Normal file
View 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

2198
app.py Normal file

File diff suppressed because it is too large Load Diff

5
config.py Normal file
View 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
View 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
View 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
View 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 dont 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
View 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")

View 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="/">Bennys 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>

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

View 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
View 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")

View File

@@ -0,0 +1,2 @@
from flask import Blueprint
board_bp = Blueprint("board", __name__, template_folder="templates")

52
modules/board/routes.py Normal file
View 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)

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

View File

@@ -0,0 +1,2 @@
from flask import Blueprint
memos_bp = Blueprint("memos", __name__, template_folder="templates")

27
modules/memos/models.py Normal file
View 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
View 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)

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

View 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="Whats 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 %}

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

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

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

View File

@@ -0,0 +1,2 @@
from flask import Blueprint
publish_bp = Blueprint("publish", __name__, template_folder="templates")

View 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")

View 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 FrontMatter</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 %}

View File

@@ -0,0 +1,2 @@
from flask import Blueprint
quotes_bp = Blueprint("quotes", __name__, template_folder="templates")

26
modules/quotes/routes.py Normal file
View 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")

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

View 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
View 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
View 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
View 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
View 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
View 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 dont 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('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;');}
async function fetchMessages(){
const r=await fetch('/api/board/messages'); if(!r.ok) return;
const data=await r.json();
board.innerHTML=data.map(m=>`
<article class="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
View 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
View 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
View 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
View 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
View 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 %}

View 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">Well review and email you shortly.</p>
</div>
{% endblock %}

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

View 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">Im 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 %}

4
wsgi.py Normal file
View File

@@ -0,0 +1,4 @@
from app import app
if __name__ == '__main__':
app.run()