New mastodon post feature

This commit is contained in:
Edward Betts 2025-11-15 12:48:36 +00:00
parent 1180c0817f
commit eada9049fa
13 changed files with 746 additions and 23 deletions

1
.gitignore vendored
View file

@ -2,3 +2,4 @@ api_keys
.mypy_cache .mypy_cache
__pycache__ __pycache__
instance instance
.env

View file

@ -10,6 +10,8 @@ A small Flask app that fetches recent Immich photos and helps generate high-qual
IMMICH_API_URL=https://photos.4angle.com/ IMMICH_API_URL=https://photos.4angle.com/
IMMICH_API_KEY=your-immich-api-key IMMICH_API_KEY=your-immich-api-key
OPENAI_API_KEY=your-openai-api-key OPENAI_API_KEY=your-openai-api-key
MASTODON_BASE_URL=http://localhost:3000
MASTODON_ACCESS_TOKEN=your-mastodon-token
``` ```
2. Install dependencies and run the development server: 2. Install dependencies and run the development server:
@ -19,6 +21,13 @@ A small Flask app that fetches recent Immich photos and helps generate high-qual
flask --app app run --debug flask --app app run --debug
``` ```
3. Visit <http://127.0.0.1:5000> to browse recent Immich photos, view cached alt text, and request new alt text guided by optional notes. 3. Visit <http://127.0.0.1:5000> to browse recent Immich photos, view cached alt text, request new alt text guided by optional notes, or compose Mastodon posts.
## Mastodon workflow
* Use **Compose Mastodon post** to select up to four photos from the last few days (200 entries are shown for convenience).
* The draft view auto-fills cached alt text (or generates it if missing), lets you edit each description, enter post text, and provide optional ChatGPT instructions.
* Hit **Improve text with ChatGPT** as many times as you like; the model polishes grammar, tone, and UK English spellings while respecting your extra notes.
* When ready, choose **Post to Mastodon** to upload the selected media (with their alt text) and publish the refined caption via the configured access token.
The app stores alt text in `instance/alt_text_cache.db`. Remove this file to clear the cache. The app stores alt text in `instance/alt_text_cache.db`. Remove this file to clear the cache.

View file

@ -8,6 +8,7 @@ from .cache import AltTextCache
from .config import load_settings from .config import load_settings
from .immich import ImmichClient from .immich import ImmichClient
from .openai_client import AltTextGenerator from .openai_client import AltTextGenerator
from .mastodon import MastodonClient
def create_app() -> Flask: def create_app() -> Flask:
@ -42,6 +43,14 @@ def create_app() -> Flask:
api_key=app.config["OPENAI_API_KEY"], api_key=app.config["OPENAI_API_KEY"],
model=app.config.get("OPENAI_MODEL", "gpt-4o-mini"), model=app.config.get("OPENAI_MODEL", "gpt-4o-mini"),
) )
mastodon_token = app.config.get("MASTODON_ACCESS_TOKEN")
if mastodon_token:
app.mastodon_client = MastodonClient(
base_url=app.config.get("MASTODON_BASE_URL", "http://localhost:3000"),
access_token=mastodon_token,
)
else:
app.mastodon_client = None
from . import routes # pragma: no cover from . import routes # pragma: no cover

View file

@ -54,6 +54,12 @@ def load_settings() -> Dict[str, str]:
"OPENAI_MODEL": os.getenv("OPENAI_MODEL", "gpt-4o-mini"), "OPENAI_MODEL": os.getenv("OPENAI_MODEL", "gpt-4o-mini"),
"ALT_TEXT_DB": os.getenv("ALT_TEXT_DB", ""), "ALT_TEXT_DB": os.getenv("ALT_TEXT_DB", ""),
"SECRET_KEY": os.getenv("SECRET_KEY", ""), "SECRET_KEY": os.getenv("SECRET_KEY", ""),
"MASTODON_BASE_URL": os.getenv(
"MASTODON_BASE_URL", "http://localhost:3000"
).rstrip("/"),
"MASTODON_ACCESS_TOKEN": os.getenv("MASTODON_ACCESS_TOKEN", ""),
"MASTODON_CLIENT_KEY": os.getenv("MASTODON_CLIENT_KEY", ""),
"MASTODON_CLIENT_SECRET": os.getenv("MASTODON_CLIENT_SECRET", ""),
} }
legacy_values = _load_legacy_api_keys(Path("api_keys")) legacy_values = _load_legacy_api_keys(Path("api_keys"))

