From 98c86b78d890eae12eb9d3c8a1703a08b702ab9b Mon Sep 17 00:00:00 2001 From: iUnstable0 Date: Wed, 1 Apr 2026 19:40:47 -0400 Subject: [PATCH 1/8] fixed api & better error handling --- app/services/you_tube_service.rb | 83 +++++++++++++++++++++++++++----- 1 file changed, 71 insertions(+), 12 deletions(-) diff --git a/app/services/you_tube_service.rb b/app/services/you_tube_service.rb index be4bfc23..161418c5 100644 --- a/app/services/you_tube_service.rb +++ b/app/services/you_tube_service.rb @@ -14,7 +14,7 @@ def find_or_fetch(url) video_id = extract_video_id(url) return nil if video_id.blank? - YouTubeVideo.find_by(video_id: video_id) || fetch_and_create(video_id) + YouTubeVideo.find_by(video_id: video_id) || fetch_and_create(video_id, url: url) end def thumbnail_url(url, quality: "default") @@ -35,8 +35,8 @@ def extract_video_id(url) url.to_s.match(VIDEO_ID_REGEX)&.[](1) end - def fetch_and_create(video_id) - attrs = fetch_video_data(video_id) + def fetch_and_create(video_id, url: nil) + attrs = fetch_video_data(video_id, url: url) return nil if attrs.nil? YouTubeVideo.create!(attrs.merge(last_refreshed_at: Time.current)) @@ -44,17 +44,26 @@ def fetch_and_create(video_id) YouTubeVideo.find_by(video_id: video_id) end - def fetch_video_data(video_id) - response = connection.get("/youtube/v3/videos") do |req| + def fetch_video_data(video_id, url: nil) + fetch_video_data_from_api(video_id) || fetch_video_data_from_oembed(video_id, url: url) + rescue StandardError => e + ErrorReporter.capture_exception(e, level: :warning, contexts: { youtube: { action: "fetch_video_data", video_id: video_id } }) + nil + end + + def fetch_video_data_from_api(video_id) + return nil if youtube_api_key.blank? + + response = google_connection.get("/youtube/v3/videos") do |req| req.headers["Accept"] = "application/json" req.params["part"] = "snippet,contentDetails" req.params["id"] = video_id - req.params["key"] = ENV.fetch("GOOGLE_CLOUD_API_KEY", nil) + req.params["key"] = youtube_api_key end unless response.success? ErrorReporter.capture_message("YouTube video fetch failed", level: :warning, contexts: { - youtube: { status: response.status, video_id: video_id } + youtube: { status: response.status, video_id: video_id, source: "data_api" } }) return nil end @@ -88,9 +97,41 @@ def fetch_video_data(video_id) tags: snippet["tags"], category_id: snippet["categoryId"] } - rescue StandardError => e - ErrorReporter.capture_exception(e, level: :warning, contexts: { youtube: { action: "fetch_video_data", video_id: video_id } }) - nil + end + + def fetch_video_data_from_oembed(video_id, url: nil) + response = oembed_connection.get("/oembed") do |req| + req.headers["Accept"] = "application/json" + req.params["url"] = url.presence || youtube_url(video_id) + req.params["format"] = "json" + end + + unless response.success? + ErrorReporter.capture_message("YouTube oEmbed fetch failed", level: :warning, contexts: { + youtube: { status: response.status, video_id: video_id, source: "oembed" } + }) + return nil + end + + data = JSON.parse(response.body) + was_live = url.to_s.include?("/live/") + + { + video_id: video_id, + title: data["title"], + description: nil, + channel_id: nil, + channel_title: data["author_name"], + thumbnail_url: data["thumbnail_url"].presence || thumbnail_url_from_id(video_id, quality: "hqdefault"), + duration_seconds: nil, + published_at: nil, + definition: nil, + caption: nil, + was_live: was_live, + live_broadcast_content: was_live ? "live" : nil, + tags: nil, + category_id: nil + } end def parse_iso8601_duration(duration_string) @@ -100,7 +141,25 @@ def parse_iso8601_duration(duration_string) (match[1].to_i * 3600) + (match[2].to_i * 60) + match[3].to_i end - def connection - @connection ||= Faraday.new(url: "https://www.googleapis.com") + def youtube_api_key + ENV["YOUTUBE_API_KEY"].presence || ENV["GOOGLE_CLOUD_API_KEY"].presence + end + + def youtube_url(video_id) + "https://www.youtube.com/watch?v=#{video_id}" + end + + def google_connection + @google_connection ||= Faraday.new(url: "https://www.googleapis.com") do |f| + f.options.open_timeout = 5 + f.options.timeout = 10 + end + end + + def oembed_connection + @oembed_connection ||= Faraday.new(url: "https://www.youtube.com") do |f| + f.options.open_timeout = 5 + f.options.timeout = 10 + end end end From d06ab573695bfa3f12768875dd04159b666fb384 Mon Sep 17 00:00:00 2001 From: iUnstable0 Date: Wed, 1 Apr 2026 19:44:15 -0400 Subject: [PATCH 2/8] fixed visual issues and better modals and handling --- app/controllers/projects_controller.rb | 44 +++- app/frontend/components/shared/BookLayout.tsx | 51 ++++- app/frontend/components/shared/Frame.tsx | 95 +++++---- app/frontend/lib/modalMutation.ts | 80 +++++++ .../pages/collaboration_invites/show.tsx | 62 +++--- app/frontend/pages/journal_entries/new.tsx | 32 ++- app/frontend/pages/mails/index.tsx | 11 +- app/frontend/pages/mails/show.tsx | 27 ++- app/frontend/pages/path/index.tsx | 8 +- app/frontend/pages/projects/form.tsx | 199 ++++++++++-------- app/frontend/pages/projects/index.tsx | 169 ++++++++------- .../pages/projects/onboarding/index.tsx | 4 +- app/frontend/pages/projects/show.tsx | 97 +++++++-- app/frontend/types/inertia-modal.d.ts | 19 +- package-lock.json | 23 +- 15 files changed, 630 insertions(+), 291 deletions(-) create mode 100644 app/frontend/lib/modalMutation.ts diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 89cd8cd9..d4531f07 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -75,11 +75,19 @@ def create authorize @project if @project.save - # Redirect to path when created from the onboarding modal so it closes and tooltips update - destination = params[:return_to] == "path" ? path_path : @project - redirect_to destination, notice: "Project created." + if request.headers["X-InertiaUI-Modal"].present? && params[:return_to] != "path" + head :no_content + else + # Onboarding modal sends return_to=path so the redirect closes the modal and updates tooltips + destination = params[:return_to] == "path" ? path_path : projects_path + redirect_to destination, notice: "Project created." + end else - redirect_back fallback_location: new_project_path, inertia: { errors: @project.errors.messages } + if request.headers["X-InertiaUI-Modal"].present? && params[:return_to] != "path" + render json: { errors: @project.errors.messages }, status: :unprocessable_entity + else + redirect_back fallback_location: new_project_path, inertia: { errors: @project.errors.messages } + end end end @@ -104,16 +112,36 @@ def update authorize @project if @project.update(project_params) - redirect_to @project, notice: "Project updated." + if request.headers["X-InertiaUI-Modal"].present? + head :no_content + else + redirect_to @project, notice: "Project updated." + end else - redirect_back fallback_location: edit_project_path(@project), inertia: { errors: @project.errors.messages } + if request.headers["X-InertiaUI-Modal"].present? + render json: { errors: @project.errors.messages }, status: :unprocessable_entity + else + redirect_back fallback_location: edit_project_path(@project), inertia: { errors: @project.errors.messages } + end end end def destroy authorize @project - @project.discard - redirect_to projects_path, notice: "Project deleted." + + if @project.discard + if request.headers["X-InertiaUI-Modal"].present? + head :no_content + else + redirect_to projects_path, notice: "Project deleted." + end + else + if request.headers["X-InertiaUI-Modal"].present? + render json: { errors: @project.errors.messages }, status: :unprocessable_entity + else + redirect_back fallback_location: project_path(@project), inertia: { errors: @project.errors.messages } + end + end end private diff --git a/app/frontend/components/shared/BookLayout.tsx b/app/frontend/components/shared/BookLayout.tsx index 5eafcdad..01fbb971 100644 --- a/app/frontend/components/shared/BookLayout.tsx +++ b/app/frontend/components/shared/BookLayout.tsx @@ -5,14 +5,21 @@ const BookLayout = ({ children, className, showJoint = true, + showBorderOnMobile = false, }: { children: ReactNode className?: string showJoint?: boolean + showBorderOnMobile?: boolean }) => ( -
+
-
{children}
+
{children}
@@ -32,6 +39,46 @@ const BookLayout = ({
)}
+ {showBorderOnMobile && ( + <> + + + + +
+
+
+
+ + )}
) diff --git a/app/frontend/components/shared/Frame.tsx b/app/frontend/components/shared/Frame.tsx index 3185bb07..00e81d48 100644 --- a/app/frontend/components/shared/Frame.tsx +++ b/app/frontend/components/shared/Frame.tsx @@ -1,46 +1,59 @@ import type { ReactNode } from 'react' import { twMerge } from 'tailwind-merge' -const Frame = ({ children, className }: { children: ReactNode; className?: string }) => ( -
-
{children}
- - - - -
-
-
-
-
-) +const Frame = ({ + children, + className, + showBorderOnMobile = false, +}: { + children: ReactNode + className?: string + showBorderOnMobile?: boolean +}) => { + const bp = showBorderOnMobile ? 'block' : 'hidden md:block' + const pad = showBorderOnMobile ? 'pl-4.25 pt-3.75 pr-6.25 pb-5.5' : 'md:pl-4.25 md:pt-3.75 md:pr-6.25 md:pb-5.5' + + return ( +
+
{children}
+ + + + +
+
+
+
+
+ ) +} export default Frame diff --git a/app/frontend/lib/modalMutation.ts b/app/frontend/lib/modalMutation.ts new file mode 100644 index 00000000..5faead34 --- /dev/null +++ b/app/frontend/lib/modalMutation.ts @@ -0,0 +1,80 @@ +import type { RefObject } from 'react' +import Axios from 'axios' +import { notify } from '@/lib/notifications' + +type ModalValidationErrors = Record + +type ModalLike = { + close?: () => void +} | null + +type ModalMutationOptions = { + url: string + method: 'delete' | 'patch' | 'post' + data?: unknown + modal?: ModalLike + modalRef?: RefObject<{ close: () => void } | null> + successMessage: string + errorMessage: string + successEvent?: string + onModalEvent?: (event: string, ...args: any[]) => void + onValidationError?: (errors: ModalValidationErrors) => void + onFinish?: () => void +} + +function modalHeaders() { + const requestId = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : 'manual-project-modal-request' + + return { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '', + 'X-InertiaUI-Modal': requestId, + 'X-InertiaUI-Modal-Use-Router': 0, + } +} + +export async function performModalMutation({ + url, + method, + data, + modal, + modalRef, + successMessage, + errorMessage, + successEvent, + onModalEvent, + onValidationError, + onFinish, +}: ModalMutationOptions): Promise { + try { + await Axios({ + url, + method, + data, + headers: modalHeaders(), + }) + + notify('notice', successMessage) + if (successEvent) onModalEvent?.(successEvent) + modal?.close?.() + modalRef?.current?.close() + return true + } catch (error) { + if (Axios.isAxiosError(error)) { + const errors = error.response?.data?.errors + if (error.response?.status === 422 && errors && onValidationError) { + onValidationError(errors) + return false + } + } + + notify('alert', errorMessage) + return false + } finally { + onFinish?.() + } +} diff --git a/app/frontend/pages/collaboration_invites/show.tsx b/app/frontend/pages/collaboration_invites/show.tsx index fbd7570c..486cbeed 100644 --- a/app/frontend/pages/collaboration_invites/show.tsx +++ b/app/frontend/pages/collaboration_invites/show.tsx @@ -29,49 +29,51 @@ export default function CollaborationInviteShow({ invite, is_modal }: { invite: } const content = ( -
-

Collaboration Invite

+
+
+

Collaboration Invite

-
- -
-

{invite.inviter_display_name}

-

invited you on {invite.created_at}

+
+ +
+

{invite.inviter_display_name}

+

invited you on {invite.created_at}

+
-
-

- to collaborate on {invite.project_name} -

+

+ to collaborate on {invite.project_name} +

- {invite.status === 'pending' && ( -
- - -
- )} + {invite.status === 'pending' && ( +
+ + +
+ )} - {invite.status === 'accepted' && ( -
-

You accepted this invite.

- - Go to project - -
- )} + {invite.status === 'accepted' && ( +
+

You accepted this invite.

+ + Go to project + +
+ )} - {invite.status === 'declined' &&

You declined this invite.

} + {invite.status === 'declined' &&

You declined this invite.

} - {invite.status === 'revoked' &&

This invite was withdrawn.

} + {invite.status === 'revoked' &&

This invite was withdrawn.

} +
) if (is_modal) { return ( - {content} + {content} ) } diff --git a/app/frontend/pages/journal_entries/new.tsx b/app/frontend/pages/journal_entries/new.tsx index 29093027..61443192 100644 --- a/app/frontend/pages/journal_entries/new.tsx +++ b/app/frontend/pages/journal_entries/new.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react' import { Deferred as InertiaDeferred, router } from '@inertiajs/react' -import { Deferred as ModalDeferred, Modal } from '@inertiaui/modal-react' +// @ts-expect-error useModal lacks type declarations in this beta package +import { Deferred as ModalDeferred, Modal, useModal } from '@inertiaui/modal-react' import BookLayout from '@/components/shared/BookLayout' import Button from '@/components/shared/Button' import Input from '@/components/shared/Input' @@ -109,6 +110,7 @@ function NewJournal({ ? projects[0] : null + const modal = useModal() const [selectedProject, setSelectedProject] = useState(initialProject) const [rightTab, setRightTab] = useState<'lapse' | 'youtube' | 'lookout'>('lookout') const [selectedTimelapses, setSelectedTimelapses] = useState>(new Set()) @@ -176,6 +178,20 @@ function NewJournal({ const hasRecording = recordingCount > 0 const canSubmit = selectedProject && hasRecording && hasEnoughImages && hasEnoughChars + function handleBack() { + if (modal?.canGoBack) { + modal.goBack() + return + } + + if (modal) { + modal.close() + return + } + + modalRef.current?.close() + } + function toggleTimelapse(id: string) { setSelectedTimelapses((prev) => { const next = new Set(prev) @@ -397,8 +413,8 @@ function NewJournal({
{is_modal && ( @@ -592,12 +608,14 @@ function NewJournal({ return ( - {content} + + {content} + {fullscreenModal} ) @@ -613,8 +631,8 @@ function NewJournal({ function DisabledOverlay() { return ( -
-

Select a project at the top left first!

+
+

Select a project at the top left first!

) } diff --git a/app/frontend/pages/mails/index.tsx b/app/frontend/pages/mails/index.tsx index 8ca27f55..aa447468 100644 --- a/app/frontend/pages/mails/index.tsx +++ b/app/frontend/pages/mails/index.tsx @@ -22,8 +22,8 @@ function MailsIndex({ mails, is_modal }: PageProps) { const hasUnread = mails.some((m) => !m.is_read) const content = ( -
-
+
+

Your Mail

{hasUnread && (
+
+ {mails.length === 0 ? (

You don't have any mail yet! Check back later!

) : ( @@ -106,13 +108,16 @@ function MailsIndex({ mails, is_modal }: PageProps) { })}
)} +
) if (is_modal) { return ( - {content} + + {content} + ) } diff --git a/app/frontend/pages/mails/show.tsx b/app/frontend/pages/mails/show.tsx index e6adb95f..a95bcc1d 100644 --- a/app/frontend/pages/mails/show.tsx +++ b/app/frontend/pages/mails/show.tsx @@ -18,17 +18,22 @@ function MailShow({ mail, is_modal }: PageProps) { } const content = ( -
-

{mail.summary}

-

{mail.created_at}

+
+
+

{mail.summary}

+

{mail.created_at}

+
- {mail.content && ( -
- {mail.content} -
- )} +
+ {mail.content && ( +
+ {mail.content} +
+ )} + {!mail.content &&

No content

} +
-
+
{mail.action_url && ( - {content} + + {content} + ) } diff --git a/app/frontend/pages/path/index.tsx b/app/frontend/pages/path/index.tsx index f1fb6052..0dda0d63 100644 --- a/app/frontend/pages/path/index.tsx +++ b/app/frontend/pages/path/index.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useMemo } from 'react' import type { ReactNode } from 'react' -import { Link, usePage } from '@inertiajs/react' +import { Link, router, usePage } from '@inertiajs/react' import type { SharedProps } from '@/types' import { useModalStack, ModalLink } from '@inertiaui/modal-react' import Shop from '@/components/Shop' @@ -110,6 +110,10 @@ export default function PathIndex() { } }, []) + function reloadPathProgress() { + router.reload({ only: ['has_projects', 'journal_entry_count', 'critter_variants'] }) + } + return ( <> @@ -139,7 +143,7 @@ export default function PathIndex() { {has_projects && !authUser?.is_trial ? ( - + Projects ) : ( diff --git a/app/frontend/pages/projects/form.tsx b/app/frontend/pages/projects/form.tsx index 4d03507d..9dec0e94 100644 --- a/app/frontend/pages/projects/form.tsx +++ b/app/frontend/pages/projects/form.tsx @@ -1,9 +1,12 @@ -import { useForm, usePage } from '@inertiajs/react' -import { Modal, ModalLink } from '@inertiaui/modal-react' +import { useState, useRef } from 'react' +import { router, usePage } from '@inertiajs/react' +import { Modal, useModal } from '@inertiaui/modal-react' +import { ArrowLeftIcon } from '@heroicons/react/20/solid' import Frame from '@/components/shared/Frame' import Button from '@/components/shared/Button' import Input from '@/components/shared/Input' import TextArea from '@/components/shared/TextArea' +import { performModalMutation } from '@/lib/modalMutation' import type { ProjectForm, SharedProps } from '@/types' export default function ProjectsForm({ @@ -12,113 +15,141 @@ export default function ProjectsForm({ submit_url, method, is_modal, + onModalEvent, }: { project: ProjectForm title: string submit_url: string method: string is_modal: boolean + onModalEvent?: (event: string, ...args: any[]) => void }) { - const { errors } = usePage().props - - const form = useForm({ - name: project.name, - description: project.description, - repo_link: project.repo_link, - }) + const { errors: pageErrors } = usePage().props + const modalRef = useRef<{ close: () => void }>(null) + const modal = useModal() + const [name, setName] = useState(project.name) + const [description, setDescription] = useState(project.description) + const [repoLink, setRepoLink] = useState(project.repo_link) + const [processing, setProcessing] = useState(false) + const [formErrors, setFormErrors] = useState>({}) + const errors = Object.keys(formErrors).length > 0 ? formErrors : pageErrors function submit(e: React.FormEvent) { e.preventDefault() - if (method === 'patch') { - form.patch(submit_url) - } else { - form.post(submit_url) + if (processing) return + setProcessing(true) + setFormErrors({}) + const data = { project: { name, description, repo_link: repoLink } } + + if (!is_modal) { + const options = { + onFinish: () => setProcessing(false), + } + + if (method === 'patch') { + router.patch(submit_url, data, options) + } else { + router.post(submit_url, data, options) + } + + return } + + void performModalMutation({ + url: submit_url, + method: method === 'patch' ? 'patch' : 'post', + data, + modal, + modalRef, + successMessage: method === 'patch' ? 'Project updated.' : 'Project created.', + errorMessage: method === 'patch' ? 'Failed to update project.' : 'Failed to create project.', + successEvent: method === 'patch' ? 'projectSaved' : 'projectCreated', + onModalEvent, + onValidationError: setFormErrors, + onFinish: () => setProcessing(false), + }) } const content = ( -
-

{title}

- -
- {Object.keys(errors).length > 0 && ( -
-
    - {Object.entries(errors).map(([field, messages]) => - messages.map((msg) => ( -
  • - {field} {msg} -
  • - )), - )} -
-
+
+
+ {is_modal && ( + )} +

{title}

+
-
- - form.setData('name', e.target.value)} - required - /> -
+
+ + {Object.keys(errors).length > 0 && ( +
+
    + {Object.entries(errors).map(([field, messages]) => + messages.map((msg) => ( +
  • + {field} {msg} +
  • + )), + )} +
+
+ )} -
- -