Easy-to-manage touch screens
무료 오픈소스 터치스크린으로 USB 설정 가이드, 드래그‑드롭 편집기, 로컬 제어를 제공하며 Home Assistant용으로 제작되었습니다
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기Serve ip recipes for users tied to a shopping list
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Recipe box, weekly meal planner and grocery list for one household.
Upload a recipe card - a PDF, or a photograph of a printed or handwritten one -
plan a dinner for each night from your library, and take the resulting shopping
list to the shop.
The library, newest dish given the hero and the rest in a grid. Collections are
a curated way in; everything else lives behind Filters.
A recipe, with everything the card printed: timings, resting, oven temperature,
equipment, and what the household thought of it.README 전문 보기 ↗Recipe box, weekly meal planner and grocery list for one household. Add a recipe from a link, a PDF, or a photograph of a printed or handwritten card, plan a dinner for each night from your library, and take the resulting shopping list to the shop.
https://github.com/user-attachments/assets/f4008d4c-4334-4b36-83d7-cc69ad15a5a2
The library, newest dish given the hero and the rest in a grid. Collections are a curated way in; everything else lives behind Filters.
A recipe, with everything the card printed: timings, resting, oven temperature, equipment, and what the household thought of it.
The week, and the list it produces. Ingredients roll up across the meals planned, grouped by aisle, with the dish each line came from underneath it.
Add a recipe from a link to one online, a PDF, or a photograph of a card.
Screenshots are of a seeded demo library, so no dish photos are attached - the
striped placeholders are what the app draws when a recipe has none. They are
taken from npm run seed:demo, so retaking them is a command rather than an
afternoon:
npm run browse:seed # once, with the app running
npm run seed:demo
npm run browse -- /recipes --shot=docs/screenshots/recipes.png
npm run browse -- /recipes/zuni-chicken-with-bread-salad-03a14891 --shot=docs/screenshots/recipe.png
npm run browse -- /plan --shot=docs/screenshots/planner.png
npm run browse -- /upload --shot=docs/screenshots/upload.png
npm run browse -- /pantry --shot=docs/screenshots/pantry.png
npm run browse -- /household --shot=docs/screenshots/household.png
The recipe's address is stable across reseeds: seed:demo derives each
publicId from a hash of the title rather than at random, so the hero keeps
the same URL and this command does not need editing every time.
/sign-up?code=..., so the person you
invite taps once and lands on a form with the code already filled in./recipes/<id>/cook is the recipe as something you are
in the middle of: one step at a time in large type, ingredients a tap away
without losing your place, and the screen held awake so a propped-up phone
does not sleep between steps. Where you got to survives a reload.| Concern | Choice |
|---|---|
| Framework | Next.js 16 (App Router) + React 19 |
| Language | TypeScript |
| UI | Material UI 9 (emotion), light + dark |
| Database | Postgres via Prisma 7 |
| Auth | Better Auth (email + password, invite-gated) |
| Storage | Vercel Blob, with a local-disk driver for dev |
| AI | Claude, for reading recipe cards |
| Tests | Vitest, against a real Postgres |
npm install # postinstall runs `prisma generate`
cp .env.example .env # then fill in the values below
npm run db:migrate # creates the schema
npm run dev # http://localhost:3000
You need a Postgres instance. Anything works locally; production is Neon.
| Variable | Needed for | Notes |
|---|---|---|
DATABASE_URL | everything | Postgres connection string |
BETTER_AUTH_SECRET | sessions | openssl rand -base64 32 |
BETTER_AUTH_URL | sessions | The app's own origin |
ANTHROPIC_API_KEY | reading a card | Without it, upload returns a clear 422 |
UPLOAD_DAILY_LIMIT | capping the bill | Cards per person per day. Unset means 30 |
INSTACART_API_KEY | sending a cart | Without it, the planner says so and stays usable |
INSTACART_API_BASE | sending a cart | Dev server by default; switch for production |
BLOB_READ_WRITE_TOKEN | uploads in production | Unset locally: files go to public/uploads/ |
TWILIO_ACCOUNT_SID | texting the list | All three are needed; any missing hides the button |
TWILIO_AUTH_TOKEN | texting the list | The account's auth token, not an API key secret |
TWILIO_FROM_NUMBER | texting the list | E.164, e.g. +15085551212 |
Missing optional keys degrade gracefully — the rest of the app keeps working and the affected feature explains what is missing.
Signup is invite-only and there is no bootstrap UI yet, so the first user is made by hand:
INSERT INTO "user"(id, name, email, "emailVerified", "createdAt", "updatedAt")
VALUES ('bootstrap', 'Your Name', '[email protected]', true, now(), now());
INSERT INTO invite(id, code, "expiresAt", "createdAt", "createdById")
VALUES ('bootstrap-invite', 'PICKSOMETHINGRANDOM', now() + interval '7 days', now(), 'bootstrap');
Then visit /sign-up?code=PICKSOMETHINGRANDOM. (Delete the placeholder row
afterwards if you like — the invite survives on its own.)
See DEPLOYING.md for the full walkthrough — Neon, Vercel Blob, environment variables, and creating the first account.
Two things that bite if skipped: migrations need Neon's direct connection
string (DIRECT_DATABASE_URL), not the pooled one, and without a Blob store
uploads land in ./public/uploads, which serverless hosting wipes on every
deploy.
GET /api/health reports whether a deploy reached its database and whether the
schema is present.
| Script | Does |
|---|---|
npm run dev | Dev server |
npm run build | Production build |
npm test | Vitest once (truncates the dev database) |
npm run lint | ESLint |
npm run typecheck | Route types + tsc --noEmit |
npm run format | Prettier |
npm run db:migrate | Create and apply a migration |
npm run db:deploy | Apply migrations (production) |
npm run db:studio | Prisma Studio |
The integration tests share one database and clean up after themselves, which means running them against a database you care about will empty it.
Texting works, and getting there was most of the work. The sender itself is one form-encoded POST. Everything around it exists because US carriers will not carry application-sent SMS to ordinary numbers until the sender is registered under A2P 10DLC: a Brand, then a Campaign describing what the messages are and how people agreed to receive them.
Unregistered traffic is not bounced, it is silently dropped — the API accepts
the message and the handset never rings, which is the worst failure shape there
is, and it cost a day before the Twilio console's message logs made it visible.
If this ever stops working, start there rather than in the code: 30034 means
the number is not registered against an approved campaign, 30007 means a
carrier filtered it, 21610 means that person replied STOP.
Two rejections were worth writing down, because neither is about the code:
src/lib/legal.ts matches the pattern rather than reading better than it.The app is called Trivet; the text messages still say McMullen Meal Magic.
That is not an oversight. BRAND in src/lib/legal.ts is the name on the
approved A2P campaign, it appears in every message body, and carrier vetting
compares the two. Renaming it before the campaign is re-registered would not
produce a branding inconsistency — it would produce silently undelivered
messages, which is the same failure as being unregistered. The order is:
register the new name, wait for approval, then change that one constant and the
two tests that pin it. Those tests failing is the tripwire, and it is deliberate.
The registration is a sole-proprietor Brand, which carries low daily throughput caps. A household and a weekly list is nowhere near them.
The consent machinery is what the registration was built on, and it is the part that reads as over-engineering until a carrier asks to see it:
saveOwnPhone writes the
caller's own row and nothing else — a digit wrong sends the week's shopping
to a stranger.smsConsentAt and smsConsentSource are stored
separately, because being reachable and having agreed are two different
facts, and a carrier asking to see the opt-in is asking about the second.
Agreeing to texts is never a condition of having an account; carrier rules do
not allow it. (A phone was briefly required to join and was made optional
again: it bought no way of reaching anybody, since only members who tick the
box can be texted, and gave a stranger signing up nothing to say yes to.)/privacy, /terms and /sms are public, deliberately outside the login,
because a policy nobody can read without an account is a policy a reviewer
will reject. The wording lives in src/lib/legal.ts so the box, the pages
and the campaign submission cannot drift apart.21610 on the next send, and that clears the person's
consent while keeping their number — so re-agreeing is a tick rather than
typing it in again.The app cannot tell you a text was delivered. Twilio answers 201 when it
accepts a message for delivery, which is not the same as a carrier handing it
to a handset, and that response is all the sender sees. So a send that is
filtered downstream is reported as a success. Real delivery state needs a
status-callback webhook, which does not exist yet; until it does, Twilio's
Messaging logs are the only place the truth lives.
Reading a card is metered per person, per day. It is the only operation
here that costs money every time it runs, and /api/upload is reachable by
anyone with an account - a theoretical problem for one household, and somebody
else's enthusiasm arriving on your bill for thirty.
The counter is a row per person per day in Postgres rather than anything new to run. It is claimed immediately before the model call and nowhere earlier: a duplicate file or a damaged one is refused for free and should not spend an allowance on a call that never happens. A refused attempt still increments, so hammering the endpoint cannot walk the count back into range.
The ceiling is not there to shape how people add recipes - a household typing
up twenty cards the evening they join should sail through it - only to stop a
runaway loop being unbounded. UPLOAD_DAILY_LIMIT moves it; a value that is
not a positive integer falls back to the default rather than becoming NaN,
which would compare false against everything and quietly switch the limit off.
A recipe is private until it is shared, and sharing means sharing with
everybody. Not with a named household: there are a handful of them, all
invited by somebody already here, and a targeting table would be machinery for
a permission nobody has asked for. Recipe.isShared can become a join table
later without the read path changing shape.
Everything that existed when the column arrived was backfilled shared. The library had been visible to every signed-in person since it was built, and defaulting it to private would have taken dishes away from households that had been cooking from them for months. Only recipes added afterwards start private.
The rule lives in src/lib/recipe-visibility.ts and nowhere else, because "who
can see this" is exactly the kind of thing that is right in four places and
forgotten in the fifth - and the fifth is the one that shows somebody another
family's dinner. Search needs it twice, once as a Prisma filter and once as SQL,
since ranking cannot be expressed in Prisma; both live in that file so the pair
is read and changed together.
Three things follow from visibility that are easy to miss:
sourceFileSha256 used to be
globally unique, so a widely printed card that two families both own was
called a duplicate - and the refusal told the second family that a recipe they
could not see existed. The constraint is now on (household, hash).Planning a shared recipe takes a reference, not a copy. The plan stores the id, so the owner's later edits reach a meal another household has already planned. Un-sharing is deliberately not retroactive: a week already planned keeps the dish and still gets its ingredients on the shopping list. Pulling dinner out of somebody's Thursday, days later, because another family changed its mind is worse than the recipe staying readable to the few who had committed to cooking it.
An evening is a fixed list of slots, not a count. Three dinners and a side
are four enum values on MealSlot, which keeps the
(householdId, date, slot) unique key that every planner query and the side
upsert rely on. The alternative — a position column and as many rows as you
like — means changing that key, and the only thing it buys is a fourth dinner
that nobody has asked for. As values, a fourth is two lines of SQL and no
application change at all: ALTER TYPE ... ADD VALUE rewrites no table.
Sides hang off the day rather than off a dinner, which is the decision that made the slots work. One set of sides for the evening, whichever mains are on it; sides per dinner is what would have forced the position column. The side suggestion reads the day's first dinner to have something to suggest against, and says so where it does it.
The extra dinners are a line of text under the first one, not a second photo tile. A day tile is about 180px on a phone, and three photo tiles stacked is a page nobody scrolls; one dish is the one you recognise across the room and the others are "and Lamb Tagine". All of them scale and merge into the shopping list identically — that part never knew how many dinners a day had.
Two kinds of rot, and only one of them is CI's. ci.yml runs on every
push and answers "did this commit break anything". Nothing in it answers "did
the world move while nobody was looking" - an advisory published against a
package nobody touched, a dependency quietly three majors behind, a schema that
drifted from its migrations. Those rot on a calendar, so health.yml checks
them on one, every Monday.
It reports rather than fixes, and it reports into a single issue that is rewritten each week and closed the week it comes back clean. A new issue every Monday is a pile of stale duplicates, and an open issue reading "all clear" is the same noise more politely - either one teaches people to skip the label that matters.
Patches alone never open it. They arrive constantly, npm audit speaks up when
one of them matters, and a weekly notice nobody needs to act on is how somebody
learns to skip the notice that does. Advisories are counted twice, once over
everything and once with --omit=dev, because "eight advisories" and "one a
stranger could reach" are different sentences deserving different Mondays.
It also watches the two things that expire on a date rather than on a release. Runtime support windows - Node, Postgres, Next - are looked up against endoflife.date each week rather than written down, because a support date copied into a repo is wrong the first time upstream moves it and nobody re-reads a constant. A lookup that fails is reported as a failure and raises the alarm: not knowing and being fine are different answers, and a check that silently returns nothing every week is worse than no check because it looks like one.
Credential ages are the opposite, and live in lifecycle.json, because no API
can say when somebody last rotated a token. A date nobody has recorded is
reported and never opens the issue on its own - that is a form to fill in, not
a weekly alarm - while a rotation past its own interval does.
Instacart does not place orders. Both of its endpoints return a URL to a prepared page; the customer checks out on Instacart. That is the entire integration surface — nothing after the hand-off is visible to this app.
Amazon cannot build a cart at all. There is no public Amazon Fresh or Whole
Foods ordering API; access is granted case by case through a business
arrangement with Amazon's Fresh team. The add-to-cart URL
(/gp/aws/cart/add.html?ASIN.1=…) still works but needs ASINs, which means the
Product Advertising API — an Associates account with qualifying sales, and one
that does not reliably cover Fresh or Whole Foods grocery items. So the Amazon
providers do the honest thing: a search link per ingredient plus the list as
plain text. The UI says so rather than implying a basket was filled.
If you later obtain real Fresh API access, src/lib/shopping/amazon.ts is the
only file that needs to change — the provider interface already allows a cart
hand-off, and ShoppingProvider already distinguishes the two kinds.
The Amazon storefront search aliases are unverified. i=amazonfresh and
i=wholefoods scope a search to those storefronts, but amazon.com is blocked
from the environment this was built in, so the links were never followed. They
are trivially checkable in a browser: the link either lands in the right store
or it does not.
Instacart cannot be switched on at present. As of August 2026 Instacart is not accepting new developer applications and offers no waitlist, which rules out development and production keys alike — this is not a lead time to plan around, it is a closed door.
The integration is kept intact rather than deleted, but it says nothing about
itself: no boot warning, no entry in /api/health, and no greyed-out button on
the planner. A permanent notice about something nobody can act on is how people
learn to read past notices. The planner offers whatever listUsableProviders()
returns, which filters on available rather than naming Instacart — so setting
INSTACART_API_KEY is the whole of the work if applications reopen.
The integration is kept rather than removed: it is written and tested, and works the day applications reopen. Until then the Instacart button is disabled and says why. Amazon Fresh and Whole Foods need no key and no approval, so they are the shopping path that works today.
The Instacart request shape is written from documentation, not from a live call. No API key was available while building it, and the docs host is unreachable from the build environment, so the request is covered by tests against a stubbed transport rather than a real response. Since applications are closed there is currently no way to verify it against a real response — do that before trusting it, whenever a key becomes obtainable.
HEIC is converted in the browser, and the decoder is only fetched when one turns up. An iPhone stores photos as HEIC, and nothing downstream reads one: not the Claude API, not the app's own validation, not most browsers' decoders. iOS often transcodes to JPEG when a file input asks for JPEG - but only often. Chrome on iOS, the Files app, Android, and a HEIC that reached a laptop by AirDrop all hand over the original.
So the first twelve bytes of any picked file are sniffed, and a HEIC pulls in
heic-to through a dynamic import. That decoder is about three megabytes of
WebAssembly, which is why it is not in the main bundle: opening the upload page
does not fetch it, and somebody who only ever uploads JPEGs never does.
A conversion that fails hands back the original untouched rather than throwing. The server then identifies it by its bytes and gives the clear explanation it always did - a failure here should cost the good error message, not replace it with a worse one.
Neither file input lists image/heic in its accept, deliberately. Naming it
makes Safari hand over the original instead of transcoding, and has been
observed to make Safari convert JPEGs into HEIC. The accept list is a hint
that nudges the picker; the conversion is the actual safety net, since accept
is advisory and phone pickers routinely ignore it.
A real HEIC has never been through this. The environment it was built in
has no HEIC encoder, so the tests cover the sniffing, the dynamic import, the
renaming and the fallback - all against a synthetic file with a genuine HEIC
header and a body no decoder could read. The path was also driven in a browser:
the decoder chunk is fetched when a .heic is picked and not before, and the
failure falls through to the server's message. What is unproven is one real
photo from one real iPhone coming out the other side as a readable JPEG. Try it
before relying on it.
Pulling the dish photo out of a PDF is JPEG-only. Images stored as DCTDecode streams are already complete JPEG files and can be written straight out. Other encodings hold raw samples that would need colour-space handling and a PNG encoder. Recipe photos are nearly always JPEG; one that is not simply arrives without a photo, and can be added by hand.
This applies only to PDFs. An uploaded photograph is the card itself rather than a container holding a picture, so nothing is extracted from it - the file is kept as the source card and the recipe starts with no dish photo.
Unit merging is conservative. Synonyms fold together (tablespoons →
tbsp), but nothing converts between units — 1 cup and 200 ml stay as two
lines. A silently wrong conversion is worse than a slightly longer list.
A function cannot cross from a server component into MUI's client code.
This cost time three separate times and it does not look like a mistake going
in: component={Link}, an sx callback, and an onChange passed down from a
server page all typecheck, build cleanly and serve 200. They fail when React
hydrates, which npm run build never exercises - so a green build says nothing
at all about this class of bug.
FormControlLabel is the sharpest instance, because it reads control.props
during render and an element that crossed the boundary has none. The fixes are
to mark the component "use client" - SmsDisclosure carries the directive
because it must, not for tidiness - or to make the callback optional, so a
server page can render an inert control without passing a function at all. A
page that renders MUI wants opening in a browser before you believe it works.
The dark colour scheme has never been reviewed. It follows the system
setting through MUI's colorSchemeSelector: "media" and has only been seen in
passing. Nothing suggests it is broken; nothing has checked it either.
Restart the dev server after a schema change — after pulling a branch that
adds a model, too. npm run dev and npm run db:deploy both regenerate the
Prisma client now, so a restart is all it takes.
Why a restart is needed at all: src/lib/db.ts caches the client on
globalThis so Next's dev-mode module reloading doesn't open a new connection
pool on every edit, and that cache survives HMR. A running server therefore
keeps the client it started with. The symptom is
Cannot read properties of undefined (reading 'findMany') on a model that
plainly exists in the schema, while npm test and npm run typecheck pass
because they load a fresh client in a new process.
npm audit reports a dev-only advisory in deepmerge-ts, reached through
@prisma/config under the Prisma CLI. It is not reachable from application code
at runtime, and the only "fix" available downgrades to Prisma 6. Worth
re-checking when Prisma publishes.
/admin lists the households, how much each is
using the planner, and how many cards they have uploaded. Counts and dates
only. Reachable by the addresses in ADMIN_EMAILS and answering 404 to
everybody else.