View file

@ -0,0 +1,88 @@
"""Client helpers for posting to Mastodon."""
from __future__ import annotations
from typing import List, Sequence
import requests
class MastodonError(RuntimeError):
"""Raised when the Mastodon API indicates a failure."""
class MastodonClient:
"""Minimal Mastodon API wrapper for uploading media and posting statuses."""
def __init__(self, base_url: str, access_token: str) -> None:
if not base_url:
raise ValueError("base_url is required")
if not access_token:
raise ValueError("access_token is required")
self.base_url = base_url.rstrip("/")
self.session = requests.Session()
self.session.headers.update(
{
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
"User-Agent": "ImmichAltTextHelper/0.1 (+https://photos.4angle.com)",
}
)
def _raise_for_error(self, response: requests.Response) -> None:
try:
payload = response.json()
except Exception: # pragma: no cover - fallback path
payload = response.text
message = f"{response.status_code} {response.reason}: {payload}"
raise MastodonError(message)
def upload_media(
self, filename: str, data: bytes, mime_type: str, alt_text: str
) -> str:
url = f"{self.base_url}/api/v2/media"
files = {
"file": (
filename or "photo.jpg",
data,
mime_type or "application/octet-stream",
)
}
form = {}
if alt_text:
form["description"] = alt_text
try:
response = self.session.post(url, files=files, data=form, timeout=30)
except requests.RequestException as exc: # pragma: no cover - network failure
raise MastodonError(str(exc)) from exc
if not response.ok:
self._raise_for_error(response)
payload = response.json()
media_id = payload.get("id")
if not media_id:
raise MastodonError("Mastodon response missing media id")
return media_id
def create_status(self, text: str, media_ids: Sequence[str]) -> str:
if not text.strip():
raise MastodonError("Post text cannot be empty")
url = f"{self.base_url}/api/v1/statuses"
payload = {
"status": text,
"language": "en",
"media_ids": list(media_ids),
}
try:
response = self.session.post(url, json=payload, timeout=30)
except requests.RequestException as exc: # pragma: no cover
raise MastodonError(str(exc)) from exc
if not response.ok:
self._raise_for_error(response)
data = response.json()
status_id = data.get("id")
if not status_id:
raise MastodonError("Mastodon response missing status id")
return status_id

View file

@ -7,10 +7,18 @@ from typing import Any, Dict, List, Optional
import requests import requests
class AltTextGenerationError(RuntimeError): class OpenAIClientError(RuntimeError):
"""Raised when the OpenAI API cannot fulfill a request."""
class AltTextGenerationError(OpenAIClientError):
"""Raised when the OpenAI API cannot generate alt text.""" """Raised when the OpenAI API cannot generate alt text."""
class TextImprovementError(OpenAIClientError):
"""Raised when the OpenAI API cannot improve post text."""
class AltTextGenerator: class AltTextGenerator:
"""Request alt text from a GPT-4o compatible OpenAI endpoint.""" """Request alt text from a GPT-4o compatible OpenAI endpoint."""
@ -92,3 +100,43 @@ class AltTextGenerator:
raise AltTextGenerationError("OpenAI response missing content") raise AltTextGenerationError("OpenAI response missing content")
return content_text.strip() return content_text.strip()
def improve_post_text(
self, draft_text: str, instructions: Optional[str] = None
) -> str:
if not draft_text or not draft_text.strip():
raise TextImprovementError("Post text cannot be empty")
prompt_parts = [
"You review Mastodon drafts and rewrite them in UK English.",
"Keep the tone warm, accessible, and descriptive without exaggeration.",
"Ensure clarity, fix spelling or grammar, and keep content suitable for social media.",
]
if instructions:
prompt_parts.append(f"Additional instructions: {instructions.strip()}")
prompt_parts.append("Return only the improved post text.")
user_content = f"Draft post:\\n{draft_text.strip()}"
payload = {
"model": self.model,
"temperature": 0.4,
"max_tokens": 400,
"messages": [
{"role": "system", "content": "\n".join(prompt_parts)},
{"role": "user", "content": user_content},
],
}
try:
response = self.session.post(self.endpoint, json=payload, timeout=30)
response.raise_for_status()
data = response.json()
except requests.RequestException as exc: # pragma: no cover
raise TextImprovementError(str(exc)) from exc
choices = data.get("choices") or []
if not choices:
raise TextImprovementError("OpenAI response did not include choices")
content_text = choices[0].get("message", {}).get("content")
if not content_text:
raise TextImprovementError("OpenAI response missing content")
return content_text.strip()

