Initial commit

This commit is contained in:
ai_ml_operations
2026-08-27 04:17:34 -07:00
commit 3a14bfb996
297 changed files with 32710 additions and 0 deletions
@@ -0,0 +1,248 @@
{% extends "portal_base.html" %}
{% block title %}Social accounts · Portal{% endblock %}
{% block topbar_title %}Social accounts{% endblock %}
{% block portal_content %}
<div class="panel">
<div class="panel-h"><h2>Add account</h2></div>
<div class="panel-b">
<div class="connect-grid">
<a class="connect-card{% if connect_platform == 'facebook' %} is-active{% endif %}"
href="{% url 'social:account_list' %}?connect=facebook">
<span class="platform-icon meta">f</span>
<div>
<strong>Facebook Page</strong>
<p>Connect via Facebook Login for Business. Shared Meta App ID with Instagram.</p>
</div>
</a>
<a class="connect-card{% if connect_platform == 'instagram' %} is-active{% endif %}"
href="{% url 'social:account_list' %}?connect=instagram">
<span class="platform-icon ig">IG</span>
<div>
<strong>Instagram</strong>
<p>Facebook Login for Business — Professional account linked to a Page.</p>
</div>
</a>
<a class="connect-card{% if connect_platform == 'linkedin' %} is-active{% endif %}"
href="{% url 'social:account_list' %}?connect=linkedin">
<span class="platform-icon li">in</span>
<div>
<strong>LinkedIn</strong>
<p>Connect via OAuth. Tokens expire (~2 months) — re-authorize when prompted.</p>
</div>
</a>
</div>
{% if connect_meta %}
<div class="connect-form-panel" id="connect-form">
<h3 style="margin:0 0 8px;font-size:16px">{{ connect_meta.title }}</h3>
<ol class="connect-steps">
{% for step in connect_meta.steps %}
<li>{{ step }}</li>
{% endfor %}
{% if connect_meta.docs_url %}
<li>Reference: <a href="{{ connect_meta.docs_url }}" target="_blank" rel="noopener">platform docs</a></li>
{% endif %}
</ol>
{% if connect_meta.oauth %}
<div class="field" style="margin:12px 0 16px;padding:12px;border:1px dashed #cbd5e1;border-radius:8px;background:#f8fafc">
<label for="oauth-callback-url" style="display:block;margin-bottom:6px">
{{ connect_meta.callback_label }}
</label>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:stretch">
<input id="oauth-callback-url" type="text" readonly
value="{{ oauth_callback_url }}"
onclick="this.select()"
style="flex:1;min-width:220px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px">
<button type="button" class="btn btn-ghost btn-sm" id="copy-oauth-callback">Copy</button>
</div>
<p class="muted" style="margin:8px 0 0;font-size:13px">
Must match exactly (including http/https and trailing slash).
</p>
</div>
<form method="post" class="form-grid" style="margin-bottom:16px">
{% csrf_token %}
<input type="hidden" name="action" value="{{ connect_meta.save_action }}">
<input type="hidden" name="connect_return" value="{{ connect_platform }}">
<div class="field">
<label for="id_client_id">{{ connect_meta.client_id_label }}</label>
<input id="id_client_id" name="client_id" required
value="{{ oauth_client_id }}"
placeholder="From the developer portal"
autocomplete="off">
</div>
<div class="field">
<label for="id_client_secret">{{ connect_meta.client_secret_label }}</label>
<input id="id_client_secret" name="client_secret" type="password"
{% if not oauth_secret_saved %}required{% endif %}
placeholder="{% if oauth_secret_saved %}Leave blank to keep saved secret{% else %}From the developer portal{% endif %}"
autocomplete="new-password">
{% if oauth_secret_saved %}
<p class="muted" style="margin:6px 0 0;font-size:13px">Secret already saved (encrypted). Enter a new value only to replace it.</p>
{% endif %}
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-ghost" type="submit">Save app credentials</button>
{% if oauth_ready and oauth_start_url_name %}
<a class="btn btn-primary" href="{% url oauth_start_url_name %}">{{ connect_meta.connect_label }}</a>
{% else %}
<button class="btn btn-primary" type="button" disabled title="Save App ID and App Secret first">{{ connect_meta.connect_label }}</button>
{% endif %}
<a class="btn btn-ghost" href="{% url 'social:account_list' %}">Cancel</a>
</div>
</form>
{% else %}
<form method="post" class="form-grid">
{% csrf_token %}
<input type="hidden" name="action" value="connect">
<input type="hidden" name="platform" value="{{ connect_platform }}">
{% for name, label, placeholder in connect_meta.fields %}
<div class="field">
<label for="id_{{ name }}">{{ label }}</label>
{% if name == 'access_token' %}
<textarea id="id_{{ name }}" name="{{ name }}" required
style="min-height:88px" placeholder="{{ placeholder }}"></textarea>
{% else %}
<input id="id_{{ name }}" name="{{ name }}"
{% if name != 'page_id' and name != 'label' %}required{% endif %}
placeholder="{{ placeholder }}">
{% endif %}
</div>
{% endfor %}
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-primary" type="submit">Save account</button>
<a class="btn btn-ghost" href="{% url 'social:account_list' %}">Cancel</a>
</div>
</form>
{% endif %}
</div>
{% endif %}
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>Connected accounts</h2></div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead>
<tr>
<th>Account</th>
<th>Platform</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for account in accounts %}
<tr {% if not account.is_active %}class="row-warn"{% endif %}>
<td>
<form method="post" class="account-rename">
{% csrf_token %}
<input type="hidden" name="action" value="rename">
<input type="hidden" name="account_id" value="{{ account.pk }}">
<input id="id_label_{{ account.pk }}" name="label" type="text" maxlength="120"
value="{{ account.label }}" required
aria-label="Display name for {{ account.get_platform_display }}">
<button class="btn btn-ghost btn-sm" type="submit">Rename</button>
</form>
{% if account.external_id %}
<span class="muted" style="display:block;margin-top:6px;overflow-wrap:anywhere">{{ account.external_id }}</span>
{% endif %}
</td>
<td>
<span class="platform-pill {% if account.platform == 'facebook' %}meta{% elif account.platform == 'instagram' %}ig{% else %}li{% endif %}">
{{ account.get_platform_display }}
</span>
</td>
<td>
{% if account.is_active %}
<span class="badge badge-delivered">Active</span>
{% else %}
<span class="badge badge-failed">Inactive</span>
{% endif %}
</td>
<td>
<div style="display:flex;gap:8px;flex-wrap:wrap">
{% if account.platform == 'linkedin' %}
<a class="btn btn-ghost btn-sm" href="{% url 'social:linkedin_oauth_start' %}">Re-authorize</a>
{% elif account.platform == 'instagram' %}
<a class="btn btn-ghost btn-sm" href="{% url 'social:instagram_oauth_start' %}">Re-authorize</a>
{% elif account.platform == 'facebook' %}
<a class="btn btn-ghost btn-sm" href="{% url 'social:facebook_oauth_start' %}">Re-authorize</a>
{% else %}
<a class="btn btn-ghost btn-sm" href="{% url 'social:account_list' %}?connect={{ account.platform }}">Update tokens</a>
{% endif %}
{% if account.is_active %}
<form method="post" style="display:inline">
{% csrf_token %}
<input type="hidden" name="action" value="disconnect">
<input type="hidden" name="account_id" value="{{ account.pk }}">
<button class="btn btn-ghost btn-sm" type="submit">Disconnect</button>
</form>
{% else %}
<form method="post" style="display:inline">
{% csrf_token %}
<input type="hidden" name="action" value="reactivate">
<input type="hidden" name="account_id" value="{{ account.pk }}">
<button class="btn btn-ghost btn-sm" type="submit">Mark active</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% empty %}
<tr><td colspan="4" class="empty-state">No accounts connected yet — pick a platform above.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="split">
<div class="panel">
<div class="panel-h"><h2>When to re-auth</h2></div>
<div class="panel-b">
<ul class="plain-list">
<li>LinkedIn / Instagram access tokens expire on a short cycle</li>
<li>Meta password changes or app review updates</li>
<li>Publishing fails with an auth error</li>
</ul>
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>Next</h2></div>
<div class="panel-b">
<a class="btn btn-primary btn-sm" href="{% url 'social:composer' %}">Open composer →</a>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
{% if connect_platform %}
<script>
document.getElementById('connect-form')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
</script>
{% endif %}
{% if connect_meta and connect_meta.oauth %}
<script>
(function () {
const btn = document.getElementById('copy-oauth-callback');
const input = document.getElementById('oauth-callback-url');
if (!btn || !input) return;
btn.addEventListener('click', async () => {
const value = input.value || '';
try {
await navigator.clipboard.writeText(value);
const prev = btn.textContent;
btn.textContent = 'Copied';
setTimeout(() => { btn.textContent = prev; }, 1500);
} catch (_err) {
input.select();
document.execCommand('copy');
}
});
})();
</script>
{% endif %}
{% endblock %}
+414
View File
@@ -0,0 +1,414 @@
{% extends "portal_base.html" %}
{% block title %}Compose · Social{% endblock %}
{% block topbar_title %}Compose & preview{% endblock %}
{% block extra_head %}
<style>
.media-drop {
border: 1px dashed var(--monica-border, #cbd5e1);
border-radius: 8px;
padding: 14px;
background: #f8fafc;
}
.media-thumbs {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 10px;
}
.media-thumb {
position: relative;
width: 96px;
height: 96px;
border-radius: 8px;
overflow: hidden;
background: #e2e8f0;
border: 1px solid var(--monica-border, #cbd5e1);
}
.media-thumb img,
.media-thumb video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.media-thumb .remove-media {
position: absolute;
top: 4px;
right: 4px;
border: 0;
border-radius: 999px;
width: 22px;
height: 22px;
line-height: 22px;
padding: 0;
background: rgba(15, 23, 42, 0.75);
color: #fff;
cursor: pointer;
font-size: 14px;
}
.media-thumb .media-label {
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.65);
color: #fff;
font-size: 10px;
padding: 2px 4px;
text-align: center;
}
#preview-media {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 12px;
}
#preview-media img,
#preview-media video {
max-width: 100%;
max-height: 280px;
border-radius: 8px;
background: #0f172a;
}
#preview-media img { object-fit: contain; }
#media-upload-status { margin-top: 8px; font-size: 13px; }
</style>
{% endblock %}
{% block portal_content %}
{% if not accounts %}
<div class="auth-alert">
<strong>No social accounts connected.</strong>
<a href="{% url 'social:account_list' %}">Connect Facebook, Instagram, or LinkedIn</a> first.
</div>
{% endif %}
<div class="split">
<div class="panel">
<div class="panel-h">
<h2>Compose</h2>
<a class="btn btn-sm btn-ghost" href="{% url 'social:account_list' %}">Manage accounts</a>
</div>
<div class="panel-b">
{% if error %}
<ul class="portal-flash" style="margin:0 0 12px"><li class="error">{{ error }}</li></ul>
{% endif %}
<form method="post" class="form-grid" id="social-compose">
{% csrf_token %}
<input type="hidden" name="media_json" id="id_media_json" value="{{ form.media_json }}">
<div class="field">
<label for="id_body">Caption</label>
<textarea id="id_body" name="body" style="min-height:140px"
placeholder="Open house this Saturday…">{{ form.body }}</textarea>
<div class="hint"><span id="char-count">0</span> characters · IG soft limit ~2,200</div>
</div>
<div class="field">
<label for="id_media">Media</label>
<div class="media-drop">
<input id="id_media" type="file" accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/quicktime,video/webm" multiple>
<div class="hint" style="margin-top:8px">
Images (JPEG/PNG/GIF/WebP, ≤8&nbsp;MB) or one video (MP4/MOV/WebM, ≤100&nbsp;MB).
Instagram requires media. Dont mix images + video on one post.
</div>
<div id="media-upload-status" class="muted" hidden></div>
<div class="media-thumbs" id="media-thumbs"></div>
</div>
</div>
<div class="field">
<label>Accounts</label>
{% for account in accounts %}
<label class="check-row account-pick">
<input type="checkbox" name="account_ids" value="{{ account.pk }}"
class="account-check"
data-platform="{{ account.platform }}"
{% if account.pk|stringformat:"s" in form.account_ids %}checked{% endif %}>
<span class="account-pick-copy">
<strong>{{ account.display_name }}</strong>
<span class="muted">{{ account.get_platform_display }}{% if account.external_id %} · {{ account.external_id }}{% endif %}</span>
</span>
</label>
{% empty %}
<p class="muted">No active accounts.</p>
{% endfor %}
{% if accounts %}
<div class="hint">Name look wrong? <a href="{% url 'social:account_list' %}">Rename it on Social accounts</a>.</div>
{% endif %}
</div>
<div class="form-grid cols-2">
<div class="field">
<label for="id_scheduled_for">Schedule for <span class="muted">(optional)</span></label>
<input id="id_scheduled_for" name="scheduled_for" type="datetime-local" step="60"
value="{{ form.scheduled_for }}">
<div class="hint">Needed only if you click Schedule. Leave blank to Send now.</div>
</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-primary" type="submit" name="action" value="send_now">Send now</button>
<button class="btn btn-ghost" type="submit" name="action" value="schedule">Schedule</button>
<button class="btn btn-ghost" type="submit" name="action" value="save">Save draft</button>
</div>
</form>
</div>
</div>
{% if 'social_ai' in enabled_features %}
<div class="panel">
<div class="panel-h"><h2>AI assist</h2></div>
<div class="panel-b form-grid">
<div class="form-grid">
<div class="field">
<label for="id_prompt">Prompt</label>
<textarea id="id_prompt" name="prompt" style="min-height:88px"
placeholder="Warm post about this weeks offer…">{{ form.prompt }}</textarea>
</div>
<button class="btn btn-ghost" type="button" id="ai-generate" data-url="{% url 'social_ai:generate' %}">Generate draft (Ollama)</button>
<p class="hint-block" id="ai-status">Generated text fills the caption field. Review before publishing.</p>
</div>
</div>
</div>
{% endif %}
</div>
<div class="panel">
<div class="panel-h"><h2>Preview</h2></div>
<div class="panel-b">
<div class="preview-pane">
<div class="muted" id="preview-empty">Preview updates as you type or attach media.</div>
<div id="preview-body" hidden>
<div id="preview-media"></div>
<div id="preview-content" style="white-space:pre-wrap"></div>
</div>
</div>
</div>
</div>
<p class="muted" style="font-size:13px;margin-top:12px">
<a href="{% url 'social:post_list' %}">View recent posts</a>
</p>
{% endblock %}
{% block extra_js %}
{% if 'social_ai' in enabled_features %}
<script>
(function () {
var btn = document.getElementById('ai-generate');
if (!btn) return;
btn.addEventListener('click', function () {
var prompt = (document.getElementById('id_prompt') || {}).value || '';
var status = document.getElementById('ai-status');
var body = document.querySelector('textarea[name=body]');
if (!prompt.trim()) { if (status) status.textContent = 'Enter a prompt first.'; return; }
btn.disabled = true;
fetch(btn.getAttribute('data-url'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
},
body: JSON.stringify({prompt: prompt})
}).then(function (r) { return r.json().then(function (d) { return {ok: r.ok, d: d}; }); })
.then(function (res) {
if (res.ok && res.d.text && body) { body.value = res.d.text; if (status) status.textContent = 'Draft inserted. Review before publishing.'; }
else if (status) status.textContent = res.d.error || 'Generate failed.';
})
.catch(function () { if (status) status.textContent = 'Generate failed.'; })
.finally(function () { btn.disabled = false; });
});
})();
</script>
{% endif %}
<script>
(function () {
var bodyEl = document.getElementById('id_body');
var countEl = document.getElementById('char-count');
var empty = document.getElementById('preview-empty');
var previewBody = document.getElementById('preview-body');
var content = document.getElementById('preview-content');
var previewMedia = document.getElementById('preview-media');
var when = document.getElementById('id_scheduled_for');
var composeForm = document.getElementById('social-compose');
var mediaInput = document.getElementById('id_media');
var mediaJson = document.getElementById('id_media_json');
var thumbs = document.getElementById('media-thumbs');
var statusEl = document.getElementById('media-upload-status');
var uploadUrl = "{{ media_upload_url|escapejs }}";
var csrf = (document.querySelector('#social-compose input[name=csrfmiddlewaretoken]') || {}).value || '';
var mediaItems = [];
try {
mediaItems = JSON.parse((mediaJson && mediaJson.value) || '[]') || [];
if (!Array.isArray(mediaItems)) mediaItems = [];
} catch (_err) {
mediaItems = [];
}
function setStatus(msg, isError) {
if (!statusEl) return;
if (!msg) {
statusEl.hidden = true;
statusEl.textContent = '';
return;
}
statusEl.hidden = false;
statusEl.textContent = msg;
statusEl.style.color = isError ? '#b91c1c' : '';
}
function syncMediaField() {
if (mediaJson) mediaJson.value = JSON.stringify(mediaItems);
}
function renderThumbs() {
if (!thumbs) return;
thumbs.innerHTML = '';
mediaItems.forEach(function (item, idx) {
var wrap = document.createElement('div');
wrap.className = 'media-thumb';
if (item.type === 'video') {
var vid = document.createElement('video');
vid.src = item.url;
vid.muted = true;
wrap.appendChild(vid);
var label = document.createElement('div');
label.className = 'media-label';
label.textContent = 'Video';
wrap.appendChild(label);
} else {
var img = document.createElement('img');
img.src = item.url;
img.alt = item.filename || 'Image';
wrap.appendChild(img);
}
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'remove-media';
btn.setAttribute('aria-label', 'Remove');
btn.textContent = '×';
btn.addEventListener('click', function () {
mediaItems.splice(idx, 1);
syncMediaField();
renderThumbs();
syncPreview();
});
wrap.appendChild(btn);
thumbs.appendChild(wrap);
});
}
function syncPreview() {
var body = bodyEl ? bodyEl.value : '';
if (countEl) countEl.textContent = String(body.length);
if (!empty || !previewBody || !content || !previewMedia) return;
previewMedia.innerHTML = '';
mediaItems.forEach(function (item) {
if (item.type === 'video') {
var vid = document.createElement('video');
vid.src = item.url;
vid.controls = true;
vid.playsInline = true;
previewMedia.appendChild(vid);
} else {
var img = document.createElement('img');
img.src = item.url;
img.alt = item.filename || 'Image';
previewMedia.appendChild(img);
}
});
content.textContent = body;
var hasContent = !!(body || mediaItems.length);
empty.hidden = hasContent;
previewBody.hidden = !hasContent;
}
function requireScheduleTime(event) {
var submitter = event.submitter;
var action = submitter && submitter.name === 'action' ? submitter.value : '';
if (action !== 'schedule') return;
if (when && when.value) return;
event.preventDefault();
if (when) when.focus();
window.alert('Pick a date and time to schedule, or choose Send now.');
}
function validateBeforeUpload(file) {
var hasVideo = mediaItems.some(function (m) { return m.type === 'video'; });
var hasImage = mediaItems.some(function (m) { return m.type === 'image'; });
var isVideo = (file.type || '').indexOf('video/') === 0;
var isImage = (file.type || '').indexOf('image/') === 0;
if (!isVideo && !isImage) {
return 'Use an image or video file.';
}
if (isVideo && hasVideo) return 'Only one video per post.';
if (isVideo && hasImage) return 'Remove images before attaching a video.';
if (isImage && hasVideo) return 'Remove the video before attaching images.';
if (isImage && mediaItems.filter(function (m) { return m.type === 'image'; }).length >= 10) {
return 'At most 10 images.';
}
return '';
}
async function uploadFile(file) {
var err = validateBeforeUpload(file);
if (err) {
setStatus(err, true);
return;
}
setStatus('Uploading ' + (file.name || 'file') + '…');
var fd = new FormData();
fd.append('media', file);
try {
var res = await fetch(uploadUrl, {
method: 'POST',
headers: { 'X-CSRFToken': csrf },
body: fd,
credentials: 'same-origin'
});
var data = await res.json().catch(function () { return {}; });
if (!res.ok) {
throw new Error(data.error || ('Upload failed (' + res.status + ')'));
}
mediaItems.push(data);
syncMediaField();
renderThumbs();
syncPreview();
setStatus('Attached ' + (data.filename || data.type) + '.');
} catch (e) {
setStatus(e.message || 'Upload failed', true);
}
}
if (mediaInput) {
mediaInput.addEventListener('change', async function () {
var files = Array.prototype.slice.call(mediaInput.files || []);
mediaInput.value = '';
for (var i = 0; i < files.length; i++) {
await uploadFile(files[i]);
}
});
}
if (bodyEl) {
bodyEl.addEventListener('input', syncPreview);
bodyEl.addEventListener('keyup', syncPreview);
bodyEl.addEventListener('change', syncPreview);
}
if (composeForm) composeForm.addEventListener('submit', requireScheduleTime);
var generateForm = document.querySelector('form input[name=action][value=generate]')?.form;
if (generateForm) {
generateForm.addEventListener('submit', function () {
var genMedia = generateForm.querySelector('input[name=media_json]');
if (genMedia && mediaJson) genMedia.value = mediaJson.value;
var genBody = generateForm.querySelector('textarea[name=body]');
// keep account/mode already mirrored server-side on last render
});
}
syncMediaField();
renderThumbs();
syncPreview();
})();
</script>
{% endblock %}
@@ -0,0 +1,63 @@
{% extends "portal_base.html" %}
{% block title %}Connecting Meta…{% endblock %}
{% block topbar_title %}Connecting…{% endblock %}
{% block portal_content %}
<div class="panel">
<div class="panel-h"><h2>Finishing Facebook Login</h2></div>
<div class="panel-b">
<p id="meta-oauth-status">Capturing access token from Facebook…</p>
<p class="muted" id="meta-oauth-error" style="display:none;color:#b91c1c"></p>
</div>
</div>
<form id="meta-oauth-complete" method="post" action="{% url 'social:meta_oauth_complete' %}" style="display:none">
{% csrf_token %}
<input type="hidden" name="access_token" id="meta-access-token" value="">
<input type="hidden" name="long_lived_token" id="meta-long-lived-token" value="">
<input type="hidden" name="expires_in" id="meta-expires-in" value="">
<input type="hidden" name="state" id="meta-state" value="">
<input type="hidden" name="error" id="meta-error" value="">
<input type="hidden" name="error_description" id="meta-error-description" value="">
</form>
{% endblock %}
{% block extra_js %}
<script>
(function () {
const status = document.getElementById('meta-oauth-status');
const errEl = document.getElementById('meta-oauth-error');
const form = document.getElementById('meta-oauth-complete');
const hash = (window.location.hash || '').replace(/^#/, '');
const params = new URLSearchParams(hash);
// Some Meta flows also put error on the query string.
const query = new URLSearchParams(window.location.search || '');
function fail(msg) {
status.textContent = 'Could not complete Facebook Login.';
errEl.style.display = 'block';
errEl.textContent = msg;
}
const error = params.get('error_reason') || params.get('error') || query.get('error') || '';
if (error) {
document.getElementById('meta-error').value = error;
document.getElementById('meta-error-description').value =
params.get('error_description') || query.get('error_description') || error;
form.submit();
return;
}
const accessToken = params.get('access_token') || '';
const longLived = params.get('long_lived_token') || '';
if (!accessToken && !longLived) {
fail('No access token in the redirect. Confirm the Valid OAuth Redirect URI matches exactly, then try Connect again.');
return;
}
document.getElementById('meta-access-token').value = accessToken;
document.getElementById('meta-long-lived-token').value = longLived;
document.getElementById('meta-expires-in').value = params.get('expires_in') || '';
document.getElementById('meta-state').value = params.get('state') || query.get('state') || '';
status.textContent = 'Token received — saving account…';
form.submit();
})();
</script>
{% endblock %}
@@ -0,0 +1,58 @@
{% extends "portal_base.html" %}
{% block title %}Post · Portal{% endblock %}
{% block topbar_title %}Social post{% endblock %}
{% block portal_content %}
<div class="toolbar">
<span class="badge badge-{{ post.status }}">{{ post.get_status_display }}</span>
<a class="btn btn-sm btn-ghost" href="{% url 'social:post_list' %}">← All posts</a>
</div>
<div class="split">
<div class="panel">
<div class="panel-h"><h2>Caption</h2></div>
<div class="panel-b">
{% if post.media %}
<div style="display:flex;flex-wrap:wrap;gap:10px;margin-bottom:12px">
{% for item in post.media %}
{% if item.type == "video" %}
<video src="{{ item.url }}" controls playsinline style="max-width:100%;max-height:280px;border-radius:8px"></video>
{% else %}
<img src="{{ item.url }}" alt="{{ item.filename|default:'Image' }}" style="max-width:100%;max-height:280px;border-radius:8px;object-fit:contain">
{% endif %}
{% endfor %}
</div>
{% endif %}
<div class="preview-pane" style="white-space:pre-wrap">{{ post.body }}</div>
{% if post.scheduled_for %}
<p class="hint-block">Scheduled {{ post.scheduled_for|date:"M j, Y g:i A" }}</p>
{% endif %}
{% if post.ollama_prompt %}
<p class="hint-block">AI prompt: {{ post.ollama_prompt }}</p>
{% endif %}
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>Targets</h2></div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead><tr><th>Account</th><th>Platform</th><th>Status</th></tr></thead>
<tbody>
{% for target in post.targets.all %}
<tr>
<td>
{{ target.account.display_name }}
{% if target.account.external_id %}
<br><span class="muted" style="overflow-wrap:anywhere">{{ target.account.external_id }}</span>
{% endif %}
</td>
<td>{{ target.get_platform_display }}</td>
<td><span class="badge badge-{{ target.status }}">{{ target.get_status_display }}</span></td>
</tr>
{% empty %}
<tr><td colspan="3" class="empty-state">No targets attached.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,30 @@
{% extends "portal_base.html" %}
{% block title %}Social posts · Portal{% endblock %}
{% block topbar_title %}Scheduled &amp; drafts{% endblock %}
{% block portal_content %}
<div class="toolbar">
<p class="muted" style="margin:0">Posts saved from the composer.</p>
<a class="btn btn-primary btn-sm" href="{% url 'social:composer' %}">Compose</a>
</div>
<div class="panel">
<div class="panel-b" style="padding:0">
<table class="table">
<thead>
<tr><th>Preview</th><th>Status</th><th>Scheduled</th><th></th></tr>
</thead>
<tbody>
{% for post in posts %}
<tr>
<td>{{ post.body|truncatechars:80 }}</td>
<td><span class="badge badge-{{ post.status }}">{{ post.get_status_display }}</span></td>
<td>{% if post.scheduled_for %}{{ post.scheduled_for|date:"M j, g:i A" }}{% else %}—{% endif %}</td>
<td><a href="{% url 'social:post_detail' post.pk %}">Open</a></td>
</tr>
{% empty %}
<tr><td colspan="4" class="empty-state">No posts yet. <a href="{% url 'social:composer' %}">Compose one</a>.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}