generated from westfarn/web_django_template
415 lines
14 KiB
HTML
415 lines
14 KiB
HTML
{% 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 %}
|