From eada9049fa6735cee2a4b0f7c860bebf79c6b862 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Sat, 15 Nov 2025 12:48:36 +0000 Subject: [PATCH] New mastodon post feature --- .gitignore | 1 + README.md | 11 +- gen_photo_alt_text/__init__.py | 9 ++ gen_photo_alt_text/config.py | 6 + gen_photo_alt_text/mastodon.py | 88 +++++++++++ gen_photo_alt_text/openai_client.py | 50 +++++- gen_photo_alt_text/routes.py | 235 +++++++++++++++++++++++++--- static/app.js | 53 +++++++ static/styles.css | 203 ++++++++++++++++++++++++ templates/base.html | 9 +- templates/compose_draft.html | 59 +++++++ templates/compose_select.html | 44 ++++++ templates/index.html | 1 + 13 files changed, 746 insertions(+), 23 deletions(-) create mode 100644 gen_photo_alt_text/mastodon.py create mode 100644 static/app.js create mode 100644 templates/compose_draft.html create mode 100644 templates/compose_select.html diff --git a/.gitignore b/.gitignore index b178cb5..8fbbdcc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ api_keys .mypy_cache __pycache__ instance +.env diff --git a/README.md b/README.md index a27091a..8c70c2f 100644 --- a/README.md +++ b/README.md @@ -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_KEY=your-immich-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: @@ -19,6 +21,13 @@ A small Flask app that fetches recent Immich photos and helps generate high-qual flask --app app run --debug ``` -3. Visit to browse recent Immich photos, view cached alt text, and request new alt text guided by optional notes. +3. Visit 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. diff --git a/gen_photo_alt_text/__init__.py b/gen_photo_alt_text/__init__.py index ff20635..d4cbb1d 100644 --- a/gen_photo_alt_text/__init__.py +++ b/gen_photo_alt_text/__init__.py @@ -8,6 +8,7 @@ from .cache import AltTextCache from .config import load_settings from .immich import ImmichClient from .openai_client import AltTextGenerator +from .mastodon import MastodonClient def create_app() -> Flask: @@ -42,6 +43,14 @@ def create_app() -> Flask: api_key=app.config["OPENAI_API_KEY"], 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 diff --git a/gen_photo_alt_text/config.py b/gen_photo_alt_text/config.py index 581e332..703d656 100644 --- a/gen_photo_alt_text/config.py +++ b/gen_photo_alt_text/config.py @@ -54,6 +54,12 @@ def load_settings() -> Dict[str, str]: "OPENAI_MODEL": os.getenv("OPENAI_MODEL", "gpt-4o-mini"), "ALT_TEXT_DB": os.getenv("ALT_TEXT_DB", ""), "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")) diff --git a/gen_photo_alt_text/mastodon.py b/gen_photo_alt_text/mastodon.py new file mode 100644 index 0000000..bf7c261 --- /dev/null +++ b/gen_photo_alt_text/mastodon.py @@ -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 diff --git a/gen_photo_alt_text/openai_client.py b/gen_photo_alt_text/openai_client.py index baee5f6..1afae69 100644 --- a/gen_photo_alt_text/openai_client.py +++ b/gen_photo_alt_text/openai_client.py @@ -7,10 +7,18 @@ from typing import Any, Dict, List, Optional 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.""" +class TextImprovementError(OpenAIClientError): + """Raised when the OpenAI API cannot improve post text.""" + + class AltTextGenerator: """Request alt text from a GPT-4o compatible OpenAI endpoint.""" @@ -92,3 +100,43 @@ class AltTextGenerator: raise AltTextGenerationError("OpenAI response missing content") 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() diff --git a/gen_photo_alt_text/routes.py b/gen_photo_alt_text/routes.py index 6eacb2a..d2bf916 100644 --- a/gen_photo_alt_text/routes.py +++ b/gen_photo_alt_text/routes.py @@ -17,8 +17,9 @@ from flask import ( url_for, ) -from .immich import ImmichError -from .openai_client import AltTextGenerationError +from .immich import ImmichAsset, ImmichError +from .mastodon import MastodonError +from .openai_client import AltTextGenerationError, TextImprovementError bp = Blueprint("main", __name__) @@ -37,7 +38,7 @@ def _humanize_timestamp(raw: str | None) -> str | None: dt = _parse_timestamp(raw) if not dt: 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: @@ -57,6 +58,53 @@ def _timestamp_with_timezone(raw: str | None) -> str | None: 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("/") def index(): immich_client = current_app.immich_client @@ -87,7 +135,6 @@ def index(): def asset_detail(asset_id: str): immich_client = current_app.immich_client alt_cache = current_app.alt_text_cache - generator = current_app.alt_text_generator error_message = None notes = "" @@ -108,22 +155,7 @@ def asset_detail(asset_id: str): if request.method == "POST": notes = request.form.get("notes", "") try: - content, mime_type = immich_client.fetch_asset_content(asset_id, "preview") - 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, - ) + generated = _generate_alt_text_for_asset(asset, notes) alt_cache.set(asset_id, generated) flash("Alt text generated.") 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//") def asset_proxy(asset_id: str, variant: str): immich_client = current_app.immich_client diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..abd18b9 --- /dev/null +++ b/static/app.js @@ -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); + }); +})(); diff --git a/static/styles.css b/static/styles.css index e89aad7..e37ef6c 100644 --- a/static/styles.css +++ b/static/styles.css @@ -22,11 +22,29 @@ body { border-bottom: 1px solid #2d2d2d; } +.header-bar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; +} + .site-header a { color: inherit; text-decoration: none; } +.nav-links { + display: flex; + gap: 1rem; + font-size: 0.95rem; +} + +.nav-links a { + color: #9cc1ff; +} + .flash-messages { margin-bottom: 1rem; } @@ -46,6 +64,28 @@ body { 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 { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); @@ -117,3 +157,166 @@ body { .notes-form button:hover { 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; +} diff --git a/templates/base.html b/templates/base.html index a3de71e..383d558 100644 --- a/templates/base.html +++ b/templates/base.html @@ -9,7 +9,13 @@
@@ -24,5 +30,6 @@ {% endwith %} {% block content %}{% endblock %}
+ diff --git a/templates/compose_draft.html b/templates/compose_draft.html new file mode 100644 index 0000000..945f91b --- /dev/null +++ b/templates/compose_draft.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} + +{% block content %} +
+

Draft Mastodon post

+

Review alt text, edit your caption, and ask ChatGPT for phrasing help before posting.

+
+ + {% if not mastodon_ready %} +
Mastodon access token missing. You can refine content, but posting is disabled.
+ {% endif %} + + {% if error_message %} +
{{ error_message }}
+ {% endif %} + +
+
+ {% for asset in assets %} +
+ +
+ Photo {{ loop.index }} +
+ + +
+
+
+ {{ asset.file_name }} +
+
+

{{ asset.file_name }}

+ {% if asset.captured_display %} +

{{ asset.captured_display }}

+ {% endif %} + + +
+
+ {% endfor %} +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+{% endblock %} diff --git a/templates/compose_select.html b/templates/compose_select.html new file mode 100644 index 0000000..fe9c09c --- /dev/null +++ b/templates/compose_select.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} + +{% block content %} +
+

Select photos for your Mastodon post

+

Choose between 1 and {{ max_photos }} recent Immich photos. We'll pull in cached alt text where possible.

+
+ + {% if error_message %} +
{{ error_message }}
+ {% endif %} + + {% if assets %} +
+
+ +
+
+ {% for asset in assets %} + + {% endfor %} +
+
+ +
+
+ {% else %} +

No assets available right now.

+ {% endif %} +{% endblock %} diff --git a/templates/index.html b/templates/index.html index 1a8f9dd..d644020 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3,6 +3,7 @@ {% block content %}

Choose a recent Immich photo to request Mastodon-friendly alt text.

+

Compose a Mastodon post

{% if error_message %}