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
146 changes: 146 additions & 0 deletions api/saved-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import {
authenticatedApiRequest,
type ApiRequestOptions,
type ClerkTokenGetter,
} from '@/api/api-client';

export type SavedLinkVerdict = 'safe' | 'caution' | 'danger';

export type SavedLinkResponse = {
id: number;
analysisId: string;
categoryId: number | null;
originalUrl: string;
finalUrl: string | null;
title: string;
description?: string | null;
verdict: SavedLinkVerdict;
isBookmarked: boolean;
createdAt: string;
};

export type SavedLinkListResponse = {
items: SavedLinkResponse[];
hasNext: boolean;
nextCursor: string | null;
};

export type SavedLinkCreateRequest = {
analysisId: string;
categoryId: number | null;
title: string;
description?: string | null;
};

export type SavedLinkListQuery = {
categoryId?: number | null;
bookmarked?: boolean;
cursor?: string | null;
size?: number;
};

export type BookmarkToggleResponse = {
id: number;
isBookmarked: boolean;
};

export type CategoryUpdateResponse = {
id: number;
categoryId: number | null;
};

export type SavedLinkTitleUpdateResponse = {
id: number;
title: string;
};

type SavedLinkRequestOptions = Pick<ApiRequestOptions, 'signal'>;

export function createSavedLink(
getToken: ClerkTokenGetter,
body: SavedLinkCreateRequest,
options: SavedLinkRequestOptions = {},
) {
return authenticatedApiRequest<SavedLinkResponse>(getToken, '/api/v1/saved-links', {
...options,
method: 'POST',
body,
});
}

export function fetchSavedLinks(
getToken: ClerkTokenGetter,
query: SavedLinkListQuery = {},
options: SavedLinkRequestOptions = {},
) {
return authenticatedApiRequest<SavedLinkListResponse>(
getToken,
`/api/v1/saved-links${buildSavedLinkQuery(query)}`,
options,
);
}

export function deleteSavedLink(getToken: ClerkTokenGetter, id: number) {
return authenticatedApiRequest<void>(
getToken,
`/api/v1/saved-links/${encodeURIComponent(String(id))}`,
{ method: 'DELETE' },
);
}

export function toggleSavedLinkBookmark(getToken: ClerkTokenGetter, id: number) {
return authenticatedApiRequest<BookmarkToggleResponse>(
getToken,
`/api/v1/saved-links/${encodeURIComponent(String(id))}/bookmark`,
{ method: 'PATCH' },
);
}

export function updateSavedLinkCategory(
getToken: ClerkTokenGetter,
id: number,
categoryId: number | null,
) {
return authenticatedApiRequest<CategoryUpdateResponse>(
getToken,
`/api/v1/saved-links/${encodeURIComponent(String(id))}/category`,
{
method: 'PATCH',
body: { categoryId },
},
);
}

export function updateSavedLinkTitle(getToken: ClerkTokenGetter, id: number, title: string) {
return authenticatedApiRequest<SavedLinkTitleUpdateResponse>(
getToken,
`/api/v1/saved-links/${encodeURIComponent(String(id))}/title`,
{
method: 'PATCH',
body: { title },
},
);
}

function buildSavedLinkQuery(query: SavedLinkListQuery) {
const params = new URLSearchParams();

if (query.categoryId != null) {
params.set('categoryId', String(query.categoryId));
}

if (query.bookmarked != null) {
params.set('bookmarked', String(query.bookmarked));
}

if (query.cursor != null) {
params.set('cursor', query.cursor);
}

if (query.size != null) {
params.set('size', String(query.size));
}

const queryString = params.toString();
return queryString ? `?${queryString}` : '';
}
121 changes: 112 additions & 9 deletions app/(tabs)/(home)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,102 @@
import { useState } from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
Alert,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { router } from 'expo-router';
import { router, useLocalSearchParams } from 'expo-router';
import { AppIcon } from '@/components/ui/app-icon';
import { CardLink } from '@/components/ui/card-link';
import { FolderContextMenu } from '@/components/ui/folder-context-menu';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { SectionHeader } from '@/components/ui/section-header';
import { TitleEditModal } from '@/components/ui/title-edit-modal';
import { Toast } from '@/components/ui/toast';
import { Colors, Typography } from '@/constants/theme';
import { useSavedLinks } from '@/context/saved-links-context';
import { getSavedLinkErrorMessage, useSavedLinks, type SavedLink } from '@/context/saved-links-context';
import type { AnchorPosition } from '@/components/ui/folder-card';

