From 416b28fb36eb52a0dd521360ee7b4bf79282670c Mon Sep 17 00:00:00 2001 From: chev Date: Sat, 20 Jun 2026 16:09:26 -0500 Subject: [PATCH 1/6] feat: add profile share and discovery filter link helpers --- src/features/Discovery/discoveryUrlState.ts | 23 ++++++++++++++++ src/features/Discovery/shareProfile.ts | 30 +++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 src/features/Discovery/shareProfile.ts diff --git a/src/features/Discovery/discoveryUrlState.ts b/src/features/Discovery/discoveryUrlState.ts index 17a2701..c990496 100644 --- a/src/features/Discovery/discoveryUrlState.ts +++ b/src/features/Discovery/discoveryUrlState.ts @@ -86,6 +86,29 @@ export const parseDiscoveryState = ( }; }; +export type DiscoveryFilterTarget = + | 'tag' + | 'primaryLanguage' + | 'targetLanguage' + | 'country'; + +const FILTER_TARGET_PARAM: Record = { + tag: TAGS_PARAM, + primaryLanguage: 'primary', + targetLanguage: 'target', + country: 'country', +}; + +export const buildDiscoveryFilterHref = ( + locale: string, + target: DiscoveryFilterTarget, + value: string, +): string => { + const params = new URLSearchParams(); + params.set(FILTER_TARGET_PARAM[target], value); + return `/${locale}?${params.toString()}`; +}; + export const buildDiscoveryQuery = ( state: DiscoveryUrlState, base?: ReadableParams, diff --git a/src/features/Discovery/shareProfile.ts b/src/features/Discovery/shareProfile.ts new file mode 100644 index 0000000..fa69b9b --- /dev/null +++ b/src/features/Discovery/shareProfile.ts @@ -0,0 +1,30 @@ +export type ShareProfileResult = 'shared' | 'copied' | 'cancelled' | 'error'; + +export const buildPublicProfileUrl = (locale: string, profileId: string) => + `${window.location.origin}/${locale}/u/${profileId}`; + +export const shareProfileUrl = async ( + url: string, + shareData?: { title?: string; text?: string }, +): Promise => { + if ( + typeof navigator !== 'undefined' && + typeof navigator.share === 'function' + ) { + try { + await navigator.share({ ...shareData, url }); + return 'shared'; + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + return 'cancelled'; + } + } + } + + try { + await navigator.clipboard.writeText(url); + return 'copied'; + } catch { + return 'error'; + } +}; From 3fc70c9dc10d71432d5fd4abc935c509b2a6fcd8 Mon Sep 17 00:00:00 2001 From: chev Date: Sat, 20 Jun 2026 16:09:28 -0500 Subject: [PATCH 2/6] feat: add public profile route --- src/app/[lang]/u/[id]/PublicProfileClient.tsx | 151 ++++++++++++++++++ src/app/[lang]/u/[id]/page.tsx | 58 +++++++ 2 files changed, 209 insertions(+) create mode 100644 src/app/[lang]/u/[id]/PublicProfileClient.tsx create mode 100644 src/app/[lang]/u/[id]/page.tsx diff --git a/src/app/[lang]/u/[id]/PublicProfileClient.tsx b/src/app/[lang]/u/[id]/PublicProfileClient.tsx new file mode 100644 index 0000000..49c665f --- /dev/null +++ b/src/app/[lang]/u/[id]/PublicProfileClient.tsx @@ -0,0 +1,151 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useState } from 'react'; +import { MdArrowBack } from 'react-icons/md'; +import { ToastStack } from '@/components/Toast'; +import { buildDiscoveryFilterHref } from '@/features/Discovery/discoveryUrlState'; +import { + type DiscoveryProfile, + ProfileCard, +} from '@/features/Discovery/ProfileCard'; +import { saveProfileRequest } from '@/features/Discovery/saveProfileRequest'; +import { + buildPublicProfileUrl, + shareProfileUrl, +} from '@/features/Discovery/shareProfile'; +import { Navbar } from '@/features/Navbar'; +import { useRouteProgressRouter } from '@/features/Navigation/RouteProgress'; +import { useToastStack } from '@/hooks/useToast'; + +type PublicProfileClientProps = { + locale: string; + profile: DiscoveryProfile; + isLoggedIn: boolean; + isSaved: boolean; + currentProfileId?: string; + viewerTimezone?: string; + userAvatarUrl?: string; +}; + +const TOAST_DURATION = 4000; + +export const PublicProfileClient = ({ + locale, + profile, + isLoggedIn, + isSaved: initialSaved, + currentProfileId, + viewerTimezone, + userAvatarUrl, +}: PublicProfileClientProps) => { + const router = useRouteProgressRouter(); + const t = useTranslations('Discovery'); + const tPublic = useTranslations('PublicProfile'); + const { toasts, addToast, dismissToast } = useToastStack(); + const [isSaved, setIsSaved] = useState(initialSaved); + + const canSave = isLoggedIn && profile.id !== currentProfileId; + + const handleToggleSave = async () => { + if (!isLoggedIn) { + addToast({ + title: t('saveLoginTitle'), + description: t('saveLoginDescription'), + duration: TOAST_DURATION, + }); + return; + } + + const nextSaved = !isSaved; + setIsSaved(nextSaved); + + try { + await saveProfileRequest(profile.id, nextSaved); + } catch { + setIsSaved(!nextSaved); + addToast({ + title: t('saveError'), + description: t('saveErrorDescription'), + duration: TOAST_DURATION, + }); + } + }; + + const handleShare = async () => { + const result = await shareProfileUrl( + buildPublicProfileUrl(locale, profile.id), + ); + + if (result === 'copied') { + addToast({ + title: t('shareCopiedTitle'), + description: t('shareCopiedDescription'), + duration: TOAST_DURATION, + }); + } else if (result === 'error') { + addToast({ + title: t('shareErrorTitle'), + description: t('shareErrorDescription'), + duration: TOAST_DURATION, + }); + } + }; + + return ( +
+ router.push(`/${locale}`)} + onLoginClick={() => + window.location.assign(`/api/auth/discord?locale=${locale}`) + } + onProfileClick={() => router.push(`/${locale}/profile`)} + onSavedClick={() => router.push(`/${locale}/saved`)} + onSettingsClick={() => router.push(`/${locale}/settings`)} + onLogoutClick={() => + window.location.assign(`/api/auth/logout?locale=${locale}`) + } + /> + +
+ + + + router.push(buildDiscoveryFilterHref(locale, 'tag', tag)) + } + onLanguageClick={(language, _level, isPrimary) => + router.push( + buildDiscoveryFilterHref( + locale, + isPrimary ? 'primaryLanguage' : 'targetLanguage', + language, + ), + ) + } + onCountryClick={(country) => + router.push(buildDiscoveryFilterHref(locale, 'country', country)) + } + /> +
+ + +
+ ); +}; diff --git a/src/app/[lang]/u/[id]/page.tsx b/src/app/[lang]/u/[id]/page.tsx new file mode 100644 index 0000000..c2c2335 --- /dev/null +++ b/src/app/[lang]/u/[id]/page.tsx @@ -0,0 +1,58 @@ +import { notFound } from 'next/navigation'; +import { + getProfileByUserId, + getPublicProfileById, + listSavedProfileIds, + mapProfileToDiscoveryProfile, + toViewerAvailabilityContext, + upsertDiscordUser, +} from '@/db'; +import { getCurrentUser } from '@/lib/auth'; +import { PublicProfileClient } from './PublicProfileClient'; + +export default async function PublicProfileRoute({ + params, +}: { + params: Promise<{ lang: string; id: string }>; +}) { + const { lang, id } = await params; + const row = await getPublicProfileById(id); + + if (!row) { + notFound(); + } + + const profile = mapProfileToDiscoveryProfile(row); + const user = await getCurrentUser(); + + let isLoggedIn = false; + let savedProfileIds: string[] = []; + let currentProfileId: string | undefined; + let viewerTimezone: string | undefined; + + if (user) { + isLoggedIn = true; + const persistedUser = await upsertDiscordUser(user); + const viewerProfile = await getProfileByUserId(persistedUser.id); + currentProfileId = viewerProfile?.profile.id; + savedProfileIds = await listSavedProfileIds(persistedUser.id); + + if (viewerProfile) { + viewerTimezone = toViewerAvailabilityContext( + viewerProfile.profile, + ).timezone; + } + } + + return ( + + ); +} From aa6138b53072f4f26ed34f08843c31407d016cba Mon Sep 17 00:00:00 2001 From: chev Date: Sat, 20 Jun 2026 16:09:30 -0500 Subject: [PATCH 3/6] feat: wire discovery card actions to public route --- src/features/Discovery/DiscoveryPage.tsx | 62 ++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/features/Discovery/DiscoveryPage.tsx b/src/features/Discovery/DiscoveryPage.tsx index e6b3ef7..9be4cc8 100644 --- a/src/features/Discovery/DiscoveryPage.tsx +++ b/src/features/Discovery/DiscoveryPage.tsx @@ -41,6 +41,7 @@ import { ProfileGridSkeleton } from './ProfileGridSkeleton'; import { SearchBar } from './SearchBar'; import { SortMenu } from './SortMenu'; import { saveProfileRequest } from './saveProfileRequest'; +import { buildPublicProfileUrl, shareProfileUrl } from './shareProfile'; import { TagCloud } from './TagCloud'; const SEARCH_TRANSITION_MS = 320; @@ -278,6 +279,55 @@ export const DiscoveryPage = ({ setPage(1); }; + const handleAddTagFilter = (tag: string) => { + setSelectedTags((previous) => + previous.includes(tag) ? previous : [...previous, tag], + ); + setPage(1); + }; + + const handleAddMultiFilter = (filterId: string, value: string) => { + setFilterValues((previous) => { + const current = previous[filterId]; + const values = Array.isArray(current) + ? current + : current + ? [current] + : []; + + if (values.includes(value)) { + return previous; + } + + return { ...previous, [filterId]: [...values, value] }; + }); + setPage(1); + }; + + const handleViewProfile = (profileId: string) => { + router.push(`/${locale}/u/${profileId}`); + }; + + const handleShareProfile = async (profileId: string) => { + const result = await shareProfileUrl( + buildPublicProfileUrl(locale, profileId), + ); + + if (result === 'copied') { + addToast({ + title: t('shareCopiedTitle'), + description: t('shareCopiedDescription'), + duration: BUMP_TOAST_DURATION, + }); + } else if (result === 'error') { + addToast({ + title: t('shareErrorTitle'), + description: t('shareErrorDescription'), + duration: BUMP_TOAST_DURATION, + }); + } + }; + const handlePageChange = (nextPage: number) => { setPage(nextPage); resultsHeadRef.current?.scrollIntoView({ @@ -449,6 +499,18 @@ export const DiscoveryPage = ({ currentProfileId={currentProfileId} viewerTimezone={viewerTimezone} onSaveProfile={saveProfileRequest} + onViewProfile={handleViewProfile} + onShare={handleShareProfile} + onTagClick={(tag) => handleAddTagFilter(tag)} + onLanguageClick={(language, _level, isPrimary) => + handleAddMultiFilter( + isPrimary ? 'primaryLanguage' : 'targetLanguage', + language, + ) + } + onCountryClick={(country) => + handleAddMultiFilter('country', country) + } addToast={addToast} emptyState={ hasActiveFilters ? undefined : t('emptyFeedDescription') From 6f01cb301b99d69367fc758f9c9a252f19980f76 Mon Sep 17 00:00:00 2001 From: chev Date: Sat, 20 Jun 2026 16:09:31 -0500 Subject: [PATCH 4/6] feat: link profile editor to public profile route --- src/app/[lang]/profile/ProfileRouteClient.tsx | 5 +++++ src/app/[lang]/profile/page.tsx | 1 + 2 files changed, 6 insertions(+) diff --git a/src/app/[lang]/profile/ProfileRouteClient.tsx b/src/app/[lang]/profile/ProfileRouteClient.tsx index 2bfb2dc..7d22f0b 100644 --- a/src/app/[lang]/profile/ProfileRouteClient.tsx +++ b/src/app/[lang]/profile/ProfileRouteClient.tsx @@ -8,6 +8,7 @@ import type { ProfileFormValues } from '@/features/Profile/schema'; type ProfileRouteClientProps = { initialValues?: ProfileFormValues; locale: string; + profileId?: string; userAvatarUrl?: string; userDisplayName: string; }; @@ -15,6 +16,7 @@ type ProfileRouteClientProps = { export const ProfileRouteClient = ({ initialValues, locale, + profileId, userAvatarUrl, userDisplayName, }: ProfileRouteClientProps) => { @@ -41,6 +43,9 @@ export const ProfileRouteClient = ({ initialValues={initialValues} userAvatarUrl={userAvatarUrl} userDisplayName={userDisplayName} + onViewPublicProfile={ + profileId ? () => router.push(`/${locale}/u/${profileId}`) : undefined + } onSubmit={async (data) => { const response = await fetch('/api/profile', { method: 'POST', diff --git a/src/app/[lang]/profile/page.tsx b/src/app/[lang]/profile/page.tsx index 5032bae..85e00e3 100644 --- a/src/app/[lang]/profile/page.tsx +++ b/src/app/[lang]/profile/page.tsx @@ -47,6 +47,7 @@ export default async function ProfileRoute({ From e452a0e9e395323d5f2c1799e027f2f4df77895c Mon Sep 17 00:00:00 2001 From: chev Date: Sat, 20 Jun 2026 16:09:33 -0500 Subject: [PATCH 5/6] feat: add public profile and share copy --- src/locales/en.json | 7 +++++++ src/locales/ja.json | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/src/locales/en.json b/src/locales/en.json index 1191206..27be4cd 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -376,6 +376,10 @@ "saveLoginDescription": "Sign in with Discord to keep profiles you want to revisit.", "saveError": "Couldn't update saved profiles", "saveErrorDescription": "Something went wrong. Please try again.", + "shareCopiedTitle": "Profile link copied", + "shareCopiedDescription": "The public profile link is on your clipboard.", + "shareErrorTitle": "Couldn't share profile", + "shareErrorDescription": "Something went wrong. Please try again.", "bumpedJustNow": "just now", "bumpedMinutesAgo": "{count, plural, one {# minute ago} other {# minutes ago}}", "bumpedHoursAgo": "{count, plural, one {# hour ago} other {# hours ago}}", @@ -400,6 +404,9 @@ "emptyDescription": "Use the save action in any profile card menu to keep it here for later.", "emptyAction": "Browse profiles" }, + "PublicProfile": { + "backToDiscovery": "Back to discovery" + }, "Onboarding": { "firstRunSetup": "First-run setup", "createTitle": "Create your language profile", diff --git a/src/locales/ja.json b/src/locales/ja.json index d7a543b..3243d39 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -376,6 +376,10 @@ "saveLoginDescription": "Discordでログインすると、気になるプロフィールを保存できます。", "saveError": "保存を更新できませんでした", "saveErrorDescription": "問題が発生しました。もう一度お試しください。", + "shareCopiedTitle": "プロフィールのリンクをコピーしました", + "shareCopiedDescription": "公開プロフィールのリンクをクリップボードにコピーしました。", + "shareErrorTitle": "プロフィールを共有できませんでした", + "shareErrorDescription": "問題が発生しました。もう一度お試しください。", "bumpedJustNow": "たった今", "bumpedMinutesAgo": "{count, plural, other {#分前}}", "bumpedHoursAgo": "{count, plural, other {#時間前}}", @@ -400,6 +404,9 @@ "emptyDescription": "プロフィールカードメニューの保存アクションを使うと、ここに保存して後で見返せます。", "emptyAction": "プロフィールを見る" }, + "PublicProfile": { + "backToDiscovery": "ディスカバリーに戻る" + }, "Onboarding": { "firstRunSetup": "初回セットアップ", "createTitle": "言語プロフィールを作成", From 20dd66b5b53062acc8c3761b981b99de3e1eb8f1 Mon Sep 17 00:00:00 2001 From: chev Date: Sat, 20 Jun 2026 16:33:26 -0500 Subject: [PATCH 6/6] fix: prevent inbox update loop on unstable notifications prop --- src/features/Inbox/Inbox.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/features/Inbox/Inbox.tsx b/src/features/Inbox/Inbox.tsx index 8e7f7ed..704f46b 100644 --- a/src/features/Inbox/Inbox.tsx +++ b/src/features/Inbox/Inbox.tsx @@ -16,6 +16,9 @@ const initializeNotifications = (initial: Notifications, premium: boolean) => .filter((notification) => premium || notification.kind === 'copy') .map((n) => ({ ...n, read: false, isDeleting: false })); +const notificationsSignature = (initial: Notifications, premium: boolean) => + `${premium}:${initial.map((n) => n.id).join(',')}`; + export const Inbox = ({ notifications: initialNotifications, premium = false, @@ -31,16 +34,21 @@ export const Inbox = ({ ); const [currentPage, setCurrentPage] = useState(1); const [isMounted, setIsMounted] = useState(false); + const [signature, setSignature] = useState(() => + notificationsSignature(initialNotifications, premium), + ); const itemsPerPage = 5; useEffect(() => { setIsMounted(true); }, []); - useEffect(() => { + const nextSignature = notificationsSignature(initialNotifications, premium); + if (nextSignature !== signature) { + setSignature(nextSignature); setNotifications(initializeNotifications(initialNotifications, premium)); setCurrentPage(1); - }, [initialNotifications, premium]); + } const unreadCount = notifications.filter((n) => !n.read).length;