View file

@ -17,8 +17,9 @@ from flask import (
url_for, url_for,
) )
from .immich import ImmichError from .immich import ImmichAsset, ImmichError
from .openai_client import AltTextGenerationError from .mastodon import MastodonError
from .openai_client import AltTextGenerationError, TextImprovementError
bp = Blueprint("main", __name__) bp = Blueprint("main", __name__)
@ -37,7 +38,7 @@ def _humanize_timestamp(raw: str | None) -> str | None:
dt = _parse_timestamp(raw) dt = _parse_timestamp(raw)
if not dt: if not dt:
return raw return raw
return dt.strftime("%d %b %Y / %H:%M") return dt.strftime("%d %b %Y %H:%M")
def _timestamp_with_timezone(raw: str | None) -> str | None: def _timestamp_with_timezone(raw: str | None) -> str | None:
@ -57,6 +58,53 @@ def _timestamp_with_timezone(raw: str | None) -> str | None:
return f"{label} (timezone unknown)" return f"{label} (timezone unknown)"
def _coordinates_text(asset: ImmichAsset) -> str | None:
if asset.latitude is None or asset.longitude is None:
return None
return f"{asset.latitude:.5f}, {asset.longitude:.5f}"
def _generate_alt_text_for_asset(asset: ImmichAsset, notes: str | None = None) -> str:
immich_client = current_app.immich_client
generator = current_app.alt_text_generator
content, mime_type = immich_client.fetch_asset_content(asset.id, "preview")
data_url = "data:{};base64,{}".format(
mime_type, base64.b64encode(content).decode("ascii")
)
return generator.generate_alt_text(
data_url,
notes,
captured_at=_timestamp_with_timezone(asset.captured_at),
location=asset.location_label,
coordinates=_coordinates_text(asset),
)
def _ensure_alt_text(asset: ImmichAsset) -> str:
cache = current_app.alt_text_cache
cached = cache.get(asset.id)
if cached:
return cached
generated = _generate_alt_text_for_asset(asset)
cache.set(asset.id, generated)
return generated
def _unique_asset_ids(values: list[str]) -> list[str]:
seen: set[str] = set()
ordered: list[str] = []
for value in values:
value = (value or "").strip()
if not value or value in seen:
continue
seen.add(value)
ordered.append(value)
return ordered
MAX_MEDIA_ATTACHMENTS = 4
@bp.route("/") @bp.route("/")
def index(): def index():
immich_client = current_app.immich_client immich_client = current_app.immich_client
@ -87,7 +135,6 @@ def index():
def asset_detail(asset_id: str): def asset_detail(asset_id: str):
immich_client = current_app.immich_client immich_client = current_app.immich_client
alt_cache = current_app.alt_text_cache alt_cache = current_app.alt_text_cache
generator = current_app.alt_text_generator
error_message = None error_message = None
notes = "" notes = ""
@ -108,22 +155,7 @@ def asset_detail(asset_id: str):
if request.method == "POST": if request.method == "POST":
notes = request.form.get("notes", "") notes = request.form.get("notes", "")
try: try:
content, mime_type = immich_client.fetch_asset_content(asset_id, "preview") generated = _generate_alt_text_for_asset(asset, notes)
data_url = "data:{};base64,{}".format(
mime_type, base64.b64encode(content).decode("ascii")
)
location_text = asset.location_label
coordinates_text = None
if asset.latitude is not None and asset.longitude is not None:
coordinates_text = f"{asset.latitude:.5f}, {asset.longitude:.5f}"
captured_text = _timestamp_with_timezone(asset.captured_at)
generated = generator.generate_alt_text(
data_url,
notes,
captured_at=captured_text,
location=location_text,
coordinates=coordinates_text,
)
alt_cache.set(asset_id, generated) alt_cache.set(asset_id, generated)
flash("Alt text generated.") flash("Alt text generated.")
return redirect(url_for("main.asset_detail", asset_id=asset_id)) return redirect(url_for("main.asset_detail", asset_id=asset_id))
@ -155,6 +187,169 @@ def asset_detail(asset_id: str):
) )
@bp.route("/compose/select", methods=["GET", "POST"])
def compose_select():
immich_client = current_app.immich_client
alt_cache = current_app.alt_text_cache
days = current_app.config.get("RECENT_DAYS", 3)
asset_objs: list[ImmichAsset] = []
error_message = None
try:
asset_objs = immich_client.get_recent_assets(days=days, limit=200)
except ImmichError as exc:
error_message = str(exc)
assets = []
for asset in asset_objs:
assets.append(
{
"id": asset.id,
"file_name": asset.file_name,
"captured_display": _humanize_timestamp(asset.captured_at),
"alt_text": alt_cache.get(asset.id),
}
)
selected_set: set[str] = set()
if request.method == "POST":
selected = _unique_asset_ids(request.form.getlist("asset_ids"))
selected_set = set(selected)
if not selected:
error_message = "Select at least one photo."
elif len(selected) > MAX_MEDIA_ATTACHMENTS:
error_message = f"Select up to {MAX_MEDIA_ATTACHMENTS} photos."
else:
return redirect(url_for("main.compose_draft", ids=",".join(selected)))
return render_template(
"compose_select.html",
assets=assets,
error_message=error_message,
max_photos=MAX_MEDIA_ATTACHMENTS,
selected_ids=selected_set,
)
@bp.route("/compose/draft", methods=["GET", "POST"])
def compose_draft():
mastodon_client = getattr(current_app, "mastodon_client", None)
immich_client = current_app.immich_client
alt_cache = current_app.alt_text_cache
generator = current_app.alt_text_generator
error_message = None
post_text = ""
instructions = ""
if request.method == "POST":
asset_ids = _unique_asset_ids(request.form.getlist("asset_ids"))
post_text = request.form.get("post_text", "")
instructions = request.form.get("post_instructions", "")
else:
ids_param = request.args.get("ids", "")
asset_ids = _unique_asset_ids(ids_param.split(",") if ids_param else [])
if not asset_ids:
flash("Choose photos before composing a post.")
return redirect(url_for("main.compose_select"))
if len(asset_ids) > MAX_MEDIA_ATTACHMENTS:
flash(f"Select at most {MAX_MEDIA_ATTACHMENTS} photos.")
return redirect(url_for("main.compose_select"))
assets: list[ImmichAsset] = []
try:
for asset_id in asset_ids:
assets.append(immich_client.get_asset(asset_id))
except ImmichError as exc:
return render_template(
"compose_draft.html",
assets=[],
error_message=str(exc),
post_text=post_text,
instructions=instructions,
mastodon_ready=bool(mastodon_client),
)
asset_entries = []
for asset in assets:
if request.method == "POST":
alt_value = request.form.get(f"alt_text_{asset.id}", "")
else:
try:
alt_value = _ensure_alt_text(asset)
except (ImmichError, AltTextGenerationError) as exc:
alt_value = ""
if not error_message:
error_message = str(exc)
asset_entries.append(
{
"id": asset.id,
"file_name": asset.file_name,
"captured_display": _humanize_timestamp(asset.captured_at),
"preview_url": url_for(
"main.asset_proxy", asset_id=asset.id, variant="preview"
),
"alt_text": alt_value,
}
)
if request.method == "POST":
action = request.form.get("action")
if action == "refine":
try:
post_text = generator.improve_post_text(post_text, instructions)
flash("Post refined with ChatGPT.")
except TextImprovementError as exc:
error_message = str(exc)
elif action == "post":
if not mastodon_client:
error_message = "Configure Mastodon access before posting."
elif not post_text.strip():
error_message = "Enter post text before posting."
else:
try:
media_ids: list[str] = []
for entry, asset in zip(asset_entries, assets):
alt_value = entry["alt_text"].strip()
if not alt_value:
raise ValueError(
f"Alt text missing for {entry['file_name']}"
)
entry["alt_text"] = alt_value
alt_cache.set(asset.id, alt_value)
content, mime_type = immich_client.fetch_asset_content(
asset.id, "original"
)
media_id = mastodon_client.upload_media(
asset.file_name, content, mime_type, alt_value
)
media_ids.append(media_id)
mastodon_client.create_status(post_text.strip(), media_ids)
flash("Post sent to Mastodon.")
return redirect(url_for("main.index"))
except ValueError as exc:
error_message = str(exc)
except ImmichError as exc:
error_message = f"Failed to fetch media: {exc}"
except MastodonError as exc:
error_message = str(exc)
elif action:
error_message = "Unknown action."
return render_template(
"compose_draft.html",
assets=asset_entries,
error_message=error_message,
post_text=post_text,
instructions=instructions,
mastodon_ready=bool(mastodon_client),
max_photos=MAX_MEDIA_ATTACHMENTS,
)
@bp.route("/proxy/assets/<asset_id>/<variant>") @bp.route("/proxy/assets/<asset_id>/<variant>")
def asset_proxy(asset_id: str, variant: str): def asset_proxy(asset_id: str, variant: str):
immich_client = current_app.immich_client immich_client = current_app.immich_client

