npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@firstlovecenter/flc-profile

v0.5.0

Published

Reusable Google-Forms-style profile system for FLC Next.js + Prisma apps. Customizable fields, CRUD, archive/restore, drag-to-reorder field builder.

Readme

@firstlovecenter/flc-profile

A reusable Google-Forms-style profile system for First Love Centre Next.js + Prisma apps. Admin-configurable fields, CRUD, archive/restore, drag-to-reorder field builder, dynamic Zod validation, and themed UI components — all wired together with port-based DI so the host owns persistence, auth, storage, and audit.

Status: stable. Server (routes + Prisma/memory persistence) and the UI components are implemented. As of 0.4.0 the system is namespace-aware: one deployment can host independent field sets (see Namespaces), and ProfileField carries a host-defined semanticRole marker. Backward-compatible — consumers that pass no namespace behave exactly as before (namespace "default").


Sibling packages

This package is the third in the trio: same architecture, same configure* entry point, same <...> UI surface.


Contents


Quick start

1. Install

pnpm add @firstlovecenter/flc-profile

Peer deps: @prisma/client >=5, next >=15, react >=18, react-dom >=18, zod >=3.

2. Paste the Prisma snippet

Add the contents of src/schema/profile.prisma (shipped in the package) to your prisma/schema.prisma. Run pnpm prisma migrate dev --name add_profile.

3. Configure the server

src/lib/profile/configure.ts:

import { configureProfile, createPrismaPersistence } from "@firstlovecenter/flc-profile/server";
import { prisma } from "@/lib/prisma";
import { authPort } from "@/lib/auth/port";
import { storagePort } from "@/lib/storage/r2";

export const profile = configureProfile({
  persistence: createPrismaPersistence(prisma),
  auth: authPort,
  storage: storagePort,
  routeBasePath: "/api/profile",
});

For a namespaced deployment, configure one instance per field set and mount each under its own base path — see Namespaces.

4. Mount the routes

