pancy fants

This commit is contained in:
john 2025-05-06 12:53:32 +02:00
parent a4fd3a3556
commit b6633d6f25
15 changed files with 339 additions and 116 deletions

View file

@ -18,8 +18,8 @@ export default function FancyTextEditor({
value: _value, value: _value,
onInput, onInput,
onKeyDown, onKeyDown,
className, className: extraClasses = '',
placeholder, placeholder = '',
}: TextInputProps) { }: TextInputProps) {
const divRef = useRef<HTMLDivElement>(null) const divRef = useRef<HTMLDivElement>(null)
const [hasFocus, setHasFocus] = useState(false) const [hasFocus, setHasFocus] = useState(false)
@ -39,7 +39,7 @@ export default function FancyTextEditor({
} }
if (!value && !hasFocus) { if (!value && !hasFocus) {
div.innerText = placeholder ?? '' div.innerText = placeholder
} else if (div.innerText !== value) { } else if (div.innerText !== value) {
div.innerText = value div.innerText = value
} }
@ -58,7 +58,7 @@ export default function FancyTextEditor({
const blurListener = () => { const blurListener = () => {
setHasFocus(false) setHasFocus(false)
if (!value) { if (!value) {
div.innerText = placeholder ?? '' div.innerText = placeholder
} }
} }
@ -86,7 +86,7 @@ export default function FancyTextEditor({
return ( return (
<div <div
ref={divRef} ref={divRef}
className={`w-full p-3 resize-none border border-gray-200 rounded-md focus:outline-none focus:border-gray-300 ${textColour} min-h-30 ${className ?? ''}`} className={`text-input w-full min-h-30 ${textColour} ${extraClasses}`}
contentEditable contentEditable
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
suppressContentEditableWarning suppressContentEditableWarning

11
src/components/NavBar.tsx Normal file
View file

@ -0,0 +1,11 @@
import { Link } from 'react-router'
export default function NavBar() {
return (
<nav className={`w-full flex flex-row-reverse px-4 md:px-8 py-0.5`}>
<Link className={`text-gray-800`} to="/signup">
create account
</Link>
</nav>
)
}

View file

@ -1,5 +1,8 @@
import { useState, ChangeEvent } from 'react' import { useState } from 'react'
import FancyTextEditor, { TextInputKeyDownEvent } from './FancyTextEditor.tsx' import FancyTextEditor, { TextInputKeyDownEvent } from './FancyTextEditor.tsx'
import PrimaryButton from './PrimaryButton.tsx'
import SecondaryButton from './SecondaryButton.tsx'
import { openFileDialog } from '../utils/openFileDialog.ts'
interface NewPostWidgetProps { interface NewPostWidgetProps {
onSubmit: (content: string, media: File[]) => void onSubmit: (content: string, media: File[]) => void
@ -20,20 +23,19 @@ export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPos
setContent(value) setContent(value)
} }
const handleMediaChange = (e: ChangeEvent<HTMLInputElement>) => { async function onAddMediaClicked() {
const inputEl = e.target as HTMLInputElement const files = await openFileDialog('image/*', true)
if (inputEl.files == null || inputEl.files.length === 0) { if (files == null || files.length === 0) {
return return
} }
const newFiles = Array.from(inputEl.files).map((file) => ({ const newFiles = Array.from(files).map((file) => ({
id: crypto.randomUUID(), id: crypto.randomUUID(),
file, file,
objectUrl: URL.createObjectURL(file), objectUrl: URL.createObjectURL(file),
})) }))
setAttachments((attachments) => [...attachments, ...newFiles]) setAttachments((attachments) => [...attachments, ...newFiles])
inputEl.value = ''
} }
const handleSubmit = () => { const handleSubmit = () => {
@ -90,24 +92,13 @@ export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPos
)} )}
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<label className="cursor-pointer text-gray-500 hover:text-gray-700"> <SecondaryButton onClick={onAddMediaClicked}>+ add media</SecondaryButton>
<input <PrimaryButton
type="file"
accept="image/*"
onChange={handleMediaChange}
className="hidden"
disabled={isSubmitting}
/>
<span className="flex items-center">+ Add media</span>
</label>
<button
className="px-4 py-2 bg-gray-800 text-white rounded-md hover:bg-gray-700 disabled:opacity-50"
onClick={handleSubmit} onClick={handleSubmit}
disabled={isSubmitting || (content.trim() === '' && attachments.length === 0)} disabled={isSubmitting || (content.trim() === '' && attachments.length === 0)}
> >
Post post
</button> </PrimaryButton>
</div> </div>
</div> </div>
) )

View file

@ -0,0 +1,27 @@
import { PropsWithChildren } from 'react'
interface PrimaryButtonProps {
disabled?: boolean
type?: 'submit' | 'button'
onClick?: () => void
className?: string
}
export default function PrimaryButton({
disabled = false,
type = 'button',
onClick = () => {},
className: extraClasses = '',
children,
}: PropsWithChildren<PrimaryButtonProps>) {
return (
<button
type={type}
disabled={disabled}
onClick={onClick}
className={`primary-button ${extraClasses}`}
>
{children}
</button>
)
}

View file

@ -0,0 +1,18 @@
import { PropsWithChildren } from 'react'
interface PrimaryLinkButtonProps {
href: string
className?: string
}
export default function PrimaryLinkButton({
href,
className: extraClasses = '',
children,
}: PropsWithChildren<PrimaryLinkButtonProps>) {
return (
<a href={href} className={`primary-button text-center ${extraClasses}`}>
{children}
</a>
)
}

View file

@ -0,0 +1,31 @@
import { PropsWithChildren } from 'react'
interface PrimaryButtonProps {
disabled?: boolean
type?: 'submit' | 'button'
onClick?: () => void
className?: string
}
export default function SecondaryButton({
disabled = false,
type = 'button',
onClick = () => {},
className: extraClasses = '',
children,
}: PropsWithChildren<PrimaryButtonProps>) {
return (
<button
type={type}
disabled={disabled}
onClick={onClick}
className={`
px-4 p-2 rounded-md
text-primary-500 hover:text-primary-700
cursor-pointer disabled:cursor-default
${extraClasses}
`}
>
{children}
</button>
)
}

View file

@ -0,0 +1,31 @@
interface TextInputProps {
id?: string
type?: 'text' | 'email' | 'password'
value: string
onInput: (value: string) => void
className?: string
placeholder?: string
required?: boolean
}
export default function TextInput({
id,
value,
onInput,
className: extraClasses = '',
placeholder = '',
type = 'text',
required = false,
}: TextInputProps) {
return (
<input
id={id}
value={value}
type={type}
required={required}
onChange={(e) => onInput(e.target.value)}
placeholder={placeholder}
className={`text-input w-full ${extraClasses}`}
/>
)
}

View file

@ -28,5 +28,5 @@ export function useFeedViewModel(
} }
}, [loadMore, hasMore]) }, [loadMore, hasMore])
return [pages, setPages, loadNextPage] as const return { pages, setPages, loadNextPage } as const
} }

