Skip to main content

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.

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 %}

Movie catalog

The movie catalog page iterates over a list of movies and displays them in a grid.

{% 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 %}

Movie detail

The detail page shows more information about a specific movie, including its genre and summary.

  {% endcomment %}
<h1>{{ movie.title }}</h1>
<p class="muted">
{{ movie.release_year }}
{% if movie.genre %} · <span class="tag">{{ movie.genre.name }}</span>{% endif %}
</p>
{% comment %}

Movie forms

We use the same form fields for both creating and editing movies.


{% 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 %}

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.

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();
}
}

Genre pages

Genre pages follow a similar pattern to the movie pages.


{% 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 %}