Templates & Storage
A full-stack application needs a way to render dynamic HTML and handle file uploads. We use Liquify for templating and File Cloud for storage.
Template renderer
We wrap the Liquify engine in a TemplateRenderer class. This allows us to provide "shared data" (like the application name or current user) to all templates automatically.
class TemplateRenderer {
TemplateRenderer(this.root, {Map<String, Object?>? sharedData})
: _sharedData = sharedData ?? <String, Object?>{};
final Root root;
final Map<String, Object?> _sharedData;
Future<String> render(String templatePath, Map<String, Object?> data) async {
final template = Template.fromFile(
templatePath,
root,
data: {..._sharedData, ...data},
);
return template.renderAsync();
}
}
Layout shell
Liquify supports layout inheritance, which means you can define a base "shell" for your application and have other templates "extend" it. This keeps your UI consistent and reduces duplication.
- Header
- Content slot
The header contains the navigation bar and is shared across all pages.
{% endcomment %}
<header>
<div>
<div class="tag">Curated</div>
<div class="wordmark">{{ app_name }}</div>
<span class="muted">An artisanal, lived-in film shelf.</span>
</div>
<nav>
<a href="/">Catalog</a>
<a href="/genres">Genres</a>
<a href="/movies/new">Add Movie</a>
</nav>
</header>
{% comment %}
The {% block content %} tag is where the specific page content will be injected.
{% endcomment %}
<main>
{% block content %}{% endblock %}
</main>
{% comment %}
Movie catalog
The movie catalog page iterates over a list of movies and displays them in a grid.
- Hero
- List
- Empty state
{% endcomment %}
<section class="hero">
<div class="hero-card">
<h1>Movie Catalog</h1>
<p class="muted">A small, intentional archive of stories worth returning to.</p>
<a class="button" href="/movies/new">Add a movie</a>
</div>
<div class="hero-card">
<h3>Storage + templates + Ormed</h3>
<p class="muted">This page is rendered with Liquify. Posters are stored via storage_fs.</p>
<a class="button alt" href="/movies/new">Upload a poster</a>
</div>
</section>
{% comment %}
We use a {% for movie in movies %} loop to render each movie card.
{% endcomment %}
<div class="grid grid-2">
{% for movie in movies %}
<article class="card">
<h2>{{ movie.title }}</h2>
<p class="muted">{{ movie.release_year }}{% if movie.genre %} · {{ movie.genre.name }}{% endif %}</p>
{% if movie.poster_url %}
<img class="poster" src="{{ movie.poster_url }}" alt="Poster for {{ movie.title }}" />
{% endif %}
{% if movie.summary %}
<p>{{ movie.summary }}</p>
{% endif %}
<a class="button" href="/movies/{{ movie.id }}">View details</a>
</article>
{% endfor %}
</div>
{% comment %}
If no movies are found, we show a friendly message.
{% endcomment %}
{% if movies.size == 0 %}
<div class="card">
<h3>No movies yet.</h3>
<p class="muted">Add the first entry to start your catalog.</p>
<a class="button" href="/movies/new">Add movie</a>
</div>
{% endif %}
{% comment %}
Movie detail
The detail page shows more information about a specific movie, including its genre and summary.
- Header
- Actions
{% endcomment %}
<h1>{{ movie.title }}</h1>
<p class="muted">
{{ movie.release_year }}
{% if movie.genre %} · <span class="tag">{{ movie.genre.name }}</span>{% endif %}
</p>
{% comment %}
Buttons for editing or deleting the movie.
{% endcomment %}
<p>
<a class="button" href="/">Back to catalog</a>
<a class="button alt" href="/movies/{{ movie.id }}/edit">Edit</a>
<a class="button" href="/movies/{{ movie.id }}/delete">Delete</a>
</p>
{% comment %}
Movie forms
We use the same form fields for both creating and editing movies.
- Create form
- Edit form
{% endcomment %}
<form method="post" action="/movies" enctype="multipart/form-data">
<div class="field">
<label for="title">Title</label>
<input id="title" name="title" type="text" value="{{ values.title }}" required />
</div>
<div class="field">
<label for="releaseYear">Release Year</label>
<input id="releaseYear" name="releaseYear" type="number" min="1888" value="{{ values.releaseYear }}" required />
</div>
<div class="field">
<label for="genreId">Genre</label>
<select id="genreId" name="genreId">
<option value="">Select a genre</option>
{% for genre in genres %}
<option value="{{ genre.id }}" {% if values.genreId == genre.id %}selected{% endif %}>{{ genre.name }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="summary">Summary</label>
<textarea id="summary" name="summary" rows="4">{{ values.summary }}</textarea>
</div>
<div class="field">
<label for="poster">Poster (optional)</label>
<input id="poster" name="poster" type="file" accept="image/*" />
</div>
<button class="button" type="submit">Save movie</button>
</form>
{% comment %}
{% endcomment %}
<form method="post" action="/movies/{{ movie.id }}/edit" enctype="multipart/form-data">
<div class="field">
<label for="title">Title</label>
<input id="title" name="title" type="text" value="{{ values.title }}" required />
</div>
<div class="field">
<label for="releaseYear">Release Year</label>
<input id="releaseYear" name="releaseYear" type="number" min="1888" value="{{ values.releaseYear }}" required />
</div>
<div class="field">
<label for="genreId">Genre</label>
<select id="genreId" name="genreId">
<option value="">Select a genre</option>
{% for genre in genres %}
<option value="{{ genre.id }}" {% if values.genreId == genre.id %}selected{% endif %}>{{ genre.name }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="summary">Summary</label>
<textarea id="summary" name="summary" rows="4">{{ values.summary }}</textarea>
</div>
<button class="button" type="submit">Save changes</button>
<a class="button alt" href="/movies/{{ movie.id }}">Cancel</a>
</form>
{% comment %}
File Storage
We use the StorageService to handle movie poster uploads. In development, this saves files to a local uploads/ directory. In production, you could easily swap this for an S3 or Google Cloud Storage implementation without changing your application logic.
- Initialization
- Saving Files
The storage service initializes a local disk driver and optionally connects to S3-compatible cloud storage.
Future<void> init() async {
Directory(uploadsRoot).createSync(recursive: true);
Storage.initialize({
'default': 'uploads',
'disks': {
'uploads': {'driver': 'local', 'root': uploadsRoot},
},
});
_cloud = _initCloudFromEnv();
if (_cloud != null) {
await _cloud!.driver.ensureReady();
}
}
Files are saved with a timestamp prefix to ensure unique filenames.
Future<String?> savePoster({
required String? originalName,
required List<int>? bytes,
}) async {
if (bytes == null || bytes.isEmpty) return null;
final safeName = _buildSafeName(originalName ?? 'poster');
final storagePath = p.join('posters', safeName);
await Storage.put(storagePath, bytes);
return storagePath;
}
Genre pages
Genre pages follow a similar pattern to the movie pages.
- Genres list
- Empty state
- Genre detail
{% endcomment %}
{% if genres.size > 0 %}
<div class="grid">
{% for genre in genres %}
<div class="card">
<h3>{{ genre.name }}</h3>
{% if genre.description %}
<p>{{ genre.description }}</p>
{% else %}
<p class="muted">No description yet.</p>
{% endif %}
<a class="button" href="/genres/{{ genre.id }}">View genre</a>
</div>
{% endfor %}
</div>
{% endif %}
{% comment %}
{% endcomment %}
{% if genres.size == 0 %}
<p class="muted">No genres yet. Seed the database to get started.</p>
{% endif %}
{% comment %}
{% endcomment %}
<h1>{{ genre.name }}</h1>
{% if genre.description %}
<p>{{ genre.description }}</p>
{% else %}
<p class="muted">No description yet.</p>
{% endif %}
<a class="button" href="/genres">Back to genres</a>
{% comment %}