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,
onInput,
onKeyDown,
className,
placeholder,
className: extraClasses = '',
placeholder = '',
}: TextInputProps) {
const divRef = useRef<HTMLDivElement>(null)
const [hasFocus, setHasFocus] = useState(false)
@ -39,7 +39,7 @@ export default function FancyTextEditor({
}
if (!value && !hasFocus) {
div.innerText = placeholder ?? ''
div.innerText = placeholder
} else if (div.innerText !== value) {
div.innerText = value
}
@ -58,7 +58,7 @@ export default function FancyTextEditor({
const blurListener = () => {
setHasFocus(false)
if (!value) {
div.innerText = placeholder ?? ''
div.innerText = placeholder
}
}
@ -86,7 +86,7 @@ export default function FancyTextEditor({
return (
<div
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
onKeyDown={handleKeyDown}
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 PrimaryButton from './PrimaryButton.tsx'
import SecondaryButton from './SecondaryButton.tsx'
import { openFileDialog } from '../utils/openFileDialog.ts'
interface NewPostWidgetProps {
onSubmit: (content: string, media: File[]) => void
@ -20,20 +23,19 @@ export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPos
setContent(value)
}
const handleMediaChange = (e: ChangeEvent<HTMLInputElement>) => {
const inputEl = e.target as HTMLInputElement
if (inputEl.files == null || inputEl.files.length === 0) {
async function onAddMediaClicked() {
const files = await openFileDialog('image/*', true)
if (files == null || files.length === 0) {
return
}
const newFiles = Array.from(inputEl.files).map((file) => ({
const newFiles = Array.from(files).map((file) => ({
id: crypto.randomUUID(),
file,
objectUrl: URL.createObjectURL(file),
}))
setAttachments((attachments) => [...attachments, ...newFiles])
inputEl.value = ''
}
const handleSubmit = () => {
@ -90,24 +92,13 @@ export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPos
)}
<div className="flex justify-between items-center">
<label className="cursor-pointer text-gray-500 hover:text-gray-700">
<input
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"
<SecondaryButton onClick={onAddMediaClicked}>+ add media</SecondaryButton>
<PrimaryButton
onClick={handleSubmit}
disabled={isSubmitting || (content.trim() === '' && attachments.length === 0)}
>
Post
</button>
post
</PrimaryButton>
</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])
return [pages, setPages, loadNextPage] as const
return { pages, setPages, loadNextPage } as const
}

View file

@ -6,4 +6,66 @@
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-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 { PostsService } from '../model/posts/postsService.ts'
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 {
postsService: PostsService
@ -17,5 +20,11 @@ export default function AuthorPage({ postsService }: AuthorPageParams) {
[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 { MediaService } from '../model/mediaService.ts'
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 { 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 {
postsService: PostsService
@ -26,7 +27,7 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
[postsService],
)
const [pages, setPages, loadNextPage] = useFeedViewModel(fetchPosts)
const { pages, setPages, loadNextPage } = useFeedViewModel(fetchPosts)
const onCreatePost = useCallback(
async (content: string, files: File[]) => {
@ -49,11 +50,11 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
)
return (
<AppLayout>
<SingleColumnLayout navbar={<NavBar />}>
<main className={`w-full max-w-3xl mx-auto`}>
<NewPostWidget onSubmit={onCreatePost} isSubmitting={isSubmitting} />
<FeedView pages={pages} onLoadMore={loadNextPage} />
</main>
</AppLayout>
</SingleColumnLayout>
)
}

View file

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