View file

@ -6,4 +6,66 @@
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
}
@layer variables {
:root {
/* TODO find out if tailwind exposes these spacing variables already */
--spacing-2: calc(var(--spacing) * 2);
--spacing-4: calc(var(--spacing) * 4);
}
}
@theme {
--color-primary-50: var(--color-gray-50);
--color-primary-100: var(--color-gray-100);
--color-primary-200: var(--color-gray-200);
--color-primary-300: var(--color-gray-300);
--color-primary-400: var(--color-gray-400);
--color-primary-500: var(--color-gray-500);
--color-primary-600: var(--color-gray-600);
--color-primary-700: var(--color-gray-700);
--color-primary-800: var(--color-gray-800);
--color-primary-900: var(--color-gray-900);
}
@layer components {
/*
component class for text-inputs
as of writing this we want to reuse this for FancyTextEditor and TextInput
which is shitty and difficult to do by creating some react component with tailwind
it might be they need to diverge in the future, in which case i suppose we can
split this up, and potentially inline the tailwind classes
*/
.text-input {
padding: var(--spacing-2);
resize: none;
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-md);
}
.text-input:focus {
outline: none;
border-color: var(--color-gray-300);
}
.primary-button {
padding: var(--spacing-2) var(--spacing-4);
background: var(--color-primary-800);
color: var(--color-white);
cursor: pointer;
border-radius: var(--radius-md);
}
.primary-button:hover {
background: var(--color-primary-700);
}
.primary-button:disabled {
opacity: 50%;
cursor: default;
}
} }

View file

