Add Meta Facebook Login OAuth and social media attach/publish.
Connect Facebook and Instagram via shared portal Meta App credentials (Facebook Login for Business), and let compose attach DB-stored images/videos with live preview and platform publish.
This commit is contained in:
@@ -11,15 +11,15 @@
|
||||
<span class="platform-icon meta">f</span>
|
||||
<div>
|
||||
<strong>Facebook Page</strong>
|
||||
<p>Connect a Page you manage with a Page access token.</p>
|
||||
<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 Business</strong>
|
||||
<p>Requires a Facebook Page linked to an IG business account.</p>
|
||||
<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 %}"
|
||||
@@ -44,17 +44,17 @@
|
||||
{% endif %}
|
||||
</ol>
|
||||
|
||||
{% if connect_meta.oauth and connect_platform == 'linkedin' %}
|
||||
{% 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="linkedin-callback-url" style="display:block;margin-bottom:6px">
|
||||
Authorized redirect URL — paste into LinkedIn Auth tab
|
||||
<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="linkedin-callback-url" type="text" readonly
|
||||
value="{{ linkedin_callback_url }}"
|
||||
<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-linkedin-callback">Copy</button>
|
||||
<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).
|
||||
@@ -63,30 +63,31 @@
|
||||
|
||||
<form method="post" class="form-grid" style="margin-bottom:16px">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="save_linkedin_app">
|
||||
<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">Client ID</label>
|
||||
<label for="id_client_id">{{ connect_meta.client_id_label }}</label>
|
||||
<input id="id_client_id" name="client_id" required
|
||||
value="{{ linkedin_client_id }}"
|
||||
placeholder="From LinkedIn Auth tab"
|
||||
value="{{ oauth_client_id }}"
|
||||
placeholder="From the developer portal"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="id_client_secret">Primary Client Secret</label>
|
||||
<label for="id_client_secret">{{ connect_meta.client_secret_label }}</label>
|
||||
<input id="id_client_secret" name="client_secret" type="password"
|
||||
{% if not linkedin_secret_saved %}required{% endif %}
|
||||
placeholder="{% if linkedin_secret_saved %}Leave blank to keep saved secret{% else %}From LinkedIn Auth tab{% endif %}"
|
||||
{% 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 linkedin_secret_saved %}
|
||||
{% 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 linkedin_oauth_ready %}
|
||||
<a class="btn btn-primary" href="{% url 'social:linkedin_oauth_start' %}">Connect with LinkedIn</a>
|
||||
{% 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 Client ID and Client Secret first">Connect with LinkedIn</button>
|
||||
<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>
|
||||
@@ -155,6 +156,10 @@
|
||||
<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 %}
|
||||
@@ -189,7 +194,7 @@
|
||||
<div class="panel-h"><h2>When to re-auth</h2></div>
|
||||
<div class="panel-b">
|
||||
<ul class="plain-list">
|
||||
<li>LinkedIn access tokens expire on a short cycle</li>
|
||||
<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>
|
||||
@@ -209,11 +214,11 @@
|
||||
document.getElementById('connect-form')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
</script>
|
||||
{% endif %}
|
||||
{% if connect_platform == 'linkedin' %}
|
||||
{% if connect_meta and connect_meta.oauth %}
|
||||
<script>
|
||||
(function () {
|
||||
const btn = document.getElementById('copy-linkedin-callback');
|
||||
const input = document.getElementById('linkedin-callback-url');
|
||||
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 || '';
|
||||
|
||||
@@ -1,6 +1,79 @@
|
||||
{% 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">
|
||||
@@ -21,18 +94,32 @@
|
||||
{% 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…"
|
||||
oninput="syncPreview()">{{ form.body }}</textarea>
|
||||
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">
|
||||
<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 %}>
|
||||
{{ account.get_platform_display }} · {{ account.label }}
|
||||
</label>
|
||||
@@ -70,6 +157,7 @@
|
||||
<input type="hidden" name="action" value="generate">
|
||||
<input type="hidden" name="publish_mode" value="{{ form.publish_mode }}">
|
||||
<input type="hidden" name="scheduled_for" value="{{ form.scheduled_for }}">
|
||||
<input type="hidden" name="media_json" value="{{ form.media_json }}">
|
||||
{% for id in form.account_ids %}
|
||||
<input type="hidden" name="account_ids" value="{{ id }}">
|
||||
{% endfor %}
|
||||
@@ -89,8 +177,11 @@
|
||||
<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.</div>
|
||||
<div id="preview-content" hidden style="white-space:pre-wrap"></div>
|
||||
<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>
|
||||
@@ -102,29 +193,190 @@
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
(function () {
|
||||
function syncPreview() {
|
||||
var body = (document.getElementById('id_body') || {}).value || '';
|
||||
var count = document.getElementById('char-count');
|
||||
if (count) count.textContent = String(body.length);
|
||||
var empty = document.getElementById('preview-empty');
|
||||
var content = document.getElementById('preview-content');
|
||||
if (!empty || !content) return;
|
||||
if (!body) {
|
||||
empty.hidden = false;
|
||||
content.hidden = true;
|
||||
return;
|
||||
}
|
||||
empty.hidden = true;
|
||||
content.hidden = false;
|
||||
content.textContent = body;
|
||||
}
|
||||
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 mode = document.getElementById('id_publish_mode');
|
||||
var when = document.getElementById('id_scheduled_for');
|
||||
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 syncWhen() {
|
||||
if (!mode || !when) return;
|
||||
when.disabled = mode.value !== 'schedule';
|
||||
}
|
||||
|
||||
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 (mode) mode.addEventListener('change', syncWhen);
|
||||
|
||||
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();
|
||||
syncWhen();
|
||||
})();
|
||||
|
||||
@@ -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 %}
|
||||
@@ -10,6 +10,17 @@
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user