53
static/app.js Normal file
View file

@ -0,0 +1,53 @@
(function () {
function updateOrderLabels(list) {
var items = list.querySelectorAll('.compose-asset');
items.forEach(function (item, index) {
var badge = item.querySelector('.order-badge');
if (badge) {
badge.textContent = 'Photo ' + (index + 1);
}
var upButton = item.querySelector('[data-move="up"]');
var downButton = item.querySelector('[data-move="down"]');
if (upButton) {
upButton.disabled = index === 0;
}
if (downButton) {
downButton.disabled = index === items.length - 1;
}
});
}
document.addEventListener('DOMContentLoaded', function () {
var list = document.querySelector('.compose-form .asset-list');
if (!list) {
return;
}
list.addEventListener('click', function (event) {
var button = event.target.closest('[data-move]');
if (!button) {
return;
}
event.preventDefault();
var direction = button.getAttribute('data-move');
var item = button.closest('.compose-asset');
if (!item || !list.contains(item)) {
return;
}
if (direction === 'up') {
var prev = item.previousElementSibling;
if (prev) {
prev.before(item);
}
} else if (direction === 'down') {
var next = item.nextElementSibling;
if (next) {
next.after(item);
}
}
updateOrderLabels(list);
});
updateOrderLabels(list);
});
})();

