- Install dependencies
yarn install # or: npm install - Configure environment variables (see
.env.example) - Run a development server
yarn dev
## Scripts
- `yarn dev`: Run the app in development mode
- `yarn build`: Build the app for production
- `yarn lint`: Run linting
- `yarn lint:fix`: Run linting and fix issues
- `yarn format`: Format the codebase
- `yarn format:check`: Check codebase formatting
- `yarn test`: Run the test suite
- `yarn test:watch`: Run the test suite in watch mode
- `yarn test:coverage`: Run the test suite with coverage report
- `yarn storybook`: Start Storybook at http://localhost:6006
- `yarn build-storybook`: Build the static Storybook site
## Supabase configuration
Create the following tables in your Supabase project (SQL view → New query):
```sql
create table if not exists public.myth_folders (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users(id) on delete cascade,
name text not null,
description text,
variants jsonb not null default '[]'::jsonb,
created_at timestamptz not null default now()
);
create table if not exists public.mythemes (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users(id) on delete cascade,
name text not null,
type text not null check (type in ('character','event','place','object')),
created_at timestamptz not null default now()
);
create table if not exists public.profile_settings (
user_id uuid primary key references auth.users(id) on delete cascade,
categories text[] not null default array[]::text[],
updated_at timestamptz not null default now()
);
create table if not exists public.myth_collaborators (
id uuid primary key default gen_random_uuid(),
myth_id uuid references public.myth_folders(id) on delete cascade,
email text not null,
role text not null check (role in ('viewer','editor','owner')),
created_at timestamptz not null default now()
);
create unique index if not exists myth_collaborators_myth_email_idx
on public.myth_collaborators (myth_id, lower(email));
alter table if exists public.myth_folders
add column if not exists contributor_instructions text not null default '';
create table if not exists public.myth_contribution_requests (
id uuid primary key default gen_random_uuid(),
myth_id uuid references public.myth_folders(id) on delete cascade,
email text not null,
token uuid not null default gen_random_uuid(),
status text not null default 'invited' check (status in ('invited','draft','submitted','expired')),
draft_payload jsonb not null default '{"name":"","source":"","plotPoints":[]}'::jsonb,
submitted_variant_id text,
created_at timestamptz not null default timezone('utc', now()),
updated_at timestamptz not null default timezone('utc', now())
);
alter table public.myth_contribution_requests enable row level security;
create policy "Owners read contribution requests"
on public.myth_contribution_requests
for select
using (
auth.uid() = (
select user_id from public.myth_folders where id = myth_contribution_requests.myth_id
)
);
create policy "Owners insert contribution requests"
on public.myth_contribution_requests
for insert
with check (
auth.uid() = (
select user_id from public.myth_folders where id = myth_contribution_requests.myth_id
)
);
create policy "Owners update contribution requests"
on public.myth_contribution_requests
for update
using (
auth.uid() = (
select user_id from public.myth_folders where id = myth_contribution_requests.myth_id
)
)
with check (
auth.uid() = (
select user_id from public.myth_folders where id = myth_contribution_requests.myth_id
)
);
create policy "Owners delete contribution requests"
on public.myth_contribution_requests
for delete
using (
auth.uid() = (
select user_id from public.myth_folders where id = myth_contribution_requests.myth_id
)
);
create or replace function public.get_contribution_request(p_token uuid)
returns table (
request_id uuid,
myth_id uuid,
email text,
status text,
draft_payload jsonb,
myth_name text,
myth_description text,
contributor_instructions text,
updated_at timestamptz
)
language sql
security definer
set search_path = public
as $$
select
r.id as request_id,
r.myth_id,
r.email,
r.status,
r.draft_payload,
coalesce(m.name, '') as myth_name,
coalesce(m.description, '') as myth_description,
coalesce(m.contributor_instructions, '') as contributor_instructions,
r.updated_at as updated_at
from public.myth_contribution_requests r
join public.myth_folders m on m.id = r.myth_id
where r.token = p_token;
$$;
create or replace function public.save_contribution_draft(p_token uuid, p_payload jsonb)
returns table (
request_id uuid,
updated_at timestamptz
)
language plpgsql
security definer
set search_path = public
as $$
declare
incoming jsonb;
normalized_payload jsonb;
begin
incoming := coalesce(p_payload, '{}'::jsonb);
normalized_payload := jsonb_build_object(
'name', coalesce(incoming->>'name', ''),
'source', coalesce(incoming->>'source', ''),
'plotPoints', coalesce(incoming->'plotPoints', '[]'::jsonb)
);
return query
update public.myth_contribution_requests as r
set draft_payload = normalized_payload,
updated_at = timezone('utc', now()),
status = case when r.status = 'invited' then 'draft' else r.status end
where r.token = p_token
and r.status in ('invited', 'draft')
returning r.id as request_id, r.updated_at;
end;
$$;
create or replace function public.submit_contribution_request(p_token uuid)
returns table (variant_id text)
language plpgsql
security definer
set search_path = public
as $$
declare
request_record public.myth_contribution_requests%rowtype;
next_position integer := 1;
plot_points jsonb;
point_record jsonb;
new_variant_id text;
target_sort_order integer;
begin
select * into request_record
from public.myth_contribution_requests
where token = p_token
for update;
if not found then
raise exception 'Invalid contribution link';
end if;
if request_record.status not in ('draft', 'invited') then
return query select coalesce(request_record.submitted_variant_id, '');
end if;
select coalesce(max(sort_order), -1) + 1
into target_sort_order
from public.myth_variants
where myth_id = request_record.myth_id;
new_variant_id := gen_random_uuid()::text;
insert into public.myth_variants (
id,
myth_id,
name,
source,
sort_order,
contributor_email,
contributor_name,
contributor_type,
contribution_request_id
)
values (
new_variant_id,
request_record.myth_id,
coalesce(request_record.draft_payload->>'name', ''),
coalesce(request_record.draft_payload->>'source', ''),
target_sort_order,
lower(coalesce(request_record.email, '')),
'',
'invitee',
request_record.id
);
plot_points := coalesce(request_record.draft_payload->'plotPoints', '[]'::jsonb);
next_position := 1;
for point_record in
select value from jsonb_array_elements(plot_points)
loop
insert into public.myth_plot_points (
id,
variant_id,
position,
text,
category,
mytheme_refs
)
values (
coalesce(point_record->>'id', gen_random_uuid()::text),
new_variant_id,
coalesce((point_record->>'order')::int, next_position),
coalesce(point_record->>'text', ''),
'Uncategorized',
'{}'::text[]
);
next_position := next_position + 1;
end loop;
update public.myth_contribution_requests
set status = 'submitted',
submitted_variant_id = new_variant_id,
updated_at = timezone('utc', now())
where id = request_record.id;
return query select new_variant_id;
end;
$$;
create or replace function public.delete_contribution_request_with_variant(p_request_id uuid)
returns table (deleted_request_id uuid)
language plpgsql
security definer
set search_path = public
as $$
declare
request_record public.myth_contribution_requests%rowtype;
owner_id uuid;
begin
select * into request_record
from public.myth_contribution_requests
where id = p_request_id
for update;
if not found then
raise exception 'Contribution request not found';
end if;
select user_id into owner_id
from public.myth_folders
where id = request_record.myth_id;
if owner_id is null or owner_id <> auth.uid() then
raise exception 'not_authorized';
end if;
if request_record.submitted_variant_id is not null then
delete from public.myth_variants where id = request_record.submitted_variant_id;
end if;
delete from public.myth_contribution_requests where id = p_request_id;
return query select request_record.id;
end;
$$;
-- Edge function email invites
-- Deploy supabase/functions/send-contribution-invite to automatically email contributors
-- after inserting records into myth_contribution_requests.
Enable Row Level Security on each table and add policies so that authenticated users can manage only their own records, for example:
alter table public.myth_folders enable row level security;
alter table public.mythemes enable row level security;
alter table public.profile_settings enable row level security;
alter table public.myth_collaborators enable row level security;
create policy "Manage own myth folders"
on public.myth_folders
for all
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
create policy "Collaborators can view shared myth folders"
on public.myth_folders
for select
using (
auth.uid() = user_id
or exists (
select 1
from public.myth_collaborators mc
where mc.myth_id = myth_folders.id
and lower(mc.email) = lower(current_setting('request.jwt.claim.email', true))
)
);
create policy "Manage own mythemes"
on public.mythemes
for all
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
create policy "Manage own profile settings"
on public.profile_settings
for all
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
create policy "Collaborators can read entries"
on public.myth_collaborators
for select using (
lower(email) = lower(current_setting('request.jwt.claim.email', true))
or auth.uid() = (
select user_id from public.myth_folders where id = myth_id
)
);
create policy "Owners invite collaborators"
on public.myth_collaborators
for insert using (
auth.uid() = (
select user_id from public.myth_folders where id = myth_id
)
)
with check (
auth.uid() = (
select user_id from public.myth_folders where id = myth_id
)
);
create policy "Owners update collaborator roles"
on public.myth_collaborators
for update using (
auth.uid() = (
select user_id from public.myth_folders where id = myth_id
)
)
with check (
auth.uid() = (
select user_id from public.myth_folders where id = myth_id
)
);
create policy "Owners remove collaborators"
on public.myth_collaborators
for delete using (
auth.uid() = (
select user_id from public.myth_folders where id = myth_id
)
);
Finally, add at least one Supabase user (via the Dashboard → Authentication panel) or enable email sign-up so new visitors can create accounts directly from the app.
To send contributor invites automatically when you add email addresses inside the app, deploy the included Edge Function and configure the required secrets:
-
Deploy the function (after logging in with the Supabase CLI):
supabase functions deploy send-contribution-invite
-
Set the secrets the function relies on (replace the placeholders with your values):
supabase secrets set \ RESEND_API_KEY=your-resend-api-key \ CONTRIBUTION_INVITE_FROM_EMAIL="Myth Archive <invites@example.com>" \ CONTRIBUTION_INVITE_APP_URL=https://your-app-hostname \ SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
RESEND_API_KEY– API key from Resend (or update the function to call a different provider).CONTRIBUTION_INVITE_FROM_EMAIL– The verified sender address.CONTRIBUTION_INVITE_APP_URL– Public base URL of the web app (used to build invite links).SUPABASE_SERVICE_ROLE_KEY– Service role key so the Edge Function can read myth + request details. Keep this secret safe.
Once deployed, every new entry in Contribution Requests triggers an email automatically. You can also resend an invitation from the UI at any time.