All checks were successful
deploy / deploy (push) Successful in 43s
Adds comprehensive documentation for: - Page versions table schema and draft columns - Image soft delete with tiered logic (block/warn/allow) - Trash/recycle bin with restore and permanent delete - Version retention policy (all/30d, weekly/90d, monthly/365d) - Storage math and pruning cascade - GDPR permanent erase option Updates PROGRESS.md with complete 7-phase breakdown (~31h total) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
607 lines
23 KiB
Markdown
607 lines
23 KiB
Markdown
# Draft-then-publish workflow
|
||
|
||
**Status:** Planned (after unified-editing-mode)
|
||
|
||
## Problem
|
||
|
||
Current editing workflow uses explicit save — changes are either saved (immediately live) or lost on navigation. This creates:
|
||
|
||
1. **Data loss anxiety** — navigate away accidentally, lose all work
|
||
2. **`beforeunload` warnings** — browser-native dialogs with no custom options
|
||
3. **No experimentation** — can't safely try changes without affecting live site
|
||
|
||
## Solution
|
||
|
||
Implement a draft-then-publish model with **site-level publishing** and **version history**:
|
||
|
||
1. **Site starts unpublished** — New sites are in draft mode until first publish
|
||
2. **Auto-save drafts** — All edits auto-save, no risk of lost work
|
||
3. **Visitors see published only** — No accidental exposure of drafts
|
||
4. **Version history** — Each publish creates a snapshot, enabling rollback
|
||
5. **URLs are versioned** — URL slugs are part of version data (ties into dynamic URL customisation)
|
||
|
||
## Design principles
|
||
|
||
1. **Site-level lifecycle** — Site has a `first_published_at` timestamp; until then, visitors see "coming soon"
|
||
2. **Drafts are per-user** — each admin sees their own pending changes
|
||
3. **Visitors always see published** — no accidental exposure of drafts
|
||
4. **Auto-save constantly** — no risk of lost work
|
||
5. **Clear visual distinction** — obvious when viewing draft vs published
|
||
6. **Single publish action** — one button publishes all pending changes
|
||
7. **Version snapshots** — each publish creates a version record for rollback
|
||
8. **Atomic rollback** — rolling back restores content AND URL (redirect chain updates automatically)
|
||
|
||
## Scope
|
||
|
||
Three types of editable content:
|
||
|
||
| Type | Storage | Draft approach |
|
||
|------|---------|----------------|
|
||
| Page blocks | `pages.blocks` (JSON) | `draft_blocks` column |
|
||
| Theme settings | `settings.theme_settings` (JSON) | `draft_theme_settings` key |
|
||
| Shop settings | `settings` table (various keys) | `draft_*` keys or `settings_drafts` table |
|
||
|
||
## Data model
|
||
|
||
### Site-level publishing
|
||
|
||
```elixir
|
||
# Add to settings or dedicated sites table
|
||
# Option 1: Settings key
|
||
%{"site_first_published_at" => nil} # nil = unpublished, datetime = published
|
||
|
||
# Option 2: Dedicated sites table (if multi-tenant later)
|
||
create table(:sites, primary_key: false) do
|
||
add :id, :binary_id, primary_key: true
|
||
add :first_published_at, :utc_datetime_usec # nil until first publish
|
||
timestamps(type: :utc_datetime_usec)
|
||
end
|
||
```
|
||
|
||
### Page versions (recommended approach)
|
||
|
||
Rather than draft columns, use a proper version table. Each publish creates a version snapshot.
|
||
|
||
```elixir
|
||
create table(:page_versions, primary_key: false) do
|
||
add :id, :binary_id, primary_key: true
|
||
add :page_id, references(:pages, type: :binary_id, on_delete: :delete_all), null: false
|
||
add :version_number, :integer, null: false
|
||
add :blocks, :map, null: false
|
||
add :url_slug, :string # URL is part of version data
|
||
add :meta_title, :string
|
||
add :meta_description, :string
|
||
add :published_at, :utc_datetime_usec
|
||
add :published_by_id, references(:users, type: :binary_id, on_delete: :nilify_all)
|
||
timestamps(type: :utc_datetime_usec)
|
||
end
|
||
|
||
create index(:page_versions, [:page_id, :version_number])
|
||
create unique_index(:page_versions, [:page_id, :version_number])
|
||
```
|
||
|
||
```elixir
|
||
# Pages table changes
|
||
alter table(:pages) do
|
||
add :published_version_id, references(:page_versions, type: :binary_id, on_delete: :nilify_all)
|
||
add :draft_blocks, :map
|
||
add :draft_url_slug, :string
|
||
add :draft_user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
|
||
add :draft_updated_at, :utc_datetime_usec
|
||
end
|
||
```
|
||
|
||
**How it works:**
|
||
- `pages.draft_*` fields hold current work-in-progress
|
||
- On publish: create new `page_versions` record, update `pages.published_version_id`
|
||
- On rollback: set `published_version_id` to older version, update redirect chain
|
||
- Visitors always see the version pointed to by `published_version_id`
|
||
|
||
### URL versioning and redirects
|
||
|
||
When a page version has a different URL than the current published version:
|
||
1. The redirect system tracks URL history
|
||
2. All old URLs redirect to current canonical URL
|
||
3. On rollback, old URL becomes canonical again, redirect chain updates
|
||
|
||
```elixir
|
||
# Example: Page starts at /about, renamed to /about-us, then rolled back
|
||
|
||
# Version 1: url_slug = "about" <- created
|
||
# Version 2: url_slug = "about-us" <- published, redirect /about → /about-us created
|
||
# Rollback to v1: redirect /about-us → /about created (or /about redirect deleted)
|
||
```
|
||
|
||
This ties directly into the [dynamic URL customisation plan](dynamic-url-customisation.md).
|
||
|
||
## Implementation
|
||
|
||
### Phase 0: Site-level publishing
|
||
|
||
| Task | Est | Notes |
|
||
|------|-----|-------|
|
||
| Add `site_first_published_at` setting | 15m | nil until first publish |
|
||
| Update coming soon page to check this setting | 30m | Show until site is published |
|
||
| Add "Publish site" button to admin dashboard | 30m | First-time publish action |
|
||
| Hide "Publish site" after first publish | 15m | One-time action |
|
||
|
||
### Phase 1: Page versions and drafts
|
||
|
||
| Task | Est | Notes |
|
||
|------|-----|-------|
|
||
| Create page_versions table | 30m | Migration with version data |
|
||
| Add draft columns and published_version_id to pages | 30m | Migration |
|
||
| Create PageVersions context module | 1.5h | `create_version/2`, `rollback_to/2`, `list_versions/1` |
|
||
| Update Pages context with draft functions | 1h | `save_draft/2`, `publish_draft/1`, `discard_draft/1`, `has_draft?/1` |
|
||
| Modify PageEditorHook to auto-save drafts | 1h | Debounced save on every change |
|
||
| Add "Publish" and "Discard" buttons to editor | 1h | Replace current "Save" |
|
||
| Show draft indicator when viewing page with unpublished changes | 1h | Banner or badge |
|
||
| Remove `beforeunload` warning (no longer needed) | 15m | Drafts persist |
|
||
|
||
### Phase 2: Theme drafts
|
||
|
||
| Task | Est | Notes |
|
||
|------|-----|-------|
|
||
| Add draft_theme_settings to settings | 30m | New key |
|
||
| Update Settings context with theme draft functions | 1h | Similar to pages |
|
||
| Modify theme editor to auto-save drafts | 1h | |
|
||
| Theme preview shows draft, shop shows published | 1h | Conditional CSS injection |
|
||
| Add publish/discard to theme editor | 30m | |
|
||
|
||
### Phase 3: Settings drafts
|
||
|
||
| Task | Est | Notes |
|
||
|------|-----|-------|
|
||
| Decide which settings are draftable | 30m | Not all need drafts (e.g. API keys) |
|
||
| Add draft handling to Settings context | 1.5h | |
|
||
| Update settings editor to use drafts | 1h | |
|
||
| Unified "Publish all" option | 1h | Publish page + theme + settings together |
|
||
|
||
### Phase 4: Version history and rollback
|
||
|
||
| Task | Est | Notes |
|
||
|------|-----|-------|
|
||
| Version history panel in editor | 1.5h | List of published versions with timestamps |
|
||
| Rollback action with confirmation | 1h | "Revert to version 3?" |
|
||
| URL redirect chain update on rollback | 1h | Update redirects to point to restored URL |
|
||
| Version comparison view (stretch) | 2h | Side-by-side diff |
|
||
|
||
### Phase 5: Image soft delete and trash
|
||
|
||
| Task | Est | Notes |
|
||
|------|-----|-------|
|
||
| Add `deleted_at` column to images table | 15m | Migration |
|
||
| Implement `image_usage/1` check | 45m | Check current pages, settings, reviews, old versions |
|
||
| Update `delete_image/1` with tiered logic | 30m | Block/warn/allow based on usage |
|
||
| Add Trash tab to media library | 1h | List soft-deleted images with restore/delete buttons |
|
||
| Filter soft-deleted from main library and picker | 15m | Hidden but still serves |
|
||
| Add restore and permanent delete actions | 30m | Trash tab buttons |
|
||
| Create `PruneImagesWorker` Oban job | 45m | Hard delete after 30 days |
|
||
| Add GDPR "Permanently erase" option | 30m | Rewrites versions, immediate purge |
|
||
|
||
### Phase 6: Polish and pruning
|
||
|
||
| Task | Est | Notes |
|
||
|------|-----|-------|
|
||
| Draft age indicator ("Last saved 5 mins ago") | 30m | |
|
||
| Draft conflict handling (stale draft warning) | 1h | Edge case: published changed since draft created |
|
||
| Create `PruneVersionsWorker` Oban job | 1.5h | Tiered retention: all/30d, weekly/90d, monthly/365d |
|
||
| Add version retention settings | 30m | Admin configurable limits |
|
||
| Storage stats on admin dashboard | 45m | Image storage by type, pending purges |
|
||
|
||
## UX flow
|
||
|
||
### First-time site publish
|
||
|
||
1. New shop completes setup wizard
|
||
2. Site is in "coming soon" mode — visitors see placeholder
|
||
3. Admin edits pages, theme, settings (all auto-saved as drafts)
|
||
4. Admin clicks "Publish site" on dashboard
|
||
5. Site goes live — `first_published_at` set, all current drafts become version 1
|
||
|
||
### Editing a page
|
||
|
||
1. Admin navigates to page, clicks "Edit"
|
||
2. Editor panel opens, showing current published state
|
||
3. Admin makes changes — **auto-saved as draft every few seconds**
|
||
4. Admin can navigate away freely — draft persists
|
||
5. Admin returns later — draft loaded automatically
|
||
6. "Publish" button commits draft → new version
|
||
7. "Discard" button deletes draft, reverts to current published version
|
||
|
||
### Rolling back a page
|
||
|
||
1. Admin opens version history in editor
|
||
2. Sees list: "Version 3 (current) — 2 hours ago", "Version 2 — yesterday", etc.
|
||
3. Clicks "Revert to version 2"
|
||
4. Confirmation: "This will restore version 2 content and URL. Continue?"
|
||
5. On confirm: `published_version_id` updated, redirect chain adjusted
|
||
6. Current draft (if any) is NOT affected — can discard or keep editing
|
||
|
||
### Visual indicators
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────┐
|
||
│ ⚠️ You have unpublished changes (last saved 2 mins ago) │
|
||
│ [Publish] [Discard] │
|
||
└─────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
When admin views a page with a draft:
|
||
- Banner at top of editor panel
|
||
- "Unpublished" badge next to page title
|
||
- Different background tint in editor (subtle)
|
||
|
||
### Visitors see published only
|
||
|
||
The `render_page/1` function checks:
|
||
1. If admin is viewing AND has draft → show draft
|
||
2. Otherwise → show published `blocks`
|
||
|
||
```elixir
|
||
defp get_blocks_for_render(page, current_user) do
|
||
if current_user && page.draft_user_id == current_user.id && page.draft_blocks do
|
||
page.draft_blocks
|
||
else
|
||
page.blocks
|
||
end
|
||
end
|
||
```
|
||
|
||
## Migration path
|
||
|
||
1. Deploy with draft support disabled (feature flag)
|
||
2. Run migration to add draft columns
|
||
3. Enable draft mode
|
||
4. Existing pages continue working (no draft = show published)
|
||
5. New edits create drafts
|
||
|
||
## Image lifecycle and versioning
|
||
|
||
Page blocks reference images by `image_id` (UUID), not by embedding image data. This design works well with versioning:
|
||
|
||
### Two types of images
|
||
|
||
| Type | Source | Versioned? | Delete behaviour |
|
||
|------|--------|------------|------------------|
|
||
| **Editorial images** | Admin uploads | Referenced by ID in page versions | Soft delete, protected while referenced |
|
||
| **Product images** | Synced from Printify/Printful | Not versioned (provider is source of truth) | Independent lifecycle |
|
||
|
||
### Editorial images (Media.Image)
|
||
|
||
Used in page blocks (hero images, image_text blocks, etc.). Referenced by `image_id` in block settings:
|
||
|
||
```elixir
|
||
%{"type" => "image_text", "settings" => %{"image_id" => "abc-123", ...}}
|
||
```
|
||
|
||
**Versioning behaviour:**
|
||
- Page version snapshots store `image_id` references, not image data
|
||
- Rollback restores the reference — image should exist (shows placeholder if deleted)
|
||
- Images on CURRENT pages are protected (deletion blocked)
|
||
- Images only in OLD versions can be deleted with warning (versions show placeholder)
|
||
|
||
**Delete scenarios:**
|
||
|
||
| Scenario | Behaviour |
|
||
|----------|-----------|
|
||
| Image on CURRENT pages/settings | **Blocked** — "Used in Homepage, About. Remove from pages first." |
|
||
| Image only in OLD VERSIONS | **Warning → Trash** — "Not on live pages, but in 3 old versions. They'll show placeholder." |
|
||
| Image not used anywhere | **Trash** — silent move to trash |
|
||
| GDPR/privacy erase | **Permanent** — rewrites versions, purges cache, immediate |
|
||
|
||
All "trash" scenarios result in soft delete:
|
||
- Image hidden from library and picker
|
||
- Image **still serves** via URL (30-day grace period)
|
||
- Can be restored from trash
|
||
- Auto hard-deleted after 30 days
|
||
|
||
**Why this model?**
|
||
- Current pages are editable — user can remove the image, then delete it
|
||
- Old versions are immutable — can't ask user to edit them, so just warn
|
||
- Live visitor experience protected — only current page usage blocks deletion
|
||
- Trash gives recovery option for all deletions
|
||
|
||
**What happens when an image is missing?**
|
||
|
||
If a page references a deleted `image_id`, the renderer returns `nil` and the template shows a placeholder:
|
||
|
||
```heex
|
||
<%= if @image_url do %>
|
||
<img src={@image_url} />
|
||
<% else %>
|
||
<div class="image-placeholder">Image unavailable</div>
|
||
<% end %>
|
||
```
|
||
|
||
This only happens with force delete — normal deletion is blocked while references exist.
|
||
|
||
**Soft delete implementation:**
|
||
|
||
```elixir
|
||
# Migration
|
||
alter table(:images) do
|
||
add :deleted_at, :utc_datetime_usec
|
||
end
|
||
|
||
# Context
|
||
def delete_image(image) do
|
||
if image_in_use?(image.id) do
|
||
{:error, :in_use, usage_description(image.id)}
|
||
else
|
||
image |> change(%{deleted_at: DateTime.utc_now()}) |> Repo.update()
|
||
end
|
||
end
|
||
|
||
def image_usage(image_id) do
|
||
# Returns structured usage info for delete decision
|
||
%{
|
||
# CURRENT usage (blocks deletion)
|
||
current_pages: pages_using_image(image_id), # ["Homepage", "About"]
|
||
current_settings: settings_using_image(image_id), # ["logo", "header"]
|
||
product_images: product_images_using(image_id), # count
|
||
reviews: reviews_using_image(image_id), # count
|
||
|
||
# HISTORICAL usage (warning only)
|
||
old_versions: versions_using_image(image_id) # count
|
||
}
|
||
end
|
||
|
||
def can_delete_image?(image_id) do
|
||
usage = image_usage(image_id)
|
||
|
||
cond do
|
||
usage.current_pages != [] ->
|
||
{:blocked, "Used in #{Enum.join(usage.current_pages, ", ")}. Edit those pages first."}
|
||
|
||
usage.current_settings != [] ->
|
||
{:blocked, "Used as #{Enum.join(usage.current_settings, ", ")}. Change in settings first."}
|
||
|
||
usage.product_images > 0 ->
|
||
{:blocked, "Linked to #{usage.product_images} product image(s)."}
|
||
|
||
usage.reviews > 0 ->
|
||
{:blocked, "Used in #{usage.reviews} review(s)."}
|
||
|
||
usage.old_versions > 0 ->
|
||
{:warn, "Appears in #{usage.old_versions} old version(s). They'll show placeholder."}
|
||
|
||
true ->
|
||
:ok
|
||
end
|
||
end
|
||
|
||
# Periodic cleanup workers
|
||
def find_orphaned_images() do
|
||
# Find images not referenced anywhere AND not soft-deleted
|
||
# These accumulate when versions are pruned
|
||
Image
|
||
|> where([i], is_nil(i.deleted_at))
|
||
|> Repo.all()
|
||
|> Enum.reject(&image_in_use?(&1.id))
|
||
end
|
||
|
||
def purge_soft_deleted_images() do
|
||
# Hard delete images that were soft-deleted > 30 days ago
|
||
Image
|
||
|> where([i], not is_nil(i.deleted_at))
|
||
|> where([i], i.deleted_at < ago(30, "day"))
|
||
|> Repo.all()
|
||
|> Enum.each(&hard_delete_image/1)
|
||
end
|
||
```
|
||
|
||
**Media library delete UX:**
|
||
|
||
```
|
||
User clicks "Delete" on image
|
||
│
|
||
├── Image on CURRENT pages/settings?
|
||
│ └── BLOCKED: "This image is used in Homepage and About page.
|
||
│ Edit those pages to use a different image, then delete."
|
||
│
|
||
├── Image only in OLD VERSIONS (not current)?
|
||
│ └── WARNING: "This image isn't on any live pages, but appears in
|
||
│ 3 old page versions. Those versions will show
|
||
│ 'Image removed' if you roll back to them."
|
||
│ [Move to trash] [Cancel]
|
||
│
|
||
└── Image not used anywhere
|
||
└── Move to trash (soft delete)
|
||
```
|
||
|
||
**Trash/recycle bin:**
|
||
|
||
Media library has two tabs: `[All images] [Trash (3)]`
|
||
|
||
Trash tab shows:
|
||
- Soft-deleted images with thumbnails
|
||
- "Deleted 5 days ago — auto-deletes in 25 days"
|
||
- [Restore] button — sets `deleted_at` to nil, back in library
|
||
- [Delete permanently] button — hard delete, URL returns 404
|
||
|
||
**Soft delete behaviour:**
|
||
- Image hidden from main library and image picker
|
||
- Image **still serves** via `/image_cache/:id` (grace period)
|
||
- Auto hard-deleted after 30 days by `PruneImagesWorker`
|
||
|
||
**Key UX principles:**
|
||
|
||
1. **Can't break live pages** — deletion blocked while image is on current pages
|
||
2. **Old versions are read-only** — can't edit them, so deletion allowed with warning
|
||
3. **Familiar trash pattern** — easy recovery, clear timeline
|
||
4. **URLs work during grace period** — soft-deleted images still serve
|
||
|
||
**For GDPR/privacy erasure:**
|
||
|
||
Add "Permanently erase" option (separate from normal delete):
|
||
- Removes image from ALL versions (rewrites history)
|
||
- Purges from image cache immediately
|
||
- No 30-day grace period
|
||
- Requires confirmation: "This cannot be undone and will affect 3 page versions"
|
||
|
||
This handles the case where someone uploaded something they shouldn't have and needs it truly gone.
|
||
|
||
### Product images (synced from providers)
|
||
|
||
Current flow:
|
||
1. `ProductSyncWorker` syncs products from Printify/Printful
|
||
2. `ProductImage` created with `src` (CDN URL), `image_id` = nil
|
||
3. `ImageDownloadWorker` downloads from CDN → creates `Media.Image` → links via `image_id`
|
||
4. `ProductImage.url/2` prefers local `image_id`, falls back to CDN `src`
|
||
|
||
**Cleanup on sync:** When a product image URL changes, the old `Media.Image` is **hard deleted** immediately. This is correct because:
|
||
- Provider is source of truth — their update is canonical
|
||
- CDN URLs are the key, not local image IDs
|
||
- Re-downloading from the same URL would create a fresh image anyway
|
||
- No user-facing "undo" exists for product image changes
|
||
|
||
**Why product images aren't versioned:**
|
||
- Provider controls the lifecycle, not you
|
||
- Product data can change independently of page edits
|
||
- Syncs can happen at any time (manual or webhook-triggered)
|
||
|
||
**Product references in page blocks:**
|
||
Product blocks store `product_id`, not product data:
|
||
|
||
```elixir
|
||
%{"type" => "featured_product", "settings" => %{"product_id" => "prod-123"}}
|
||
```
|
||
|
||
Rollback restores the product reference. The CURRENT product data is displayed (not a snapshot). If the product was deleted, show "Product no longer available" gracefully.
|
||
|
||
### Version diff and images
|
||
|
||
When showing what changed between versions, detect image changes:
|
||
|
||
```elixir
|
||
def diff_block_images(old_block, new_block) do
|
||
old_image = get_in(old_block, ["settings", "image_id"])
|
||
new_image = get_in(new_block, ["settings", "image_id"])
|
||
|
||
cond do
|
||
old_image == new_image -> nil
|
||
is_nil(old_image) -> %{type: :image_added, image_id: new_image}
|
||
is_nil(new_image) -> %{type: :image_removed, image_id: old_image}
|
||
true -> %{type: :image_changed, old: old_image, new: new_image}
|
||
end
|
||
end
|
||
```
|
||
|
||
In the diff UI, show thumbnail previews of old/new images for visual comparison.
|
||
|
||
### Version retention and storage
|
||
|
||
**Version retention policy:**
|
||
|
||
| Time period | Retention | Example |
|
||
|-------------|-----------|---------|
|
||
| Last 30 days | All versions | 15 versions if publishing twice daily |
|
||
| 31-90 days | 1 per week | ~8 versions |
|
||
| 91-365 days | 1 per month | ~9 versions |
|
||
| Older | Deleted | — |
|
||
|
||
Maximum versions per page: ~32 (assuming active editing). Configurable via settings.
|
||
|
||
**Storage math for page versions:**
|
||
|
||
Page version snapshots are lightweight — just JSON with block definitions and UUIDs:
|
||
|
||
```
|
||
Typical page JSON: 5-20 KB
|
||
32 versions × 20 KB = 640 KB per page
|
||
50 pages × 640 KB = 32 MB total version storage
|
||
```
|
||
|
||
This is negligible compared to image storage.
|
||
|
||
**Storage math for editorial images:**
|
||
|
||
Images are the real storage concern. Key insight: **images are stored once, referenced many times**.
|
||
|
||
```
|
||
Typical editorial image: 500 KB - 2 MB source + variants
|
||
Average: ~1.5 MB per image (including WebP/AVIF variants)
|
||
|
||
Scenario A: Page uses same image across 10 versions
|
||
Storage: 1.5 MB (image stored once, referenced 10 times)
|
||
|
||
Scenario B: Page changes image every version, 10 versions
|
||
Storage: 15 MB (10 different images, each stored once)
|
||
```
|
||
|
||
The soft-delete protection means old images stick around until:
|
||
1. No page version references them, AND
|
||
2. 30-day grace period passes
|
||
|
||
**Worst case:** User uploads new hero image every publish for a year
|
||
- 52 weeks × 1.5 MB = 78 MB for one page's hero images
|
||
- After version pruning kicks in: ~32 versions × 1.5 MB = 48 MB
|
||
|
||
**Typical case:** Hero image changes 2-3 times per year
|
||
- 3 images × 1.5 MB = 4.5 MB
|
||
|
||
**Pruning cascades to images:**
|
||
|
||
When `PruneVersionsWorker` deletes old versions:
|
||
|
||
1. Version record deleted (e.g., version 5 from 8 months ago)
|
||
2. Image X was only referenced by version 5
|
||
3. `image_in_use?("X")` now returns `false`
|
||
4. Image X is now **orphaned** — not soft-deleted, just unreferenced
|
||
5. `PruneImagesWorker` finds orphaned images and soft-deletes them
|
||
6. After 30-day grace period, soft-deleted images are hard deleted
|
||
|
||
**Key distinction:**
|
||
- Images referenced by ANY existing version are **protected** (cannot be deleted)
|
||
- Images become orphaned only when ALL referencing versions are pruned
|
||
- The 30-day grace period is for recovering from accidental deletion, not for version retention
|
||
|
||
This means image storage naturally follows version retention — if you keep 32 versions max, you keep at most the images referenced by those 32 versions.
|
||
|
||
**Admin visibility:**
|
||
|
||
Add storage stats to admin dashboard:
|
||
- Total image storage (MB)
|
||
- Images by type (editorial, product, review)
|
||
- Soft-deleted images pending purge
|
||
- "Clean up now" button for force purge
|
||
|
||
## Future considerations
|
||
|
||
- **Multi-user drafts** — currently single draft per page, could extend to per-user drafts
|
||
- **Draft preview link** — shareable URL that shows draft to non-admins (for review)
|
||
- **Scheduled publishing** — "Publish at 9am tomorrow"
|
||
- **Theme/settings versioning** — extend version history to theme and shop settings
|
||
- **Bulk rollback** — "Revert entire site to yesterday" across all pages
|
||
- **Image version history** — track when images were replaced (not just which version used them)
|
||
|
||
## Dependencies
|
||
|
||
- Unified editing mode (phases 1-7) must be complete first
|
||
- This work builds on the editor panel UI established there
|
||
|
||
## Related Plans
|
||
|
||
- [dynamic-url-customisation.md](dynamic-url-customisation.md) — URLs are versioned data; these plans integrate cleanly
|
||
- Can be implemented in either order, but implementing together gives the cleanest result
|
||
|
||
## Estimates
|
||
|
||
| Phase | Est |
|
||
|-------|-----|
|
||
| Phase 0: Site-level publishing | 1.5h |
|
||
| Phase 1: Page versions and drafts | 7h |
|
||
| Phase 2: Theme drafts | 4h |
|
||
| Phase 3: Settings drafts | 4h |
|
||
| Phase 4: Version history and rollback | 5.5h |
|
||
| Phase 5: Image soft delete and trash | 4.5h |
|
||
| Phase 6: Polish and pruning | 4.25h |
|
||
| **Total** | **30.75h** |
|
||
|
||
## References
|
||
|
||
- [Squarespace draft model](https://support.squarespace.com/hc/en-us/articles/205815578-Saving-and-publishing-site-changes)
|
||
- [Shopify theme versions](https://shopify.dev/docs/themes/architecture#theme-versions)
|
||
- [WordPress autosave](https://developer.wordpress.org/plugins/post/autosave/)
|