export default function HomeScreen() {
const { links, toggleBookmark, deleteLink } = useSavedLinks();
const { savedLinkToast: savedLinkToastParam } = useLocalSearchParams<{
savedLinkToast?: string | string[];
}>();
const { links, toggleBookmark, deleteLink, updateTitle } = useSavedLinks();
const [menuState, setMenuState] = useState<{ visible: boolean; anchor?: AnchorPosition; linkId?: number }>({ visible: false });
const [editingLink, setEditingLink] = useState<SavedLink | null>(null);
const [saveToastVisible, setSaveToastVisible] = useState(false);
const [deleteToastVisible, setDeleteToastVisible] = useState(false);
const [titleToastVisible, setTitleToastVisible] = useState(false);
const lastToastParamRef = useRef<string | undefined>(undefined);
const savedLinkToast = typeof savedLinkToastParam === 'string' ? savedLinkToastParam : undefined;

// 최근 저장한 링크 — createdAt 내림차순 상위 3개
const recentLinks = links.slice(0, 3);

useEffect(() => {
if (!savedLinkToast || lastToastParamRef.current === savedLinkToast) {
return;
}

lastToastParamRef.current = savedLinkToast;
setSaveToastVisible(true);
}, [savedLinkToast]);

const handleMore = (id: number, anchor: AnchorPosition) => {
setMenuState({ visible: true, anchor, linkId: id });
};

const selectedLink = links.find((l) => l.id === menuState.linkId);

const handleBookmark = useCallback(
async (id: number) => {
try {
await toggleBookmark(id);
} catch (error) {
Alert.alert(
'북마크 변경 실패',
getSavedLinkErrorMessage(error, '북마크 상태를 변경하지 못했습니다. 잠시 후 다시 시도해주세요.'),
);
}
},
[toggleBookmark],
);

const handleDelete = useCallback(
(id: number) => {
Alert.alert('링크 삭제', '저장한 링크를 삭제할까요?', [
{ text: '취소', style: 'cancel' },
{
text: '삭제',
style: 'destructive',
onPress: async () => {
try {
await deleteLink(id);
setDeleteToastVisible(true);
} catch (error) {
Alert.alert(
'삭제 실패',
getSavedLinkErrorMessage(error, '링크를 삭제하지 못했습니다. 잠시 후 다시 시도해주세요.'),
);
}
},
},
]);
},
[deleteLink],
);

const openTitleModal = useCallback((link: SavedLink) => {
setEditingLink(link);
}, []);

const handleTitleConfirm = useCallback(async (id: number, title: string) => {
await updateTitle(id, title);
setTitleToastVisible(true);
}, [updateTitle]);

return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<ScrollView
Expand Down Expand Up @@ -75,7 +148,9 @@ export default function HomeScreen() {
originalUrl={link.originalUrl}
finalUrl={link.finalUrl}
bookmarked={link.isBookmarked}
onBookmark={() => toggleBookmark(link.id)}
onBookmark={() => {
void handleBookmark(link.id);
}}
onMore={(anchor) => handleMore(link.id, anchor)}
/>
))}
Expand All @@ -88,17 +163,45 @@ export default function HomeScreen() {
anchor={menuState.anchor}
items={[
{
label: selectedLink?.isBookmarked ? '북마크 제거' : '북마크 추가',
onPress: () => menuState.linkId != null && toggleBookmark(menuState.linkId),
label: '제목 수정',
onPress: () => selectedLink && openTitleModal(selectedLink),
},
{
label: '링크 삭제',
onPress: () => menuState.linkId != null && deleteLink(menuState.linkId),
onPress: () => menuState.linkId != null && handleDelete(menuState.linkId),
destructive: true,
},
]}
onDismiss={() => setMenuState({ visible: false })}
/>

<TitleEditModal
editingLink={editingLink}
onConfirm={handleTitleConfirm}
onClose={() => setEditingLink(null)}
/>

<Toast
visible={saveToastVisible}
message="링크가 저장되었습니다."
placement="top"
topOffset={96}
onHide={() => setSaveToastVisible(false)}
/>
<Toast
visible={deleteToastVisible}
message="링크가 삭제되었습니다."
placement="top"
topOffset={96}
onHide={() => setDeleteToastVisible(false)}
/>
<Toast
visible={titleToastVisible}
message="제목이 수정되었습니다."
placement="top"
topOffset={96}
onHide={() => setTitleToastVisible(false)}
/>
</SafeAreaView>
);
}
Expand Down
Loading
Loading