@ -1,15 +0,0 @@
import { PropsWithChildren } from 'react'
import { Link } from 'react-router'
export default function AppLayout({ children }: PropsWithChildren) {
return (
<div>
<nav className={`w-full flex flex-row-reverse px-4 md:px-8 py-0.5`}>
<Link className={`text-gray-800`} to="/signup">
create account
</Link>
</nav>
<main className={`w-full`}>{children}</main>
</div>
)
}

View file

@ -0,0 +1,17 @@
import { PropsWithChildren, ReactNode } from 'react'
interface SingleColumnLayoutProps {
navbar?: ReactNode
}
export default function SingleColumnLayout({
children,
navbar,
}: PropsWithChildren<SingleColumnLayoutProps>) {
return (
<div>
{navbar}
<main className={`w-full max-w-3xl mx-auto`}>{children}</main>
</div>
)
}

View file

@ -2,6 +2,9 @@ import { useCallback } from 'react'
import FeedView from '../feed/FeedView.tsx' import FeedView from '../feed/FeedView.tsx'
import { PostsService } from '../model/posts/postsService.ts' import { PostsService } from '../model/posts/postsService.ts'
import { useParams } from 'react-router' import { useParams } from 'react-router'
import SingleColumnLayout from '../layouts/SingleColumnLayout.tsx'
import NavBar from '../components/NavBar.tsx'
import { useFeedViewModel } from '../feed/FeedView.ts'
interface AuthorPageParams { interface AuthorPageParams {
postsService: PostsService postsService: PostsService
@ -17,5 +20,11 @@ export default function AuthorPage({ postsService }: AuthorPageParams) {
[postsService, username], [postsService, username],
) )
return <FeedView loadMore={fetchPosts} /> const { pages, loadNextPage } = useFeedViewModel(fetchPosts)
return (
<SingleColumnLayout navbar={<NavBar />}>
<FeedView pages={pages} onLoadMore={loadNextPage} />
</SingleColumnLayout>
)
} }

View file

@ -4,10 +4,11 @@ import { PostsService } from '../model/posts/postsService.ts'
import { useUserStore } from '../store/userStore.ts' import { useUserStore } from '../store/userStore.ts'
import { MediaService } from '../model/mediaService.ts' import { MediaService } from '../model/mediaService.ts'
import NewPostWidget from '../components/NewPostWidget.tsx' import NewPostWidget from '../components/NewPostWidget.tsx'
import { useFeedViewModel } from '../feed/feedViewModel.ts' import { useFeedViewModel } from '../feed/FeedView.ts'
import { Post } from '../model/posts/posts.ts' import { Post } from '../model/posts/posts.ts'
import { Temporal } from '@js-temporal/polyfill' import { Temporal } from '@js-temporal/polyfill'
import AppLayout from '../layouts/AppLayout.tsx' import SingleColumnLayout from '../layouts/SingleColumnLayout.tsx'
import NavBar from '../components/NavBar.tsx'
interface HomePageProps { interface HomePageProps {
postsService: PostsService postsService: PostsService
@ -26,7 +27,7 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
[postsService], [postsService],
) )
const [pages, setPages, loadNextPage] = useFeedViewModel(fetchPosts) const { pages, setPages, loadNextPage } = useFeedViewModel(fetchPosts)
const onCreatePost = useCallback( const onCreatePost = useCallback(
async (content: string, files: File[]) => { async (content: string, files: File[]) => {
@ -49,11 +50,11 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
) )
return ( return (
<AppLayout> <SingleColumnLayout navbar={<NavBar />}>
<main className={`w-full max-w-3xl mx-auto`}> <main className={`w-full max-w-3xl mx-auto`}>
<NewPostWidget onSubmit={onCreatePost} isSubmitting={isSubmitting} /> <NewPostWidget onSubmit={onCreatePost} isSubmitting={isSubmitting} />
<FeedView pages={pages} onLoadMore={loadNextPage} /> <FeedView pages={pages} onLoadMore={loadNextPage} />
</main> </main>
</AppLayout> </SingleColumnLayout>
) )
} }

View file

