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

export type CategoryResponse = {
id: number;
name: string;
displayOrder: number;
linkCount: number;
createdAt: string;
};

export type CategoryListResponse = {
items: CategoryResponse[];
};

export type CategoryCreateRequest = {
name: string;
linkIds?: number[];
};

export type CategoryRenameResponse = {
id: number;
name: string;
};

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

export function createCategory(
getToken: ClerkTokenGetter,
body: CategoryCreateRequest,
options: CategoryRequestOptions = {},
) {
return authenticatedApiRequest<CategoryResponse>(getToken, '/api/v1/categories', {
...options,
method: 'POST',
body,
});
}

export function fetchCategories(
getToken: ClerkTokenGetter,
options: CategoryRequestOptions = {},
) {
return authenticatedApiRequest<CategoryListResponse>(
getToken,
'/api/v1/categories',
options,
);
}

export function renameCategory(getToken: ClerkTokenGetter, id: number, name: string) {
return authenticatedApiRequest<CategoryRenameResponse>(
getToken,
`/api/v1/categories/${encodeURIComponent(String(id))}`,
{
method: 'PATCH',
body: { name },
},
);
}

export function deleteCategory(getToken: ClerkTokenGetter, id: number) {
return authenticatedApiRequest<void>(
getToken,
`/api/v1/categories/${encodeURIComponent(String(id))}`,
{ method: 'DELETE' },
);
}
72 changes: 58 additions & 14 deletions app/(tabs)/(folder)/[id].tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { Alert, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router';

import { AddFolderButton } from '@/components/ui/add-folder-button';
import { AppIcon } from '@/components/ui/app-icon';
import { CardLink } from '@/components/ui/card-link';
import { FolderContextMenu } from '@/components/ui/folder-context-menu';
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 { useFolders } from '@/context/folders-context';
import type { AnchorPosition } from '@/components/ui/folder-card';
import { useEffect, useState } from 'react';
Expand All @@ -24,14 +25,16 @@ export default function FolderDetailScreen() {
const router = useRouter();
const folderId = Number(id);

const { links, toggleBookmark, assignCategory } = useSavedLinks();
const { folders } = useFolders();
const { links, toggleBookmark, assignCategory, updateTitle } = useSavedLinks();
const { folders, refreshFolders } = useFolders();
const folderLinks = links.filter((l) => l.categoryId === folderId);
const folderName = folders.find((f) => f.id === folderId)?.name ?? '폴더';

const [menuState, setMenuState] = useState<MoreMenuState>({ visible: false });
const [editingLink, setEditingLink] = useState<SavedLink | null>(null);
const [toastVisible, setToastVisible] = useState(false);
const [addToastVisible, setAddToastVisible] = useState(false);
const [titleToastVisible, setTitleToastVisible] = useState(false);

useEffect(() => {
if (urlAdded === '1') setAddToastVisible(true);
Expand All @@ -41,10 +44,34 @@ export default function FolderDetailScreen() {
setMenuState({ visible: true, anchor, linkId });
};

const handleDelete = (linkId: number) => {
// API: PATCH /api/v1/saved-links/{id} { categoryId: null }
assignCategory([linkId], null);
setToastVisible(true);
const handleDelete = async (linkId: number) => {
try {
await assignCategory([linkId], null);
await refreshFolders();
setToastVisible(true);
} catch (error) {
Alert.alert(
'폴더에서 삭제 실패',
getSavedLinkErrorMessage(error, '링크를 폴더에서 제외하지 못했습니다. 잠시 후 다시 시도해주세요.'),
);
}
};

const confirmDeleteFromFolder = (linkId: number) => {
Alert.alert(
'폴더에서 삭제할까요?',
'링크는 삭제되지 않아요.\n현재 폴더에서만 제외돼요.',
[
{ text: '취소', style: 'cancel' },
{
text: '폴더에서 삭제',
style: 'destructive',
onPress: () => {
void handleDelete(linkId);
},
},
],
);
};

const handleAddUrl = () => {
Expand All @@ -56,6 +83,11 @@ export default function FolderDetailScreen() {

const currentLink = folderLinks.find((l) => l.id === menuState.linkId);

const handleTitleConfirm = async (linkId: number, title: string) => {
await updateTitle(linkId, title);
setTitleToastVisible(true);
};

return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
{/* 헤더 */}
Expand Down Expand Up @@ -122,25 +154,37 @@ export default function FolderDetailScreen() {
anchor={menuState.anchor}
items={[
{
label: currentLink?.isBookmarked ? '북마크 해제' : '북마크 추가',
label: '제목 수정',
onPress: () => {
if (menuState.linkId == null) return;
// API: PATCH /saved-links/{id}/bookmark
toggleBookmark(menuState.linkId);
if (!currentLink) return;
setEditingLink(currentLink);
},
},
{
label: '폴더에서 삭제',
destructive: true,
onPress: () => {
if (menuState.linkId == null) return;
// API: PATCH /api/v1/saved-links/{id} { categoryId: null }
handleDelete(menuState.linkId);
confirmDeleteFromFolder(menuState.linkId);
},
},
]}
onDismiss={() => setMenuState({ visible: false })}
/>

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

<Toast
visible={titleToastVisible}
message="제목이 수정되었습니다."
placement="top"
topOffset={96}
onHide={() => setTitleToastVisible(false)}
/>
</SafeAreaView>
);
}
Expand Down
11 changes: 1 addition & 10 deletions app/(tabs)/(folder)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,5 @@
import { Stack } from 'expo-router';

import { FoldersProvider } from '@/context/folders-context';
import { SavedLinksProvider } from '@/context/saved-links-context';

export default function FolderLayout() {
return (
<SavedLinksProvider>
<FoldersProvider>
<Stack screenOptions={{ headerShown: false }} />
</FoldersProvider>
</SavedLinksProvider>
);
return <Stack screenOptions={{ headerShown: false }} />;
}
12 changes: 10 additions & 2 deletions app/(tabs)/(folder)/folder-add-url.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useCallback, useState } from 'react';
import {
Alert,
FlatList,
StyleSheet,
Text,
Expand All @@ -11,6 +12,7 @@ import { Button } from '@/components/ui/button';
import { CardLink } from '@/components/ui/card-link';
import { SelectionCircle } from '@/components/ui/selection-circle';
import { Colors, Typography } from '@/constants/theme';
import { getFolderErrorMessage, useFolders } from '@/context/folders-context';
import { useSavedLinks, type SavedLink } from '@/context/saved-links-context';

// ─── Selectable card row ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -51,6 +53,7 @@ export default function FolderAddUrlScreen() {
folderName: string;
}>();
const { links, assignCategory } = useSavedLinks();
const { refreshFolders } = useFolders();

// 미분류 URL만 표시 (categoryId === null)
const uncategorizedLinks = links.filter((l) => l.categoryId === null);
Expand All @@ -74,12 +77,17 @@ export default function FolderAddUrlScreen() {
if (isAdding || selectedIds.size === 0) return;
setIsAdding(true);
try {
// PATCH /api/v1/saved-links/{id} { categoryId } — 선택한 링크들을 폴더에 추가
assignCategory([...selectedIds], Number(folderId));
await assignCategory([...selectedIds], Number(folderId));
await refreshFolders();
router.replace({
pathname: '/(tabs)/(folder)/[id]',
params: { id: folderId, urlAdded: '1' },
});
} catch (error) {
Alert.alert(
'URL 추가 실패',
getFolderErrorMessage(error, '선택한 URL을 폴더에 추가하지 못했습니다. 잠시 후 다시 시도해주세요.'),
);
} finally {
setIsAdding(false);
}
Expand Down
32 changes: 30 additions & 2 deletions app/(tabs)/(folder)/folder-name.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,31 @@ import { router, Stack } from 'expo-router';

import { Button } from '@/components/ui/button';
import { Colors, Typography } from '@/constants/theme';
import { useFolders } from '@/context/folders-context';

export default function FolderNameScreen() {
const [folderName, setFolderName] = useState('');
const [isFocused, setIsFocused] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const { folders } = useFolders();

const canProceed = folderName.trim().length > 0;

const handleNext = () => {
const trimmedName = folderName.trim();
const hasDuplicateName = folders.some(
(folder) => normalizeFolderName(folder.name) === normalizeFolderName(trimmedName),
);

if (hasDuplicateName) {
setErrorMessage('이미 같은 이름의 폴더가 있어요.');
return;
}

setErrorMessage('');
router.push({
pathname: '/(tabs)/(folder)/folder-url-select',
params: { folderName: folderName.trim() },
params: { folderName: trimmedName },
});
};

Expand Down Expand Up @@ -57,7 +71,12 @@ export default function FolderNameScreen() {
<TextInput
style={styles.input}
value={folderName}
onChangeText={setFolderName}
onChangeText={(value) => {
setFolderName(value);
if (errorMessage) {
setErrorMessage('');
}
}}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder="폴더 이름 입력"
Expand All @@ -76,6 +95,7 @@ export default function FolderNameScreen() {
</TouchableOpacity>
)}
</View>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
<Text style={styles.helperText}>
{'이 이름으로 폴더가 생성되고,\n다음 단계에서 URL을 고를 수 있어요.'}
</Text>
Expand Down Expand Up @@ -168,7 +188,15 @@ const styles = StyleSheet.create({
color: Colors.brand.textSecondary,
lineHeight: 20,
},
errorText: {
...Typography.caption,
color: Colors.brand.textWarning,
},
buttonArea: {
marginTop: 32,
},
});

function normalizeFolderName(name: string) {
return name.trim().toLocaleLowerCase();
}
Loading
Loading