Template
Populate the client website template with catalog feature flags.
Extract always-on public/portal/UTM plus optional email_sms, directmail, blog, payments, social, and social_ai so new client sites can be bootstrapped from this seed. Refs #1 Refs #2 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 %}
|
||||
@@ -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 MB) or one video (MP4/MOV/WebM, ≤100 MB).
|
||||
Instagram requires media. Don’t 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 week’s 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 & 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 %}
|
||||
Reference in New Issue
Block a user