Merge branch 'master' into please-god-help-us

# Conflicts:
#	.env
#	bun.lockb
#	package.json
#	src/routes/login/+page.server.ts
#	src/routes/login/+page.svelte
This commit is contained in:
piopi 2025-01-04 21:10:12 -05:00
commit 4afc8a9efa
No known key found for this signature in database
GPG key ID: E305BD1ADD33F590
30 changed files with 435 additions and 124 deletions

9
.env
View file

@ -1,4 +1,11 @@
VITE_APP_VERSION=1.0.0-alpha # TWILIO
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
TWILIO_PHONE_NUMBER=
# PRISMA
DATABASE_URL="postgres://hestia:test123@localhost:5432/hestia" DATABASE_URL="postgres://hestia:test123@localhost:5432/hestia"
# CLERK
PUBLIC_CLERK_PUBLISHABLE_KEY=secret_do_not_commit_or_change_this_create_.env.local_instead PUBLIC_CLERK_PUBLISHABLE_KEY=secret_do_not_commit_or_change_this_create_.env.local_instead
CLERK_SECRET_KEY=secret_do_not_commit_or_change_this_create_.env.local_instead CLERK_SECRET_KEY=secret_do_not_commit_or_change_this_create_.env.local_instead

2
.husky/pre-commit Normal file
View file

@ -0,0 +1,2 @@
bunx prettier . --write
bunx eslint_d $(git diff --name-only HEAD | grep -E '\.(*)$' | xargs)

BIN
bun.lockb

Binary file not shown.

View file

@ -1,11 +1,21 @@
{ {
"$schema": "https://inlang.com/schema/inlang-message-format", "$schema": "https://inlang.com/schema/inlang-message-format",
"greeting": "Hello {name}!", "nav_greeting": "Hello {name}!",
"email_required": "Email is required", "login_tab_login": "Login",
"password_required": "Password is required", "login_tab_register": "Register",
"name_required": "Name is required", "login_label_email": "Email",
"email_incorrect": "Email is incorrect", "login_label_password": "Password",
"password_incorrect": "Password is incorrect", "login_label_name": "Name",
"email_inuse": "Email is already in use", "login_button_submit": "Submit",
"user_not_found": "The user could not be found" "login_error_email_required": "Email is required",
"login_error_password_required": "Password is required",
"login_error_name_required": "Name is required",
"login_error_email_incorrect": "Email is incorrect",
"login_error_password_incorrect": "Password is incorrect",
"login_error_email_inuse": "Email is already in use",
"login_error_user_not_found": "The user could not be found",
"sms_prompt": "Send a Message",
"sms_label_phone": "Phone Number",
"sms_label_message": "Message",
"sms_button_submit": "Send Message"
} }

View file