View file

@ -22,11 +22,29 @@ body {
border-bottom: 1px solid #2d2d2d; border-bottom: 1px solid #2d2d2d;
} }
.header-bar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
}
.site-header a { .site-header a {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
} }
.nav-links {
display: flex;
gap: 1rem;
font-size: 0.95rem;
}
.nav-links a {
color: #9cc1ff;
}
.flash-messages { .flash-messages {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@ -46,6 +64,28 @@ body {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.warning {
background: #443510;
border: 1px solid #7a5c1c;
padding: 0.8rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.button-link {
display: inline-block;
padding: 0.5rem 1.1rem;
border-radius: 999px;
background: #2b74ff;
color: #fff;
text-decoration: none;
font-weight: 600;
}
.button-link:hover {
background: #1f5fe0;
}
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
@ -117,3 +157,166 @@ body {
.notes-form button:hover { .notes-form button:hover {
background: #1f5fe0; background: #1f5fe0;
} }
.selection-form .select-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.selection-form .actions {
display: flex;
justify-content: flex-end;
margin-bottom: 1rem;
}
.selection-form .actions.top-actions {
margin-top: 0;
}
.selection-form .actions button {
background: #2b74ff;
border: none;
color: #fff;
padding: 0.6rem 1.4rem;
border-radius: 4px;
font-weight: 600;
cursor: pointer;
}
.select-card {
border: 1px solid #2d2d2d;
border-radius: 6px;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
background: #1b1b1b;
cursor: pointer;
}
.select-card input[type=\"checkbox\"] {
width: 1rem;
height: 1rem;
}
.select-card img {
width: 100%;
border-radius: 4px;
border: 1px solid #2d2d2d;
object-fit: cover;
}
.alt-text-sample {
font-size: 0.8rem;
color: #bcbcbc;
margin-top: 0.3rem;
max-height: 4.5rem;
overflow: hidden;
}
.compose-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.asset-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.compose-asset {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
border: 1px solid #2d2d2d;
border-radius: 8px;
padding: 1rem;
background: #151515;
}
.compose-asset img {
width: 100%;
border-radius: 8px;
border: 1px solid #2d2d2d;
}
.compose-asset textarea {
width: 100%;
border-radius: 6px;
border: 1px solid #3b3b3b;
padding: 0.6rem;
background: #121212;
color: inherit;
}
.order-controls {
display: flex;
justify-content: space-between;
align-items: center;
grid-column: 1 / -1;
}
.order-badge {
font-size: 0.9rem;
font-weight: 600;
padding: 0.2rem 0.6rem;
border-radius: 999px;
border: 1px solid #3b3b3b;
}
.move-buttons {
display: flex;
gap: 0.5rem;
}
.move-buttons button {
border: 1px solid #3b3b3b;
background: #1f1f1f;
color: #fff;
padding: 0.3rem 0.6rem;
border-radius: 4px;
cursor: pointer;
}
.move-buttons button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.compose-form textarea {
width: 100%;
border-radius: 6px;
border: 1px solid #3b3b3b;
padding: 0.6rem;
background: #121212;
color: inherit;
}
.compose-actions {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.compose-actions button {
background: #2b74ff;
border: none;
color: #fff;
padding: 0.6rem 1.4rem;
border-radius: 4px;
font-weight: 600;
cursor: pointer;
}
.compose-actions button[name=\"action\"][value=\"post\"] {
background: #2ecc71;
}
.compose-actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View file

@ -9,7 +9,13 @@
<body> <body>
<header class="site-header"> <header class="site-header">
<div class="container"> <div class="container">
<h1><a href="{{ url_for('main.index') }}">Immich Alt Text Helper</a></h1> <div class="header-bar">
<h1><a href="{{ url_for('main.index') }}">Immich Alt Text Helper</a></h1>
<nav class="nav-links">
<a href="{{ url_for('main.index') }}">Recent photos</a>
<a href="{{ url_for('main.compose_select') }}">Compose Mastodon post</a>
</nav>
</div>
</div> </div>
</header> </header>
<main class="container"> <main class="container">
@ -24,5 +30,6 @@
{% endwith %} {% endwith %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<script src="{{ url_for('static', filename='app.js') }}"></script>
</body> </body>
</html> </html>

View file

@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block content %}
<section class="intro">
<h2>Draft Mastodon post</h2>
<p>Review alt text, edit your caption, and ask ChatGPT for phrasing help before posting.</p>
</section>
{% if not mastodon_ready %}
<div class="warning">Mastodon access token missing. You can refine content, but posting is disabled.</div>
{% endif %}
{% if error_message %}
<div class="error">{{ error_message }}</div>
{% endif %}
<form method="post" class="compose-form">
<div class="asset-list">
{% for asset in assets %}
<article class="compose-asset" data-asset-id="{{ asset.id }}">
<input type="hidden" name="asset_ids" value="{{ asset.id }}">
<div class="order-controls">
<span class="order-badge">Photo {{ loop.index }}</span>
<div class="move-buttons">
<button type="button" data-move="up" aria-label="Move photo up"></button>
<button type="button" data-move="down" aria-label="Move photo down"></button>
</div>
</div>
<div class="image">
<img src="{{ asset.preview_url }}" alt="{{ asset.file_name }}">
</div>
<div class="fields">
<h3>{{ asset.file_name }}</h3>
{% if asset.captured_display %}
<p class="date">{{ asset.captured_display }}</p>
{% endif %}
<label for="alt_text_{{ asset.id }}">Alt text</label>
<textarea id="alt_text_{{ asset.id }}" name="alt_text_{{ asset.id }}" rows="4">{{ asset.alt_text }}</textarea>
</div>
</article>
{% endfor %}
</div>
<div class="post-text">
<label for="post_text">Mastodon post text</label>
<textarea id="post_text" name="post_text" rows="6" placeholder="Compose your post here.">{{ post_text }}</textarea>
</div>
<div class="post-instructions">
<label for="post_instructions">Instructions for ChatGPT (optional)</label>
<textarea id="post_instructions" name="post_instructions" rows="3" placeholder="Mention tone, audience, hashtags, etc.">{{ instructions }}</textarea>
</div>
<div class="compose-actions">
<button type="submit" name="action" value="refine">Improve text with ChatGPT</button>
<button type="submit" name="action" value="post" {% if not mastodon_ready %}disabled{% endif %}>Post to Mastodon</button>
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block content %}
<section class="intro">
<h2>Select photos for your Mastodon post</h2>
<p>Choose between 1 and {{ max_photos }} recent Immich photos. We'll pull in cached alt text where possible.</p>
</section>
{% if error_message %}
<div class="error">{{ error_message }}</div>
{% endif %}
{% if assets %}
<form method="post" class="selection-form">
<div class="actions top-actions">
<button type="submit">Use selected photos</button>
</div>
<div class="select-grid">
{% for asset in assets %}
<label class="select-card">
<input type="checkbox" name="asset_ids" value="{{ asset.id }}" {% if selected_ids and asset.id in selected_ids %}checked{% endif %}>
<div class="thumbnail">
<img src="{{ url_for('main.asset_proxy', asset_id=asset.id, variant='thumbnail') }}" alt="{{ asset.file_name }}">
</div>
<div class="meta">
<div class="file-name">{{ asset.file_name }}</div>
{% if asset.captured_display %}
<div class="date">{{ asset.captured_display }}</div>
{% endif %}
{% if asset.alt_text %}
<div class="alt-text-sample">{{ asset.alt_text }}</div>
{% endif %}
</div>
</label>
{% endfor %}
</div>
<div class="actions">
<button type="submit">Use selected photos</button>
</div>
</form>
{% else %}
<p>No assets available right now.</p>
{% endif %}
{% endblock %}

View file

@ -3,6 +3,7 @@
{% block content %} {% block content %}
<section class="intro"> <section class="intro">
<p>Choose a recent Immich photo to request Mastodon-friendly alt text.</p> <p>Choose a recent Immich photo to request Mastodon-friendly alt text.</p>
<p><a class="button-link" href="{{ url_for('main.compose_select') }}">Compose a Mastodon post</a></p>
</section> </section>
{% if error_message %} {% if error_message %}