Manual Recipes Likes / Votes Toggle

Likes / Votes Toggle

elements man recipes/likes-toggle Read as markdown

A "like" or "vote" toggles a row in a join table: insert when the user toggles on, delete when they toggle off. The pattern is the canonical Elements answer for likes, hearts, upvotes, bookmarks, follows, and similar one-row-per-user-per-thing reactions. Scope the LiveTable by the parent id so each item's likes are their own partition; insert.auth gates who can add a row, delete.auth restricts removal to the row's owner.

Migration

Scaffold the migration with elements create migration. The -tables flag stubs out the table with the standard primary key, timestamps, and touchUpdatedAt trigger so you don't write that boilerplate by hand.

elements create migration 'add comment likes' -tables=commentLikes

That writes app/migrations/<timestamp>-add-comment-likes.migration.sql. Open it and add the foreign-key columns plus the unique constraint, the parts specific to this feature.

-- add comment likes

-- Auto-update updatedAt on row changes.
create or replace function touchUpdatedAt()
returns trigger
language plpgsql
as $$
begin
  new.updatedAt = now();
  return new;
end;
$$;

create table commentLikes (
  id uuid primary key default uuidGenerateV7(),
  createdAt timestamptz not null default now(),
  updatedAt timestamptz not null default now(),
  commentId uuid not null references comments(id) on delete cascade,
  userId uuid not null references users(id) on delete cascade,
  unique (commentId, userId)
);

create trigger commentLikesTouchUpdatedAt
  before update on commentLikes
  for each row execute function touchUpdatedAt();

The three lines added on top of the scaffold are commentId, userId, and unique (commentId, userId). The unique constraint is the safety net at the database: even if two clicks race, only one row lands.

Comment template

elements create template comment

That writes app/shared/templates/comment/{index.html, style.css}. Update index.html.

The template declares its own LiveTable at module scope and exports it so parent pages can scope it per row. The toggle helper sits next to the template since it's only called from inside it.

app/shared/templates/comment/index.html:

import "./style.css";
import { LiveTable, session } from "@elements/app";

export interface Comment {
  id: string;
  postId: string;
  userId: string;
  body: string;
}

export interface CommentLike {
  id: string;
  commentId: string;
  userId: string;
}

export let commentLikes = new LiveTable<CommentLike>({
  scope: "commentId",
  insert: { auth: (item, s) => s.isLoggedIn() },
  delete: { auth: (item, s) => item.userId === s.getOrThrow('userId') },
});

function toggle(commentId: string, likes: LiveTable<CommentLike>) {
  let mine = likes.find(l => l.userId === session.getOrThrow('userId'));

  if (mine) {
    likes.delete(mine);
  } else {
    likes.insert({ commentId, userId: session.getOrThrow('userId') });
  }
}

<CommentItem (c: Comment, likes: LiveTable<CommentLike>)>
  <p>{c.body}</p>
  <button onclick={() => toggle(c.id, likes)}>
    {likes.length} {likes.length === 1 ? "like" : "likes"}
  </button>
</CommentItem>

scope: "commentId" partitions the live channel per comment, so the count for one comment only updates templates watching that comment. insert.auth requires a logged-in user; delete.auth enforces that users can only un-like their own rows. likes.length is reactive, so the button label updates on every insert and delete.

Post page

elements create page post

The page renders a list of comments and scopes the LiveTable per comment, then ships the scoped instance to <CommentItem> via the likes attribute. The scope call happens at template-render time on the server, and the scoped LiveTable serializes to the browser as a live handle.

app/pages/post/services.ts:

export interface Post {
  id: string;
  title: string;
}

app/pages/post/index.ts:

import { sql } from "@elements/app";
import { Comment } from "#app/shared/templates/comment";
import post from "./template";
import { Post } from "./services";

export default function route(req, res) {
  let p = sql<Post>(`select * from posts where id = ${req.params.id}`).firstOrThrow("post not found");
  let comments = sql<Comment>(`select * from comments where postId = ${p.id}`).all();

  return new post({ post: p, comments });
}

firstOrThrow("post not found") raises NotFoundError (a safe 404) when no row matches, so the client gets a clean response instead of a runtime null deref.

app/pages/post/template.html:

import "./style.css";
import CommentItem, { commentLikes, Comment } from "#app/shared/templates/comment";
import { Post } from "./services";

<html class="post" (post: Post, comments: Comment[])>
  <h1>{post.title}</h1>
  <CommentItem e:for={c of comments} c={c} likes={commentLikes.scope(c.id)}/>
</html>

Routes

Register the page in index.ts:

import post from "#app/pages/post";

// ...
app.route("/posts/:id", post);

Notes

  • The auto-fill behaviour of scope means commentId doesn't need to appear in the insert(...) payload above. scope: "commentId" plus the bound value will populate it. The example writes it explicitly for clarity.
  • A second click while a delete is in flight is a no-op (the row is already gone from local state). Optimistic state guards the user from accidentally double-mutating.
  • The same shape handles upvote / downvote (store the direction as a value: 1 | -1 column), star ratings (store a rating int), or any other one-row-per-user-per-thing reaction.