Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/app/[lang]/profile/ProfileRouteClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import type { ProfileFormValues } from '@/features/Profile/schema';
type ProfileRouteClientProps = {
initialValues?: ProfileFormValues;
locale: string;
profileId?: string;
userAvatarUrl?: string;
userDisplayName: string;
};

export const ProfileRouteClient = ({
initialValues,
locale,
profileId,
userAvatarUrl,
userDisplayName,
}: ProfileRouteClientProps) => {
Expand All @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/app/[lang]/profile/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export default async function ProfileRoute({
<ProfileRouteClient
locale={lang}
initialValues={profile ? toProfileFormValues(profile) : undefined}
profileId={profile?.profile.isPublic ? profile.profile.id : undefined}
userAvatarUrl={user.avatarUrl}
userDisplayName={user.name}
/>
Expand Down
151 changes: 151 additions & 0 deletions src/app/[lang]/u/[id]/PublicProfileClient.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="min-h-screen bg-background-main text-white">
<Navbar
iconUrl={userAvatarUrl}
isLoggedIn={isLoggedIn}
notifications={[]}
onHomeClick={() => 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}`)
}
/>

<main className="mx-auto w-full max-w-lg px-4 py-8 sm:px-8">
<button
type="button"
onClick={() => router.push(`/${locale}`)}
className="mb-5 inline-flex items-center gap-1.5 text-gray-400 text-sm transition-colors hover:text-white"
>
<MdArrowBack size={18} />
{tPublic('backToDiscovery')}
</button>

<ProfileCard
profile={profile}
isLoggedIn={isLoggedIn}
isSaved={isSaved}
viewerTimezone={viewerTimezone}
onToggleSave={canSave ? handleToggleSave : undefined}
onShare={handleShare}
onTagClick={(tag) =>
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))
}
/>
</main>

<ToastStack toasts={toasts} onDismiss={dismissToast} />
</div>
);
};
58 changes: 58 additions & 0 deletions src/app/[lang]/u/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<PublicProfileClient
locale={lang}
profile={profile}
isLoggedIn={isLoggedIn}
isSaved={savedProfileIds.includes(profile.id)}
currentProfileId={currentProfileId}
viewerTimezone={viewerTimezone}
userAvatarUrl={user?.avatarUrl}
/>
);
}
62 changes: 62 additions & 0 deletions src/features/Discovery/DiscoveryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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')
Expand Down
23 changes: 23 additions & 0 deletions src/features/Discovery/discoveryUrlState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,29 @@ export const parseDiscoveryState = (
};
};

export type DiscoveryFilterTarget =
| 'tag'
| 'primaryLanguage'
| 'targetLanguage'
| 'country';

const FILTER_TARGET_PARAM: Record<DiscoveryFilterTarget, string> = {
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,
Expand Down
30 changes: 30 additions & 0 deletions src/features/Discovery/shareProfile.ts
Original file line number Diff line number Diff line change
@@ -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<ShareProfileResult> => {
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';
}
};
Loading
Loading