@ -3,7 +3,7 @@
"version": "0.0.1", "version": "0.0.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "bun database:up && bun prisma:generate && bun prisma:push && vite dev", "dev": "bun validate-env && bun database:up && bun prisma:generate && bun prisma:push && vite dev",
"build": "vite build", "build": "vite build",
"build-storybook": "storybook build", "build-storybook": "storybook build",
"database:up": "docker compose -p hestia -f devops/docker-compose.dev.yml up -d && docker compose -p hestia -f devops/docker-compose.dev.yml -f devops/docker-compose.wait.yml run --rm wait -c hestia-database:5432", "database:up": "docker compose -p hestia -f devops/docker-compose.dev.yml up -d && docker compose -p hestia -f devops/docker-compose.dev.yml -f devops/docker-compose.wait.yml run --rm wait -c hestia-database:5432",
@ -23,7 +23,9 @@
"prisma:push": "prisma db push", "prisma:push": "prisma db push",
"prisma:reset": "prisma migrate reset --force", "prisma:reset": "prisma migrate reset --force",
"prisma:studio": "prisma studio", "prisma:studio": "prisma studio",
"prisma:validate": "prisma validate" "prisma:validate": "prisma validate",
"prepare": "husky",
"validate-env": "bun ./scripts/validate-env.ts"
}, },
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "^3.2.2", "@chromatic-com/storybook": "^3.2.2",
@ -48,6 +50,7 @@
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.36.0", "eslint-plugin-svelte": "^2.36.0",
"globals": "^15.0.0", "globals": "^15.0.0",
"husky": "^9.1.7",
"prettier": "^3.3.2", "prettier": "^3.3.2",
"prettier-plugin-svelte": "^3.2.6", "prettier-plugin-svelte": "^3.2.6",
"prettier-plugin-tailwindcss": "^0.6.5", "prettier-plugin-tailwindcss": "^0.6.5",
@ -73,12 +76,13 @@
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"clerk-sveltekit": "https://pkg.pr.new/wobsoriano/clerk-sveltekit@ca15d4e", "clerk-sveltekit": "https://pkg.pr.new/wobsoriano/clerk-sveltekit@ca15d4e",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"eslint_d": "^14.3.0",
"graphql": "^16.9.0", "graphql": "^16.9.0",
"graphql-yoga": "^5.10.4", "graphql-yoga": "^5.10.4",
"oslo": "^1.2.1",
"pino": "^9.5.0", "pino": "^9.5.0",
"pino-pretty": "^13.0.0", "pino-pretty": "^13.0.0",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
"twilio": "^5.4.0",
"zod": "^3.24.0" "zod": "^3.24.0"
} }
} }

19
scripts/validate-env.ts Normal file
View file

@ -0,0 +1,19 @@
import { PhoneRegex } from '../src/lib/regex/phone';
import { z } from 'zod';
const ValidateEnvironment = () => {
const { success, error } = z
.object({
TWILIO_ACCOUNT_SID: z.string().min(1),
TWILIO_AUTH_TOKEN: z.string().min(1),
TWILIO_PHONE_NUMBER: z.string().regex(PhoneRegex),
})
.safeParse(process.env);
if (!success) {
console.error(error.message);
process.exit(1);
}
};
ValidateEnvironment();

1
src/app.d.ts vendored
View file

