Files
college_craft/site/social/templates/social/composer.html
T
ai_ml_operations 3a14bfb996 Initial commit
2026-08-27 04:17:34 -07:00

415 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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 %}