Initial Commit
This commit is contained in:
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 %}
|
||||
Reference in New Issue
Block a user