Featured Community Plugin: Poste
This post is part of a series, featuring the incredible work of plugin authors who grow the TRMNL plugin ecosystem with every contribution. The TRMNL team has individually selected these plugins and authors to be featured.
Poste Plugin
Community member Daniel Eichman created the Poste plugin; here are their words on how it was created.
Why did you want this plugin to exist?
I wanted my kids to be able to share photos and notes with their grandparents. My son loves taking photos of the Lego and puzzles he builds to share with his grandparents, its a ton of fun!
What was your process for creating the plugin?
The tech stack is pretty straight forward, it deployed to AWS and runs a golang lambda, s3 bucket for the photos and a static react website. As for tools, I have been trying out Zed as my new IDE.
What, if any, challenges did you face while creating it?
Being more of a backend developer I fought with the TRMNL html more than I would like to admit. Its probably a skill issue as I still don't know what a flex box is...
Is there a tip you would give to a new plugin developer?
Yeah I broke down and built an TRMNL rendering script see attached!
// To run: yarn ts-node previewScreens.ts
// This script calls the TRMNL screen generation API 3 times and generates HTML preview files
import { callTRMNLScreenGeneration } from "./getScreen";
import { writeFileSync, mkdirSync, rmSync } from "fs";
import { resolve, dirname } from "path";
interface ScreenResponse {
markup: string;
markup_half_horizontal: string;
markup_half_vertical: string;
markup_quadrant: string;
}
const layouts = [
{
key: "markup" as const,
name: "full",
label: "Full (800x480)",
width: 800,
height: 480,
},
key: "markup_half_horizontal" as const,
name: "half_horizontal",
label: "Half Horizontal (800x240)",
height: 240,
key: "markup_half_vertical" as const,
name: "half_vertical",
label: "Half Vertical (400x480)",
width: 400,
key: "markup_quadrant" as const,
name: "quadrant",
label: "Quadrant (400x240)",
];
async function main() {
const accessToken = "bc863e409faa3f1ce18852f630ae4a18d334ce8c";
const uuid = "a79d758e-4d35-431d-8895-2d9c132c609d";
const missingUuid = "00000000-0000-0000-0000-000000000000";
const outputDir = resolve(dirname(__filename), "html");
// Clean and create output directory
rmSync(outputDir, { recursive: true, force: true });
mkdirSync(outputDir, { recursive: true });
const files: {
call: number;
layout: string;
label: string;
filename: string;
markup: string;
}[] = [];
// Call API 3 times to rotate through cards
for (let call = 1; call <= 3; call++) {
if (call > 1) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
const res = await callTRMNLScreenGeneration(accessToken, uuid);
const body = (await res.json()) as ScreenResponse;
// Generate one HTML file per layout
for (const layout of layouts) {
const markup = body[layout.key];
const html = generateScreenHTML(markup, layout.width, layout.height);
const filename = `call${call}_${layout.name}.html`;
writeFileSync(`${outputDir}/${filename}`, html);
files.push({
call,
layout: layout.name,
label: layout.label,
filename,
markup,
});
}
// Call API with missing UUID to generate no_cards view
const noCardsRes = await callTRMNLScreenGeneration(accessToken, missingUuid);
const noCardsBody = (await noCardsRes.json()) as ScreenResponse;
for (const layout of layouts) {
const markup = noCardsBody[layout.key];
const html = generateScreenHTML(markup, layout.width, layout.height);
const filename = `no_cards_${layout.name}.html`;
writeFileSync(`${outputDir}/${filename}`, html);
files.push({
call: 0,
layout: layout.name,
label: `No Cards - ${layout.label}`,
filename,
markup,
});
// Generate index.html with links to all files
const indexHTML = generateIndexHTML(files);
writeFileSync(`${outputDir}/index.html`, indexHTML);
console.log(`${outputDir}/index.html`);
function generateScreenHTML(
markup: string,
width: number,
height: number,
): string {
// TRMNL framework calculates view sizes based on full 800x480 screen
// We set screen size to match the view size so the view fills the screen
return `<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://usetrmnl.com/css/latest/plugins.css">
<script src="https://usetrmnl.com/js/latest/plugins.js"></script>
</head>
<body class="environment trmnl">
<div class="screen">
${markup}
</div>
</body>
</html>`;
function generateIndexHTML(
files: {
}[],
let links = "";
// Group by layout size
links += `<h2>${layout.label}</h2><ul>`;
// Card rotation calls for this layout
for (let call = 1; call <= 3; call++) {
const file = files.find(
(f) => f.call === call && f.layout === layout.name,
);
if (file) {
const escapedMarkup = file.markup
.replace(/&/g, "&")
.replace(/"/g, """);
links += `<li>
<a href="${file.filename}" target="_blank">Call ${call}</a>
<button class="copy-btn" data-markup="${escapedMarkup}">Copy Markup</button>
<div class="preview">
<iframe src="${file.filename}" width="${layout.width}" height="${layout.height}"></iframe>
</div>
</li>`;
}
// No cards for this layout
const noCardsFile = files.find(
(f) => f.call === 0 && f.layout === layout.name,
);
if (noCardsFile) {
const escapedMarkup = noCardsFile.markup
.replace(/&/g, "&")
.replace(/"/g, """);
links += `<li>
<a href="${noCardsFile.filename}" target="_blank">No Cards</a>
<button class="copy-btn" data-markup="${escapedMarkup}">Copy Markup</button>
<div class="preview">
<iframe src="${noCardsFile.filename}" width="${layout.width}" height="${layout.height}"></iframe>
</div>
</li>`;
links += "</ul>";
<title>TRMNL Screen Previews</title>
<style>
body { font-family: sans-serif; padding: 20px; }
h1 { border-bottom: 2px solid #333; padding-bottom: 8px; }
h2 { margin-top: 24px; color: #555; }
ul { list-style: none; padding: 0; }
li { margin: 8px 0; position: relative; }
a { color: #0066cc; text-decoration: none; }
a:hover { text-decoration: underline; }
.preview {
display: none;
position: absolute;
left: 150px;
top: -10px;
border: 2px solid #333;
background: white;
z-index: 100;
box-shadow: 4px 4px 10px rgba(0,0,0,0.3);
li:hover .preview {
display: block;
.preview iframe {
border: none;
pointer-events: none;
.copy-btn {
margin-left: 10px;
padding: 2px 8px;
font-size: 12px;
cursor: pointer;
background: #f0f0f0;
border: 1px solid #ccc;
border-radius: 3px;
.copy-btn:hover {
background: #e0e0e0;
.copy-btn.copied {
background: #90EE90;
</style>
<body>
<h1>TRMNL Screen Previews</h1>
<p>Each call rotates to the next card in the queue. Hover to preview.</p>
${links}
<div style="height: 600px;"></div>
<script>
document.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const markup = btn.dataset.markup;
await navigator.clipboard.writeText(markup);
btn.textContent = 'Copied!';
btn.classList.add('copied');
setTimeout(() => {
btn.textContent = 'Copy Markup';
btn.classList.remove('copied');
}, 2000);
</script>
main().catch(console.error);
What part of the TRMNL Framework, Liquid filters, or other documentation was most helpful?
The plugin docs were really good!