For each handler the package exposes, create a thin re-export under app/api/profile/**:

// app/api/profile/profiles/route.ts
export const GET = profile.routes.profilesList.GET;
export const POST = profile.routes.profilesList.POST;

// app/api/profile/profiles/[id]/route.ts
export const GET = profile.routes.profileItem.GET;
export const PATCH = profile.routes.profileItem.PATCH;
export const DELETE = profile.routes.profileItem.DELETE;

// ...and so on for /profiles/[id]/restore, /profiles/[id]/photo,
// /fields, /fields/[id], /fields/reorder.

5. Import the CSS

app/globals.css:

@import "@firstlovecenter/flc-profile/styles.css";
@import "tailwindcss";
@import "tw-animate-css";
@import "@firstlovecenter/flc-profile/theme.css";

Order matters — structural CSS before Tailwind so package internals survive preflight; theme CSS after so host overrides win.

6. Mount UI components

// app/missionaries/page.tsx
import { ProfileList } from "@firstlovecenter/flc-profile/ui";

export default function MissionariesPage() {
  // Defaults to basePath "/api/profile"; pass basePath="/api/profile/estate"
  // (etc.) for a namespaced mount.
  return <ProfileList />;
}
// app/admin/profile-fields/page.tsx
import { ProfileFieldBuilder } from "@firstlovecenter/flc-profile/ui";

export default function AdminFieldsPage() {
  return (
    <ProfileFieldBuilder
      allowedSemanticRoles={[{ value: "occupant_type", label: "Occupant type" }]}
    />
  );
}

That's it. You now have an admin-editable profile system with archive, restore, drag-to-reorder, and dynamic form validation.

UI component props (all components accept an optional client or basePath; default basePath is /api/profile):

| Component | Key props | | --- | --- | | <ProfileList> | basePath?, client?, onSelect? | | <ProfileView> | profileId, basePath?, client?, resolvePhotoUrl?, renderGallery? | | <ProfileForm> | profileId? (omit to create), basePath?, client?, onSaved?, resolvePhotoUrl?, renderGalleryEditor? | | <ProfileFieldBuilder> | basePath?, client?, allowedSemanticRoles?, onChange? | | <ProfileCard> | profile, fields, resolvePhotoUrl?, onClick? (presentational — takes data, does not fetch) | | <ArchivedProfiles> | basePath?, client?, onRestored? |

resolvePhotoUrl(objectKey) => string maps a stored R2 object key to a servable URL (the package can't know your CDN base). PHOTO/FILE/GALLERY uploads in <ProfileForm> require an existing profile, so they activate in edit mode (profileId set); createProfileClient(...).uploadPhoto(profileId, fieldKey, file) presigns and PUTs in one call.

Galleries & custom rendering

A GALLERY field holds many photos — stored as an ordered GalleryItem[] ({ key, caption? }) in customFields[fieldKey]. Out of the box it renders a thumbnail grid (read-only in <ProfileView>, a file-picker + per-photo caption/remove in <ProfileForm>), so you don't have to build anything.

When you want your own look, pass a render-prop. The package keeps managing the photos (upload, remove, reorder, R2 cleanup on save); you only own the layout:

// Read-only: render galleries however you like (carousel, lightbox, masonry…)
<ProfileView
  profileId={id}
  resolvePhotoUrl={(key) => `${CDN}/${key}`}
  renderGallery={({ items, resolvePhotoUrl }) => (
    <MyCarousel slides={items.map((it) => ({ src: resolvePhotoUrl!(it.key), caption: it.caption }))} />
  )}
/>

// Editor: your layout, the package's photo management
<ProfileForm
  profileId={id}
  resolvePhotoUrl={(key) => `${CDN}/${key}`}
  renderGalleryEditor={({ items, resolvePhotoUrl, addFiles, remove, updateCaption, reorder, uploading }) => (
    <MyGalleryEditor
      items={items}
      resolve={resolvePhotoUrl}
      onAdd={addFiles}        // (files) => Promise<void> — presigns + uploads + appends
      onRemove={remove}       // (key) => void — purged from R2 on save
      onCaption={updateCaption}
      onReorder={reorder}
      busy={uploading}
    />
  )}
/>

Removing a photo and saving physically deletes the R2 object (best-effort; a storage failure never fails the save). See the storage-adapter note in docs/host-integration.md — gallery adapters must fold the per-upload unique token into stampKey.


Concepts

| Concept | What it is | | --- | --- | | Profile | One row per person (or other subject). Holds a JSON customFields blob whose shape is defined at runtime by the ProfileField schema. | | ProfileField | A field definition (admin-editable). key, label, type, options, required, section, etc. Adding/editing fields does NOT rewrite existing Profile.customFields data. | | ProfileFieldChange | Audit row written every time a ProfileField is created / edited / archived. | | Section | Grouping label for fields in <ProfileView> and <ProfileForm> ("Identity", "Contact", "Family", etc.). Admin-editable. | | Primary label | One or more fields marked isPrimaryLabel = true. Their concatenated values become the profile's display name (e.g., first + last → "Adwoa Mensah"). | | Archive | Soft-delete. archivedAt is set; the row stays in the DB; lists filter it out by default. Restore reverses. | | Namespace | Field-set discriminator (default "default"). Lets one deployment host independent field sets. Keys are unique within a namespace. |


Namespaces

A single deployment can host independent field sets — e.g. one for estates and one for apartments — by configuring one instance per namespace. Each instance scopes every read and write to its namespace, so a profile or field in one namespace is invisible to another.

// src/lib/profile/estate.ts
export const estateProfile = configureProfile({
  namespace: "ESTATE",
  persistence: createPrismaPersistence(prisma, { namespace: "ESTATE" }),
  auth: authPort,
  storage: storagePort,
  routeBasePath: "/api/profile/estate",
});

// src/lib/profile/apartment.ts — identical with namespace "APARTMENT",
// mounted under /api/profile/apartment

Notes:

  • The persistence adapter is the source of truth for scoping: createPrismaPersistence(prisma, { namespace }) filters every query, rewrites getFieldByKey to the @@unique([namespace, key]) composite, and fences find/update-by-id so a foreign-namespace id can't be read or mutated. configureProfile({ namespace }) mirrors it on the handler context for hooks/diagnostics.
  • The mount path separates instances. Mount each instance's routes under a distinct base path (/api/profile/estate/**, /api/profile/apartment/**). routeBasePath is metadata; what actually routes a request is where you mount the handler. On the client, pass the matching basePath to the UI components / createProfileClient.
  • The same key may exist in two namespaces (e.g. both have a name field) without colliding.
  • Omitting namespace everywhere keeps the pre-0.4.0 single-namespace behaviour ("default").

Field markers

Three boolean/string markers on ProfileField drive host behaviour; all are set in <ProfileFieldBuilder>:

  • isPrimaryLabel — fields concatenated (in sortOrder) into the profile's display name via composePrimaryLabel.
  • isProfilePhoto — designates the single PHOTO field used as the profile's cover image (enforced unique per namespace).
  • semanticRole (0.4.0) — a nullable, host-defined tag (e.g. "occupant_type"). The server accepts any string; the builder restricts choices to the host-supplied allowedSemanticRoles. Use it to let the host find a field by role for charts/aggregation without hard-coding its key.

Field types reference

| Type | Form input | Persists as | Zod schema (active) | | --- | --- | --- | --- | | TEXT | <input type="text"> | string | z.string().min(required ? 1 : 0) | | LONG_TEXT | <textarea> | string | z.string() | | NUMBER | <input type="number"> | number | z.coerce.number() | | BOOLEAN | shadcn <Checkbox> | boolean | z.coerce.boolean() | | DATE | <input type="date"> (custom) | "YYYY-MM-DD" string | z.string().regex(/^\d{4}-\d{2}-\d{2}$/) | | DATETIME | datetime picker | ISO string | z.string().datetime() | | EMAIL | <input type="email"> | string | z.string().email() | | PHONE | <input type="tel"> | string | regex /^\+?[0-9 \-()]+$/ | | URL | <input type="url"> | string | z.string().url() | | SELECT | shadcn <Select> | option value | z.enum([...options.value]) | | MULTI_SELECT | tag-style multi | string[] | z.array(z.enum(...)) | | FILE | presigned-PUT upload | R2 objectKey | z.string() | | PHOTO | presigned-PUT upload, image preview | R2 objectKey | z.string() | | GALLERY | multi-image upload, thumbnail grid + captions | { key, caption? }[] (array order = display order) | z.array(z.object({ key, caption? })) | | MARKDOWN | <textarea> + preview | string | z.string() |

required = false wraps the schema with .optional().nullable().

options (for SELECT / MULTI_SELECT) is [{ value: string, label: string }].


Upgrading to namespaces (0.4.0)

0.4.0 adds a namespace column to Profile and ProfileField, a nullable semanticRole column to ProfileField, and replaces the global unique on ProfileField.key with @@unique([namespace, key]). This is a breaking database migration for existing consumers.

  1. Paste the updated snippet from src/schema/profile.prisma into your schema.
  2. Apply the migration. Either run pnpm prisma migrate dev to let Prisma generate it for your exact schema (recommended), or apply the reference SQL shipped at src/schema/migrations/0001_add_namespace.sql (MySQL/MariaDB; verify your index names with SHOW INDEX first).
  3. Deploy ordering matters. The 0.4.0 adapter resolves fields via the composite unique; code running ≤ 0.3.0 resolves by key alone. Apply the migration together with the package upgrade and redeploy — never against a database still served by 0.3.0 code.

Existing rows are backfilled to namespace "default", and code that passes no namespace continues to behave exactly as before.


Ports

The package never imports your auth library, your Prisma client, or your storage SDK. Instead it accepts five ports:

AuthPort<S>

interface AuthPort<S> {
  requireAuth(req: Request): Promise<S>;
  isAdmin(scope: S): boolean;
  isSuperAdmin?(scope: S): boolean;
  userId(scope: S): string;
}
  • requireAuth returns your typed scope or throws (caller-defined).
  • isAdmin gates write endpoints.
  • isSuperAdmin gates field-schema endpoints.
  • userId returns the actor for hooks + audit.

StoragePort

Same shape as @firstlovecenter/milestone-grid's StoragePort so you can share one R2 impl. Includes presign / streamGet / delete / HMAC stamp + verify / optional max-size + content-type allow-list.

PersistencePort

Created via createPrismaPersistence(prismaClient). If you don't use Prisma, you can implement the interface yourself against any data store.

ProfileHooks<S> (optional)

interface ProfileHooks<S> {
  onProfileCreated?(ctx: { profile, userId, scope }): Promise<void>;
  onProfileUpdated?(ctx: { before, after, userId, scope }): Promise<void>;
  onProfileArchived?(ctx: { profile, userId, reason, scope }): Promise<void>;
  onProfileRestored?(ctx: { profile, userId, scope }): Promise<void>;
  onFieldCreated?(ctx: { field, userId, scope }): Promise<void>;
  onFieldUpdated?(ctx: { before, after, userId, scope }): Promise<void>;
  onFieldArchived?(ctx: { field, userId, scope }): Promise<void>;
}

Use hooks to write your own audit log, to maintain a denormalised projection (e.g., a Missionary table linked 1:1 to Profile), or to fan out events to a queue.


Theming

The package ships two CSS files:

  • styles.css — structural; loads BEFORE Tailwind.
  • theme.css — overridable CSS variables; loads AFTER Tailwind.

Override --profile-* variables in your own :root / .dark block:

:root {
  --profile-brand: var(--primary);
  --profile-surface: var(--card);
  --profile-muted: var(--muted-foreground);
  --profile-border: var(--border);
  --profile-accent: var(--accent);
  --profile-destructive: var(--destructive);
  --profile-radius: 0.5rem;
}

The package's components default to shadcn-compatible values, so the most common case (a shadcn-themed host) needs zero overrides.


Field-schema migration guarantees

  • Editing a ProfileField (rename label, change required, change options) does NOT rewrite existing Profile.customFields. Existing rows preserve their values under the original key.
  • Archiving a ProfileField hides it from forms and lists; values stay in Profile.customFields and are returned by API reads.
  • Restoring a ProfileField brings it back, no row changes needed.
  • Hard-deleting a ProfileField is NOT supported. Archive only.
  • key is immutable. To rename a key, archive the old field and create a new one.

Every field mutation writes a ProfileFieldChange row for the audit trail.


Reuse in another app

To wire @firstlovecenter/flc-profile into a brand-new Next.js + Prisma app (e.g., an HR system):

  1. Paste the Prisma snippet into schema.prisma. Run prisma migrate dev.
  2. Implement your own AuthPort (NextAuth, Lucia, custom — your choice).
  3. Implement / borrow a StoragePort (the package's contract matches milestone-grid's).
  4. Call configureProfile({ ... }) and mount the routes.
  5. Seed your initial ProfileField rows for your domain (employeeNumber, department, etc.).
  6. Import <ProfileList>, <ProfileForm>, <ProfileFieldBuilder> and mount them in your app's pages.

Zero code in this package changes between hosts.

See docs/host-integration.md for the full integration walkthrough using flc-missions as a worked example.


API reference

Routes are mounted under routeBasePath (default /api/profile).

| Method | Path | Auth | Purpose | | --- | --- | --- | --- | | GET | /profiles | isAdmin | List profiles (paginated). Query: archived, q, cursor, limit. | | POST | /profiles | isAdmin | Create profile. Body: { customFields }. | | GET | /profiles/[id] | isAdmin | Get a profile. | | PATCH | /profiles/[id] | isAdmin | Update customFields. | | DELETE | /profiles/[id] | isAdmin | Archive. Body: { reason? }. | | POST | /profiles/[id]/restore | isAdmin | Restore. | | POST | /profiles/[id]/photo | isAdmin | Presigned PUT for profile photo. | | GET | /fields | isAdmin | List field defs. Query: archived. | | POST | /fields | isSuperAdmin | Create field. | | PATCH | /fields/[id] | isSuperAdmin | Update field (label, type, options, etc.). | | DELETE | /fields/[id] | isSuperAdmin | Archive field (data preserved). | | POST | /fields/reorder | isSuperAdmin | Bulk reorder [{ id, sortOrder }]. |

Errors return the standard envelope:

{
  "error": {
    "code": "VALIDATION_ERROR" | "FORBIDDEN" | "NOT_FOUND" | "CONFLICT" | "RATE_LIMITED" | "INTERNAL",
    "message": "Human-readable summary",
    "details": [{ "path": ["..."], "message": "..." }],
    "requestId": "req_..."
  }
}

Type reference

All exports are typed; see src/server/types.ts for canonical definitions.

import type {
  ProfileFieldType,
  FieldOption,
  ProfileDTO,
  ProfileFieldDTO,
  ProfileFieldChangeDTO,
  AuthPort,
  StoragePort,
  PersistencePort,
  PrismaPersistenceOptions,
  ProfileHooks,
  ConfigureProfileOptions,
  ConfiguredProfile,
  ProfileRoutes,
  MemoryPersistence,
  MemoryStore,
} from "@firstlovecenter/flc-profile/server";

// Runtime helpers: configureProfile, createPrismaPersistence,
// createMemoryPersistence, createMemoryStore, buildZodSchema, composePrimaryLabel

UI exports:

import {
  ProfileList,
  ProfileView,
  ProfileForm,
  ProfileFieldBuilder,
  ProfileCard,
  ArchivedProfiles,
  createProfileClient,
  ProfileClientError,
} from "@firstlovecenter/flc-profile/ui";
import type {
  ProfileClient,
  CreateProfileClientOptions,
  ProfileClientErrorDetail,
  ProfileListProps,
  ProfileViewProps,
  ProfileFormProps,
  ProfileFieldBuilderProps,
  ProfileCardProps,
  ArchivedProfilesProps,
} from "@firstlovecenter/flc-profile/ui";

Development

pnpm install
pnpm build      # tsup → dist/
pnpm typecheck
pnpm test
pnpm dev        # tsup --watch

Tests live in src/**/*.test.ts (Vitest).


License

UNLICENSED — internal First Love Centre code.