@ -1,85 +1,111 @@
import { useParams } from 'react-router' import { useParams } from 'react-router'
import { useState } from 'react' import { useEffect, useRef, useState } from 'react'
import SingleColumnLayout from '../layouts/SingleColumnLayout.tsx'
import TextInput from '../components/TextInput.tsx'
import PrimaryButton from '../components/PrimaryButton.tsx'
import PrimaryLinkButton from '../components/PrimaryLinkButton.tsx'
const SignupCodeKey = 'signupCode'
export default function SignupPage() { export default function SignupPage() {
const { code: signupCode } = useParams() const { code } = useParams()
const [signupCode, setSignupCode] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [username, setUsername] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const dialogRef = useRef<HTMLDialogElement | null>(null)
useEffect(() => {
if (signupCode) return
let theSignupCode: string | null
if (code) {
theSignupCode = code
setSignupCode(theSignupCode)
localStorage.setItem(SignupCodeKey, theSignupCode)
} else {
theSignupCode = localStorage.getItem(SignupCodeKey)
}
if (!theSignupCode) {
dialogRef.current?.showModal()
}
}, [code, signupCode])
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault() e.preventDefault()
setIsSubmitting(true) setIsSubmitting(true)
}
if (!signupCode) { try {
return <RejectionMessage /> // todo
} finally {
setIsSubmitting(false)
}
} }
return ( return (
<main className="w-full max-w-3xl mx-auto p-4"> <SingleColumnLayout>
<div className="mt-12"> <main className="w-full mx-auto p-4">
<form className="flex flex-col gap-4 max-w-md" onSubmit={onSubmit}> <div className="mt-12">
<div className="flex flex-col gap-2"> <form className="flex flex-col gap-4 max-w-md" onSubmit={onSubmit}>
<label htmlFor="username" className="text-sm text-gray-600"> <div className="flex flex-col gap-2">
Username <label htmlFor="username" className="text-sm text-gray-600">
</label> username
<input </label>
id="username" <TextInput id="username" value={username} onInput={setUsername} required />
type="text" </div>
className="p-2 border rounded bg-white/50 focus:outline-none focus:border-gray-400"
required
/>
</div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="email" className="text-sm text-gray-600"> <label htmlFor="email" className="text-sm text-gray-600">
Email (optional) email (optional)
</label> </label>
<input <TextInput id="email" value={email} onInput={setEmail} required />
id="email" </div>
type="email"
className="p-2 border rounded bg-white/50 focus:outline-none focus:border-gray-400"
/>
</div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="password" className="text-sm text-gray-600"> <label htmlFor="password" className="text-sm text-gray-600">
Password password
</label> </label>
<input <TextInput
id="password" id="password"
type="password" type="password"
className="p-2 border rounded bg-white/50 focus:outline-none focus:border-gray-400" value={password}
required onInput={setPassword}
/> required
</div> />
</div>
<button <PrimaryButton className="mt-4" disabled={isSubmitting} type="submit">
type="submit" {isSubmitting ? 'wait...' : 'give me an account pls'}
disabled={isSubmitting} </PrimaryButton>
className="mt-4 p-2 bg-gray-800 text-white rounded hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed" </form>
> </div>
{isSubmitting ? 'Creating account...' : 'Create account'} </main>
</button>
</form> <dialog
</div> id="go-away-dialog"
</main> ref={dialogRef}
) className="p-6 rounded-lg shadow-lg m-auto outline-none"
} >
<div className="text-gray-600 flex flex-col gap-2">
function RejectionMessage() { <h1 className={`font-bold text-lg`}>STOP !!!</h1>
return ( <p>You need an invitation to sign up</p>
<main className="w-full max-w-3xl mx-auto p-4"> <p>
<div className="mt-12 text-gray-600 flex flex-col gap-2"> I'm surprised you even found your way here without one and honestly I'd prefer it if you
<p>An invitation is required to create an account.</p> would leave
<p> </p>
I'm surprised you even found your way here without one and honestly I'd prefer it if you <p>
would leave If you <span className="italic">do</span> want to create an account, you should know who
</p> to contact
<p> </p>
If you <span className="italic">do</span> want to create an account, you should know who <PrimaryLinkButton className={`mt-4`} href="https://en.wikipedia.org/wiki/Special:Random">
to contact I'm sorry I'll go somewhere else :(
</p> </PrimaryLinkButton>
</div> </div>
</main> </dialog>
</SingleColumnLayout>
) )
} }

View file

@ -0,0 +1,14 @@
export function openFileDialog(accept: string, multiple: boolean): Promise<FileList | null> {
return new Promise((resolve) => {
const inputEl = document.createElement('input')
inputEl.type = 'file'
inputEl.accept = accept
inputEl.multiple = multiple
inputEl.addEventListener('change', () => {
resolve(inputEl.files)
})
inputEl.click()
})
}