From 6ac9475bd3ea7d9b550959c81bcaf753c72ea55b Mon Sep 17 00:00:00 2001 From: mrboxs Date: Tue, 10 Nov 2026 17:02:55 +0700 Subject: [PATCH 1/4] fix(web): reset GitHub previews when repository changes - Scope metadata and README request state to the repository URL - Show refresh guidance after a request when the repository changes --- .../components/github-metadata-prefill.tsx | 63 ++++++++++++------- .../components/github-readme-import.tsx | 54 +++++++++------- 2 files changed, 73 insertions(+), 44 deletions(-) diff --git a/apps/web/src/features/admin-projects/components/github-metadata-prefill.tsx b/apps/web/src/features/admin-projects/components/github-metadata-prefill.tsx index f4348c8..5ac2ed9 100644 --- a/apps/web/src/features/admin-projects/components/github-metadata-prefill.tsx +++ b/apps/web/src/features/admin-projects/components/github-metadata-prefill.tsx @@ -41,17 +41,37 @@ const REPOSITORY_CHANGED = 'Repository changed. Fetch from GitHub again to preview its metadata.' const isBlank = (value: string | null | undefined) => !value?.trim() -export function GithubMetadataPrefill({ - repositoryUrl, - disabled, - getCurrentValues, - onApply, -}: { +type GithubMetadataPrefillProps = { repositoryUrl: string disabled: boolean getCurrentValues: () => CurrentValues onApply: (values: MetadataFields) => void -}) { +} +type GithubMetadataPrefillSessionProps = GithubMetadataPrefillProps & { + hasRequested: boolean + onRequest: () => void +} + +export function GithubMetadataPrefill(props: GithubMetadataPrefillProps) { + const [hasRequested, setHasRequested] = useState(false) + return ( + setHasRequested(true)} + /> + ) +} + +function GithubMetadataPrefillSession({ + repositoryUrl, + disabled, + getCurrentValues, + onApply, + hasRequested, + onRequest, +}: GithubMetadataPrefillSessionProps) { const { id, pending, @@ -69,6 +89,8 @@ export function GithubMetadataPrefill({ disabled, getCurrentValues, onApply, + hasRequested, + onRequest, }) return ( @@ -248,28 +270,22 @@ function useGithubMetadataPrefill({ disabled, getCurrentValues, onApply, -}: Parameters[0]) { + hasRequested, + onRequest, +}: GithubMetadataPrefillSessionProps) { const { mutateAsync } = useAdminProjectGithubMetadata() const id = useId() const requestId = useRef(0) - const activeRepository = useRef(repositoryUrl) const [pending, setPending] = useState(false) const [error, setError] = useState(null) - const [feedback, setFeedback] = useState(null) + const [feedbackValue, setFeedback] = useState() + const feedback = + feedbackValue === undefined + ? hasRequested + ? REPOSITORY_CHANGED + : null + : feedbackValue const [preview, setPreview] = useState(null) - - useEffect(() => { - if (activeRepository.current === repositoryUrl) return - activeRepository.current = repositoryUrl - if (requestId.current === 0) return - requestId.current += 1 - // Synchronize an external form field with the request/dialog lifecycle. - // oxlint-disable-next-line react-hooks-js/set-state-in-effect - setPending(false) - setError(null) - setPreview(null) - setFeedback(REPOSITORY_CHANGED) - }, [repositoryUrl]) useEffect( () => () => { if (requestId.current > 0) requestId.current += 1 @@ -287,6 +303,7 @@ function useGithubMetadataPrefill({ } const requestedRepositoryUrl = repositoryUrl const currentRequestId = ++requestId.current + onRequest() setPending(true) setError(null) setFeedback(null) diff --git a/apps/web/src/features/admin-projects/components/github-readme-import.tsx b/apps/web/src/features/admin-projects/components/github-readme-import.tsx index c45a105..5cbb20d 100644 --- a/apps/web/src/features/admin-projects/components/github-readme-import.tsx +++ b/apps/web/src/features/admin-projects/components/github-readme-import.tsx @@ -34,39 +34,50 @@ type Preview = { const REPOSITORY_CHANGED = 'Repository changed. Import README again to fetch its content.' -export function GithubReadmeImport({ - repositoryUrl, - disabled, - getRepositoryUrl, - getEditor, -}: { +type GithubReadmeImportProps = { repositoryUrl: string disabled: boolean getRepositoryUrl: () => string getEditor: () => BlockNoteEditorHandle | null +} + +export function GithubReadmeImport(props: GithubReadmeImportProps) { + const [hasRequested, setHasRequested] = useState(false) + return ( + setHasRequested(true)} + /> + ) +} + +function GithubReadmeImportSession({ + repositoryUrl, + disabled, + getRepositoryUrl, + getEditor, + hasRequested, + onRequest, +}: GithubReadmeImportProps & { + hasRequested: boolean + onRequest: () => void }) { const { mutateAsync } = useAdminProjectGithubReadme() const id = useId() const requestId = useRef(0) - const activeRepository = useRef(repositoryUrl) const [pending, setPending] = useState(false) const [applying, setApplying] = useState(false) const [error, setError] = useState(null) - const [feedback, setFeedback] = useState(null) + const [feedbackValue, setFeedback] = useState() + const feedback = + feedbackValue === undefined + ? hasRequested + ? REPOSITORY_CHANGED + : null + : feedbackValue const [preview, setPreview] = useState(null) - - useEffect(() => { - if (activeRepository.current === repositoryUrl) return - activeRepository.current = repositoryUrl - if (requestId.current === 0) return - requestId.current += 1 - // Synchronize this request lifecycle with the external repository form field. - // oxlint-disable-next-line react-hooks-js/set-state-in-effect - setPending(false) - setPreview(null) - setError(null) - setFeedback(REPOSITORY_CHANGED) - }, [repositoryUrl]) useEffect( () => () => { requestId.current += 1 @@ -92,6 +103,7 @@ export function GithubReadmeImport({ } const requestedRepositoryUrl = repositoryUrl const currentRequest = ++requestId.current + onRequest() setPending(true) setError(null) setFeedback(null) From ece39aec64c9d494ac91cb97b7c7459dc6e04e0a Mon Sep 17 00:00:00 2001 From: mrboxs Date: Tue, 10 Nov 2026 17:16:26 +0700 Subject: [PATCH 2/4] refactor(web): extract admin editor state and fields --- apps/web/src/features/admin-blogs/editor.tsx | 350 +++-- .../src/features/admin-collections/editor.tsx | 574 ++++---- .../components/featured-editor.tsx | 731 ++++++----- .../components/create-project-form.tsx | 1023 ++++++++------- .../components/edit-project-form.tsx | 1162 +++++++++-------- apps/web/src/routes/_main/admin.activity.tsx | 567 ++++---- 6 files changed, 2430 insertions(+), 1977 deletions(-) diff --git a/apps/web/src/features/admin-blogs/editor.tsx b/apps/web/src/features/admin-blogs/editor.tsx index 00f242d..d65fd50 100644 --- a/apps/web/src/features/admin-blogs/editor.tsx +++ b/apps/web/src/features/admin-blogs/editor.tsx @@ -68,7 +68,7 @@ const empty: BlogBody = { status: 'draft', } -export function BlogEditor({ initial }: { initial?: Initial }) { +function useBlogEditor(initial: Initial | undefined) { const queryClient = useQueryClient() const navigate = useNavigate() const router = useRouter() @@ -120,6 +120,27 @@ export function BlogEditor({ initial }: { initial?: Initial }) { {fieldErrors[name]} ) : null } + function onTitleChange(title: string) { + setValue((previous) => { + return { + ...previous, + title, + ...(!customizedSlug.current + ? { + slug: slugify(title) + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+/, '') + .slice(0, 160) + .replace(/-+$/, ''), + } + : {}), + } + }) + } + function onSlugChange(slug: string) { + customizedSlug.current = true + setField('slug', slug) + } async function submit(event: React.SubmitEvent) { event.preventDefault() if (busy) return @@ -180,6 +201,52 @@ export function BlogEditor({ initial }: { initial?: Initial }) { }) await navigate({ to: '/admin/blogs' }) } + return { + initial, + queryClient, + router, + editor, + onTitleChange, + onSlugChange, + value, + fieldErrors, + error, + conflict, + reload, + setReload, + setReloading, + coverBusy, + setCoverBusy, + coverRevision, + saving, + busy, + blocker, + setField, + fieldProps, + fieldError, + submit, + } +} + +export function BlogEditor({ initial }: { initial?: Initial }) { + const state = useBlogEditor(initial) + const { + queryClient, + router, + editor, + value, + error, + conflict, + reload, + setReload, + setReloading, + coverBusy, + saving, + busy, + blocker, + submit, + } = state + return (
)} - - - Title - { - const title = event.target.value - setValue((previous) => { - return { - ...previous, - title, - ...(!customizedSlug.current - ? { - slug: slugify(title) - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+/, '') - .slice(0, 160) - .replace(/-+$/, ''), - } - : {}), - } - }) - }} - /> - {fieldError('title')} - - - Slug - { - customizedSlug.current = true - setField('slug', event.target.value) - }} - /> - - Public URL: /blogs/{value.slug || 'your-post-slug'}. Generated - from the title until you customize it. - - {fieldError('slug')} - - - Description -