@ -1,4 +1,5 @@
// See https://svelte.dev/docs/kit/types#app.d.ts // See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces // for information about these interfaces
declare global { declare global {
namespace App { namespace App {

View file

@ -1,7 +1,8 @@
<script module lang="ts"> <script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf'; import { defineMeta } from '@storybook/addon-svelte-csf';
import Button from './Button.svelte';
import { fn } from '@storybook/test'; import { fn } from '@storybook/test';
import type { ComponentProps } from 'svelte';
import Button from './Button.svelte';
const { Story } = defineMeta({ const { Story } = defineMeta({
title: 'Actions/Button', title: 'Actions/Button',
@ -10,6 +11,7 @@
onClick: fn(), onClick: fn(),
}, },
argTypes: { argTypes: {
block: { control: 'boolean' },
color: { color: {
control: 'select', control: 'select',
options: [ options: [
@ -25,9 +27,12 @@
'error', 'error',
], ],
}, },
full: { control: 'boolean' },
glass: { control: 'boolean' },
outline: { outline: {
control: 'boolean', control: 'boolean',
}, },
responsive: { control: 'boolean' },
size: { size: {
control: 'select', control: 'select',
options: ['Default', 'xs', 'sm', 'lg'], options: ['Default', 'xs', 'sm', 'lg'],
@ -37,8 +42,13 @@
control: 'select', control: 'select',
options: ['button', 'reset', 'submit'], options: ['button', 'reset', 'submit'],
}, },
wide: { control: 'boolean' },
}, },
}); });
</script> </script>
<Story name="Default" args={{ label: 'Button', color: 'primary' }} /> {#snippet template({ children: _, ...props }: Partial<ComponentProps<typeof Button>>)}
<Button {...props}>Button</Button>
{/snippet}
<Story name="Default" args={{}} children={template} />

View file

@ -1,12 +1,14 @@
<script lang="ts"> <script lang="ts">
import type { DaisyColor, DaisySize } from '$lib/types'; import type { DaisyColor, DaisySize } from '$lib/types';
import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements'; import type { HTMLButtonAttributes } from 'svelte/elements';
interface Props { interface Props {
block?: boolean; block?: boolean;
children: Snippet;
color?: DaisyColor; color?: DaisyColor;
full?: boolean;
glass?: boolean; glass?: boolean;
label: string;
outline?: boolean; outline?: boolean;
onClick?: () => void; onClick?: () => void;
responsive?: boolean; responsive?: boolean;
@ -17,9 +19,10 @@
let { let {
block = false, block = false,
children,
color, color,
full = false,
glass = false, glass = false,
label,
outline = false, outline = false,
onClick, onClick,
responsive = false, responsive = false,
@ -35,6 +38,7 @@
class:btn-outline={outline} class:btn-outline={outline}
class:btn-block={block} class:btn-block={block}
class:btn-wide={wide} class:btn-wide={wide}
class:w-full={full}
class:glass class:glass
class:btn-xs={size === 'xs'} class:btn-xs={size === 'xs'}
class:btn-sm={size === 'sm'} class:btn-sm={size === 'sm'}
@ -51,7 +55,7 @@
class:btn-error={color === 'error'} class:btn-error={color === 'error'}
class={`btn ${responsive && 'btn-xs sm:btn-sm md:btn-md lg:btn-lg'}`} class={`btn ${responsive && 'btn-xs sm:btn-sm md:btn-md lg:btn-lg'}`}
> >
{label} {@render children()}
</button> </button>
<style></style> <style></style>

View file

@ -0,0 +1 @@
export { default as Button } from './Button.svelte';

View file

@ -6,27 +6,36 @@
title: 'Data Input/Text Input', title: 'Data Input/Text Input',
component: TextInput, component: TextInput,
argTypes: { argTypes: {
bordered: {
control: 'boolean',
},
color: { color: {
control: 'select', control: 'select',
options: [ options: [
'ghost',
'primary', 'primary',
'secondary', 'secondary',
'accent', 'accent',
'ghost',
'link',
'info', 'info',
'success', 'success',
'warning', 'warning',
'error', 'error',
], ],
}, },
bordered: { disabled: {
control: 'boolean', control: 'boolean',
}, },
error: {
control: 'text',
},
fade: { control: 'boolean' },
start: { control: 'text' },
end: { control: 'text' },
label: { control: 'text' },
placeholder: { control: 'text' },
size: { size: {
control: 'select', control: 'select',
options: ['Default', 'xs', 'sm', 'lg'], options: ['xs', 'sm', '-', 'lg'],
defaultValue: 'Default',
}, },
type: { type: {
control: 'select', control: 'select',

View file

@ -0,0 +1,99 @@
<script lang="ts">
import type { DaisyColor, DaisySize } from '$lib/types';
import type { Snippet } from 'svelte';
import type { HTMLInputTypeAttribute } from 'svelte/elements';
import { fade as fadeTransition } from 'svelte/transition';
type Props = {
bordered?: boolean;
color?: Exclude<DaisyColor, 'neutral'>;
disabled?: boolean;
error?: string | Snippet;
fade?: boolean;
start?: string | Snippet;
end?: string | Snippet;
label?: string | Snippet;
name: string;
placeholder?: string;
size?: DaisySize;
type?: Extract<
HTMLInputTypeAttribute,
'email' | 'password' | 'search' | 'tel' | 'text' | 'url'
>;
};
let {
bordered = false,
color,
disabled,
error,
fade,
start,
end,
label,
name,
placeholder,
size,
type = 'text',
}: Props = $props();
</script>
<label class="form-control w-full">
<div class="label">
<span
class="label-text"
class:text-primary={color === 'primary'}
class:text-secondary={color === 'secondary'}
class:text-accent={color === 'accent'}
class:text-info={color === 'info'}
class:text-success={color === 'success'}
class:text-warning={color === 'warning'}
class:text-error={color === 'error' || error}
>
{#if typeof label === 'string'}
{label}
{:else if label}
{@render label()}
{/if}
</span>
<span class="label-text-alt font-semibold text-error">
{#if typeof error === 'string'}
{error}
{:else if error}
{@render error()}
{/if}
</span>
</div>
<div
transition:fadeTransition={{ duration: fade ? 200 : 0 }}
class="input flex w-full items-center gap-2"
class:input-bordered={bordered}
class:input-xs={size === 'xs'}
class:input-sm={size === 'sm'}
class:input-lg={size === 'lg'}
class:input-primary={color === 'primary'}
class:input-secondary={color === 'secondary'}
class:input-accent={color === 'accent'}
class:input-ghost={color === 'ghost'}
class:input-link={color === 'link'}
class:input-info={color === 'info'}
class:input-success={color === 'success'}
class:input-warning={color === 'warning'}
class:input-error={color === 'error' || error}
>
{#if typeof start === 'string'}
{start}
{:else}
{@render start?.()}
{/if}
<input {disabled} {name} {placeholder} {type} class="grow" />
{#if typeof end === 'string'}
{end}
{:else}
{@render end?.()}
{/if}
</div>
</label>
<style>
</style>

View file

@ -0,0 +1,51 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Textarea from './Textarea.svelte';
import type { ComponentProps } from 'svelte';
const { Story } = defineMeta({
title: 'Data Input/Textarea',
component: Textarea,
argTypes: {
bordered: {
control: 'boolean',
},
color: {
control: 'select',
options: [
'default',
'ghost',
'primary',
'secondary',
'accent',
'info',
'success',
'warning',
'error',
],
},
disabled: {
control: 'boolean',
},
error: {
control: 'text',
},
label: {
control: 'text',
},
placeholder: {
control: 'text',
},
size: {
control: 'select',
options: ['xs', 'sm', '-', 'lg'],
},
},
});
</script>
{#snippet template(props: ComponentProps<typeof Textarea>)}
<Textarea {...props} />
{/snippet}
<Story name="Default" args={{ label: 'Label' }} children={template} />

View file

@ -0,0 +1,61 @@
<script lang="ts">
import type { DaisyColor, DaisySize } from '$lib/types';
import type { Snippet } from 'svelte';
type Props = {
bordered?: boolean;
color?: Omit<DaisyColor, 'neutral'>;
disabled?: boolean;
error?: string | Snippet;
form?: string;
label?: string | Snippet;
name?: string;
placeholder?: string;
size?: DaisySize;
};
let { bordered, color, error, label, size, ...props }: Props = $props();
</script>
<label class="form-control w-full max-w-lg">
<div class="label">
<span
class="label-text"
class:text-primary={color === 'primary'}
class:text-secondary={color === 'secondary'}
class:text-accent={color === 'accent'}
class:text-info={color === 'info'}
class:text-success={color === 'success'}
class:text-warning={color === 'warning'}
class:text-error={color === 'error' || error}
>
{#if typeof label === 'string'}
{label}
{:else if label}
{@render label()}
{/if}
</span>
<span class="label-text-alt font-semibold text-error">
{#if typeof error === 'string'}
{error}
{:else if error}
{@render error()}
{/if}
</span>
</div>
<textarea
class="textarea"
class:textarea-bordered={bordered}
class:textarea-xs={size === 'xs'}
class:textarea-sm={size === 'sm'}
class:textarea-lg={size === 'lg'}
class:textarea-ghost={color === 'ghost'}
class:textarea-primary={color === 'primary'}
class:textarea-secondary={color === 'secondary'}
class:textarea-accent={color === 'accent'}
class:textarea-info={color === 'info'}
class:textarea-success={color === 'success'}
class:textarea-warning={color === 'warning'}
class:textarea-error={color === 'error' || error}
{...props}
></textarea>
</label>

View file

@ -0,0 +1,2 @@
export { default as Textarea } from './Textarea.svelte';
export { default as TextInput } from './TextInput.svelte';

View file

@ -4,7 +4,7 @@
let { title, username }: { title: string; username: string } = $props(); let { title, username }: { title: string; username: string } = $props();
let message = $derived(messages.greeting({ name: username })); let message = $derived(messages.nav_greeting({ name: username }));
</script> </script>
<header class="navbar justify-between bg-base-200 px-4"> <header class="navbar justify-between bg-base-200 px-4">

View file

@ -1,3 +0,0 @@
import Button from './Button.svelte';
export default Button;

View file

@ -1,68 +0,0 @@
<script lang="ts">
import type { DaisyColor, DaisySize } from '$lib/types';
import type { Snippet } from 'svelte';
import type { HTMLInputTypeAttribute } from 'svelte/elements';
import { fade as fadeTransition } from 'svelte/transition';
type Props = {
bordered?: boolean;
color?: Exclude<DaisyColor, 'neutral'>;
disabled?: boolean;
fade?: boolean;
start?: Snippet | string;
end?: Snippet | string;
name: string;
placeholder?: string;
size?: DaisySize;
type?: Extract<
HTMLInputTypeAttribute,
'email' | 'password' | 'search' | 'tel' | 'text' | 'url'
>;
};
let {
bordered = false,
color,
disabled,
fade,
start,
end,
name,
placeholder,
size,
type = 'text',
}: Props = $props();
</script>
<label
transition:fadeTransition={{ duration: fade ? 200 : 0 }}
class="input flex w-full items-center gap-2"
class:input-bordered={bordered}
class:input-xs={size === 'xs'}
class:input-sm={size === 'sm'}
class:input-lg={size === 'lg'}
class:input-primary={color === 'primary'}
class:input-secondary={color === 'secondary'}
class:input-accent={color === 'accent'}
class:input-ghost={color === 'ghost'}
class:input-link={color === 'link'}
class:input-info={color === 'info'}
class:input-success={color === 'success'}
class:input-warning={color === 'warning'}
class:input-error={color === 'error'}
>
{#if typeof start === 'string'}
{start}
{:else}
{@render start?.()}
{/if}
<input {disabled} {name} {placeholder} {type} class="grow" />
{#if typeof end === 'string'}
{end}
{:else}
{@render end?.()}
{/if}
</label>
<style>
</style>

View file

@ -1,3 +0,0 @@
import TextInput from './TextInput.svelte';
export default TextInput;

1
src/lib/regex/index.ts Normal file
View file

@ -0,0 +1 @@
export * from './phone';

1
src/lib/regex/phone.ts Normal file
View file

@ -0,0 +1 @@
export const PhoneRegex = /^\+?\d?(\d{3}\d{3}\d{4}|\d{3}-\d{3}-\d{4})$/;

View file

@ -1,24 +0,0 @@
import { logger } from '$lib/server/logger';
import { z } from 'zod';
export interface Configuration {
app_version: string;
}
export const LoadConfig = (): Configuration => {
const { success, data, error } = z
.object({
VITE_APP_VERSION: z.string().default('development'),
})
.safeParse(import.meta.env);
if (!success) {
logger.error(error.message);
}
return {
app_version: data!.VITE_APP_VERSION,
};
};
export const Config = LoadConfig();

View file

@ -1,3 +1,4 @@
import { version } from '$app/environment';
import { builder } from '../builder'; import { builder } from '../builder';
builder.queryType({}); builder.queryType({});
@ -5,7 +6,7 @@ builder.queryType({});
builder.queryField('version', (t) => builder.queryField('version', (t) =>
t.string({ t.string({
description: 'Application version', description: 'Application version',
resolve: (parent, args, context) => context.config.app_version, resolve: () => version,
}) })
); );

View file

@ -0,0 +1,4 @@
import { TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN } from '$env/static/private';
import twilio from 'twilio';
export const TwilioClient = twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN);

View file

@ -0,0 +1 @@
export * from './client';

View file

@ -1,7 +1,6 @@
import { Config } from '$lib/server/config';
import type { YogaInitialContext } from 'graphql-yoga'; import type { YogaInitialContext } from 'graphql-yoga';
export const Context = (initialContext: YogaInitialContext) => ({ export const Context = (initialContext: YogaInitialContext) => ({
...initialContext, ...initialContext,
config: Config, config: {},
}); });

View file

@ -1,2 +1,4 @@
<script lang="ts"> <script lang="ts">
</script> </script>
<a href="/app/sms" class="btn btn-ghost">SMS</a>

View file

@ -0,0 +1,49 @@
import { TWILIO_PHONE_NUMBER } from '$env/static/private';
import { PhoneRegex } from '$lib/regex';
import { logger } from '$lib/server/logger';
import { TwilioClient } from '$lib/server/twilio';
import { fail, type Actions } from '@sveltejs/kit';
import zod from 'zod';
export const actions = {
push: async (event) => {
const form = await event.request.formData();
if (!form.has('phone')) {
return fail(400, { error: 'phone_missing' });
}
if (!form.get('message')) {
return fail(400, { error: 'message_missing' });
}
const {
success: phoneSuccess,
data: phone,
error: phoneError,
} = zod.string().regex(PhoneRegex).safeParse(form.get('phone'));
if (!phoneSuccess) {
logger.error(phoneError);
return fail(400, { error: 'invalid_phone' });
}
const message = form.get('message');
if (typeof message !== 'string') {
return fail(400, { error: 'invalid_message' });
}
try {
const result = await TwilioClient.messages.create({
to: phone,
body: message,
from: TWILIO_PHONE_NUMBER,
});
logger.debug(result);
} catch (e) {
logger.error(e);
fail(500, { success: false });
}
return {
success: true,
};
},
} satisfies Actions;

View file

@ -0,0 +1,68 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { Button } from '$lib/components/Actions';
import { Textarea, TextInput } from '$lib/components/DataInput';
import { fade } from 'svelte/transition';
import type { ActionData } from './$types';
import { messages } from '$lib/i18n';
type Props = {
form: ActionData;
};
let { form }: Props = $props();
</script>
{#snippet PhoneLabel()}
<i class="fi fi-sr-phone-flip"></i> {messages.sms_label_phone()}
{/snippet}
{#snippet MessageLabel()}
<i class="fi fi-sr-comment-alt"></i> {messages.sms_label_message()}
{/snippet}
{#snippet alert(message: string)}
<div role="alert" class="alert alert-error absolute -top-20" transition:fade>
<i class="fi fi-bs-octagon-xmark h-6 w-6 shrink-0"></i>
<span>{message}</span>
</div>
{/snippet}
<div class="page" transition:fade>
<div class="card bg-base-200 px-4 pt-4 shadow-xl">
<div class="card-title justify-center">
<h2 class="text-2xl font-semibold">{messages.sms_prompt()}</h2>
{#if form?.error}
{@render alert(form.error)}
{/if}
</div>
<form id="sms" method="POST" action="?/push" use:enhance>
<div class="card-body">
<TextInput
type="tel"
name="phone"
label={PhoneLabel}
placeholder="XXX-XXX-XXXX"
bordered
fade
/>
<Textarea
label={MessageLabel}
size="lg"
error={form?.error}
name="message"
placeholder="..."
form="sms"
/>
</div>
<div class="card-actions justify-center px-8 pb-4">
<Button outline type="submit" full>{messages.sms_button_submit()}</Button>
</div>
</form>
</div>
</div>
<style>
.page {
@apply flex flex-col items-center justify-around gap-24 py-[10%];
}
</style>

View file

@ -8,6 +8,9 @@ const config = {
preprocess: vitePreprocess(), preprocess: vitePreprocess(),
kit: { kit: {
version: {
name: '1.0.0-alpha',
},
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter. // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters. // See https://svelte.dev/docs/kit/adapters for more information about adapters.