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
21 changes: 21 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
## 개요
<!-- 이번 PR의 개요를 설명해주세요. -->
<!-- 예시: apps/web에 Vitest + React Testing Library 기반 단위 테스트 환경을 구축하고 데이터/로직 레이어와 커스텀 훅에 대한 단위 테스트를 신규 구현합니다. -->
-

## 작업 내용
<!-- 구현/수정한 기능 목록을 상세히 작성해주세요. -->

-

## 검증 결과
<!-- 테스트 실행 결과 및 빌드/린트 검증 결과를 작성해주세요. -->
- **Test Files**: <!-- 예시: 11 passed -->
- **Tests**: <!-- 예시: 45 passed -->
- [ ] `tsc --noEmit` 통과
- [ ] `biome check` (또는 `lint/format`) 통과

## 비고
<!-- 의존성 추가, 후속 작업, E2E 계획 등 특이사항을 기재해주세요. -->
<!-- 예시: 통합 테스트(MSW) 및 E2E(Playwright)는 후속 PR로 분리 예정 -->
-
18 changes: 18 additions & 0 deletions apps/extension/domain/bookmarks/quick-bookmark.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { browser } from 'wxt/browser';

const POPUP_SIZE = { width: 440, height: 620 };

/** 대상 페이지의 제목/URL을 쿼리로 실어 북마크 추가 팝업 URL을 생성. */
export function buildQuickBookmarkUrl(targetUrl: string, title: string): string {
const params = new URLSearchParams({ url: targetUrl, title });
return browser.runtime.getURL(`/quick-bookmark.html?${params.toString()}`);
}

/** 대상 페이지 정보를 실어 북마크 추가 팝업 창을 연다. */
export function openQuickBookmarkPopup(targetUrl: string, title: string): void {
browser.windows.create({
url: buildQuickBookmarkUrl(targetUrl, title),
type: 'popup',
...POPUP_SIZE,
});
}
14 changes: 2 additions & 12 deletions apps/extension/entrypoints/background/context-menu.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import { browser } from 'wxt/browser';
import { openQuickBookmarkPopup } from '@/domain/bookmarks/quick-bookmark';

const CONTEXT_MENU_ID = 'dookmark-add-bookmark';

// 대상 페이지의 제목/URL을 쿼리로 실어 북마크 추가 팝업 URL을 생성.
function setQuickBookmarkPopupUrl(targetUrl: string, title: string): string {
const params = new URLSearchParams({ url: targetUrl, title });
return browser.runtime.getURL(`/quick-bookmark.html?${params.toString()}`);
}

export function setupContextMenu() {
if (!browser.contextMenus) return;

Expand All @@ -30,11 +25,6 @@ export function setupContextMenu() {
const title = info.linkUrl ? '' : tab?.title || '';
if (!targetUrl) return;

browser.windows.create({
url: setQuickBookmarkPopupUrl(targetUrl, title),
type: 'popup',
width: 440,
height: 620,
});
openQuickBookmarkPopup(targetUrl, title);
});
}
20 changes: 20 additions & 0 deletions apps/extension/entrypoints/quick-bookmark/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,26 @@ async function saveMetadataWithRetry(
}

export function CreateApp() {
useEffect(() => {
const syncTheme = () => {
const savedTheme = localStorage.getItem('theme') || 'system';
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = savedTheme === 'dark' || (savedTheme === 'system' && systemPrefersDark);
document.documentElement.classList.toggle('dark', isDark);
};

syncTheme();

const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', syncTheme);
window.addEventListener('storage', syncTheme);

return () => {
mediaQuery.removeEventListener('change', syncTheme);
window.removeEventListener('storage', syncTheme);
};
}, []);

const params = useMemo(() => new URLSearchParams(window.location.search), []);

const [title, setTitle] = useState(params.get('title') ?? '');
Expand Down
9 changes: 6 additions & 3 deletions apps/extension/entrypoints/sidepanel/pages/main.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { browser } from 'wxt/browser';
import { openQuickBookmarkPopup } from '@/domain/bookmarks/quick-bookmark';
import { useLocalDnd } from '@/domain/dnd/use-local-dnd';
import { useSidepanel } from '@/entrypoints/sidepanel/providers/sidepanel-provider';
import { useTheme } from '@/entrypoints/sidepanel/providers/theme-provider';
import { useNavigation } from '@/entrypoints/sidepanel/shared/hooks/use-navigation';
import { SidepanelContent } from '@/entrypoints/sidepanel/ui/shared/sidepanel-content';
import { SidepanelFooter } from '@/entrypoints/sidepanel/ui/shared/sidepanel-footer';
Expand All @@ -14,7 +14,6 @@ const WEB_BASE_URL = 'http://localhost:3000';

export function MainPage() {
const nav = useNavigation();
const { theme, toggleTheme } = useTheme();
const {
loading,
searchQuery,
Expand All @@ -32,7 +31,7 @@ export function MainPage() {

return (
<SidepanelContent
header={<SidepanelHeader theme={theme} onToggleTheme={toggleTheme} />}
header={<SidepanelHeader />}
main={
<SidepanelMain
loading={loading}
Expand All @@ -46,6 +45,10 @@ export function MainPage() {
onNavigateToDashboard={() => {
browser.tabs.create({ url: `${WEB_BASE_URL}/dashboard` });
}}
onAddClick={async () => {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
if (tab?.url) openQuickBookmarkPopup(tab.url, tab.title ?? '');
}}
>
{!searchQuery && (
<FavoritesSection favorites={favorites} onToggleFavorite={toggleFavorite} />
Expand Down
67 changes: 52 additions & 15 deletions apps/extension/entrypoints/sidepanel/providers/theme-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
'use client';

import type React from 'react';
import { createContext, useContext, useEffect, useState } from 'react';

type Theme = 'light' | 'dark';
export type Theme = 'light' | 'dark' | 'system';

interface ThemeContextType {
theme: Theme;
Expand All @@ -12,28 +14,63 @@ interface ThemeContextType {
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('light');
const [theme, setTheme] = useState<Theme>('system');
const [systemPrefersDark, setSystemPrefersDark] = useState<boolean>(false);

// Load saved theme on mount
// Load saved theme, sync system theme and setup storage listeners on mount
useEffect(() => {
const savedTheme = localStorage.getItem('theme') as Theme | null;
const initialTheme = savedTheme || 'light';
setTheme(initialTheme);
document.documentElement.classList.toggle('dark', initialTheme === 'dark');
const loadTheme = () => {
const savedTheme = localStorage.getItem('theme') as Theme | null;
if (savedTheme) {
setTheme(savedTheme);
} else {
localStorage.setItem('theme', 'system');
}
};

loadTheme();

const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
setSystemPrefersDark(mediaQuery.matches);

const handleSystemThemeChange = (e: MediaQueryListEvent) => {
setSystemPrefersDark(e.matches);
};

// 다른 익스텐션 탭/창에서 테마 변경 시 동기화
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'theme') {
loadTheme();
}
};

mediaQuery.addEventListener('change', handleSystemThemeChange);
window.addEventListener('storage', handleStorageChange);

return () => {
mediaQuery.removeEventListener('change', handleSystemThemeChange);
window.removeEventListener('storage', handleStorageChange);
};
}, []);

// Sync class list and localStorage whenever theme state changes
const isDark = theme === 'dark' || (theme === 'system' && systemPrefersDark);

// Declarative side-effect to sync document class and localStorage
useEffect(() => {
document.documentElement.classList.toggle('dark', isDark);
localStorage.setItem('theme', theme);
}, [theme, isDark]);

const toggleTheme = () => {
const nextTheme = theme === 'light' ? 'dark' : 'light';
setTheme(nextTheme);
localStorage.setItem('theme', nextTheme);
document.documentElement.classList.toggle('dark', nextTheme === 'dark');
setTheme((prev) => {
if (prev === 'system') return 'light';
if (prev === 'light') return 'dark';
return 'system';
});
};

return (
<ThemeContext.Provider value={{ theme, toggleTheme, isDark: theme === 'dark' }}>
{children}
</ThemeContext.Provider>
<ThemeContext.Provider value={{ theme, toggleTheme, isDark }}>{children}</ThemeContext.Provider>
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@ export function LoginRequired({ onOpenLoginTab }: LoginRequiredProps) {
</h1>
</div>
<div className='bg-surface-container-lowest border border-outline-variant rounded-2xl p-6 shadow-lg w-full box-border text-center'>
<h2 className='text-lg font-bold text-on-surface mt-0 mb-2.5'>스마트한 북마크 관리</h2>
<h2 className='text-lg font-bold text-on-surface mt-0 mb-2.5'>
실시간 크로스 브라우저 북마크 동기화 서비스
</h2>
<p className='text-[12.5px] text-on-surface-variant leading-relaxed mb-6'>
북마크를 인공지능으로 지능화하여 한눈에 관리하고 탐색해 보세요.
여러 브라우저의 북마크를 한곳에서 관리하고
<br />
어디서든 빠르게 찾아보세요.
</p>
<button
type='button'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { ArrowLeft, Moon, Sun } from 'lucide-react';
import { useTheme } from '@/entrypoints/sidepanel/providers/theme-provider';

interface SidepanelHeaderProps {
theme: 'light' | 'dark';
onToggleTheme: () => void;
}
export function SidepanelHeader() {
const { toggleTheme, isDark } = useTheme();

export function SidepanelHeader({ theme, onToggleTheme }: SidepanelHeaderProps) {
return (
<header className='flex items-center justify-between h-[52px] px-4 border-b border-outline-variant bg-surface-container-lowest shrink-0'>
<button
Expand All @@ -19,13 +17,13 @@ export function SidepanelHeader({ theme, onToggleTheme }: SidepanelHeaderProps)
<button
type='button'
className='bg-transparent border-0 p-1.5 text-on-surface-variant cursor-pointer rounded-full flex items-center justify-center transition-all duration-200 w-8 h-8 hover:bg-surface-container-low hover:text-on-surface'
onClick={onToggleTheme}
onClick={toggleTheme}
aria-label='Toggle Theme'
>
{theme === 'light' ? (
<Sun size={18} strokeWidth={2} aria-hidden='true' />
) : (
{isDark ? (
<Moon size={18} strokeWidth={2} aria-hidden='true' />
) : (
<Sun size={18} strokeWidth={2} aria-hidden='true' />
)}
</button>
</header>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface SidepanelMainProps {
onDragOver: (event: DragOverEvent) => void;
onDragEnd: (event: DragEndEvent) => void;
onNavigateToDashboard: () => void;
onAddClick?: () => void;
children: React.ReactNode;
}

Expand All @@ -31,6 +32,7 @@ export function SidepanelMain({
onDragOver,
onDragEnd,
onNavigateToDashboard,
onAddClick,
children,
}: SidepanelMainProps) {
return (
Expand All @@ -57,6 +59,7 @@ export function SidepanelMain({
<button
type='button'
className='flex-1 flex items-center justify-center gap-1.5 bg-primary text-on-primary border-0 py-2.5 px-4 text-sm font-semibold rounded-[20px] cursor-pointer shadow-sm transition-all duration-200 hover:brightness-95 hover:-translate-y-[1px]'
onClick={onAddClick}
>
<Plus size={14} strokeWidth={2.5} aria-hidden='true' />
<span>북마크</span>
Expand Down
37 changes: 31 additions & 6 deletions apps/extension/entrypoints/welcome/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Bookmark, MousePointerClick, PanelRight, Search } from 'lucide-react';
import { useEffect } from 'react';
import { browser } from 'wxt/browser';

const WEB_BASE_URL = 'http://localhost:3000';
Expand All @@ -11,13 +12,13 @@ const STEPS = [
},
{
icon: Bookmark,
title: '브라우저 북마크 자동 동기화',
desc: '기존 북마크가 자동으로 동기화되고, 여러 브라우저의 북마크를 한곳에서 관리해요.',
title: '크로스 브라우저 실시간 동기화',
desc: '크롬, 웨일, 엣지 등 어떤 브라우저를 쓰든 기존 북마크가 안전하고 실시간으로 연동됩니다.',
},
{
icon: MousePointerClick,
title: '우클릭으로 빠른 저장',
desc: '웹페이지에서 우클릭 → "Dookmark로 북마크하기"로 메모·태그·폴더까지 바로 지정해요.',
title: '자유로운 북마크 저장',
desc: '우클릭, 브라우저 기본 북마크 모두 자동으로 동기화됩니다.',
},
{
icon: Search,
Expand All @@ -27,6 +28,26 @@ const STEPS = [
];

export function WelcomeApp() {
useEffect(() => {
const syncTheme = () => {
const savedTheme = localStorage.getItem('theme') || 'system';
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = savedTheme === 'dark' || (savedTheme === 'system' && systemPrefersDark);
document.documentElement.classList.toggle('dark', isDark);
};

syncTheme();

const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', syncTheme);
window.addEventListener('storage', syncTheme);

return () => {
mediaQuery.removeEventListener('change', syncTheme);
window.removeEventListener('storage', syncTheme);
};
}, []);

const handleLogin = () => {
browser.tabs.create({ url: `${WEB_BASE_URL}/auth?deviceType=EXTENSION` });
};
Expand All @@ -41,7 +62,9 @@ export function WelcomeApp() {
</h1>
<h2 className='text-xl font-bold text-on-surface m-0'>Dookmark에 오신 것을 환영합니다</h2>
<p className='text-[14px] text-on-surface-variant leading-relaxed m-0 max-w-[420px]'>
북마크를 더 똑똑하게 — 여러 브라우저의 북마크를 한곳에서 관리하고, 메모·태그로 정리하고,
여러 브라우저의 북마크를 한곳에서 관리하고
</p>
<p className='text-[14px] text-on-surface-variant leading-relaxed m-0 max-w-[420px]'>
어디서든 빠르게 찾아보세요.
</p>
</div>
Expand All @@ -58,7 +81,9 @@ export function WelcomeApp() {
</span>
<div className='flex flex-col gap-1 min-w-0'>
<span className='text-[13.5px] font-bold text-on-surface'>{title}</span>
<span className='text-[12px] text-on-surface-variant leading-relaxed'>{desc}</span>
<span className='text-[12px] text-on-surface-variant leading-relaxed whitespace-pre-line'>
{desc}
</span>
</div>
</div>
))}
Expand Down
Binary file modified apps/extension/public/icon/128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/extension/public/icon/16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/extension/public/icon/32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/extension/public/icon/48.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/extension/public/icon/96.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed apps/web/app/favicon.ico
Binary file not shown.
29 changes: 29 additions & 0 deletions apps/web/app/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading