Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5530cfe7fe | ||
|
|
31eee4bab9 | ||
|
|
f04b97d52a | ||
|
|
df272e38ef | ||
|
|
a835af2d71 | ||
|
|
30284859c7 | ||
|
|
64c3d7e384 | ||
|
|
e6d09546b1 | ||
|
|
83a0a1e235 | ||
|
|
7c3c3b5f7b | ||
|
|
8b1b3ad57b | ||
|
|
afefbb8d46 | ||
|
|
4c2d185be4 | ||
|
|
c73940e22a | ||
|
|
09eda86d3f | ||
|
|
0d28d811a8 | ||
|
|
45a485a1fa | ||
|
|
bc202498d5 | ||
|
|
721556e777 | ||
|
|
8c9cf3a66b | ||
|
|
b88026de82 | ||
|
|
6ed8ae5812 | ||
|
|
089583a9ef | ||
|
|
2d834213ee | ||
|
|
439a4dbe8a | ||
|
|
8350bad2a6 | ||
|
|
2d56cc9be9 | ||
|
|
2547c10060 | ||
|
|
01779fa59e | ||
|
|
fc38427ed0 | ||
|
|
3cbfb70860 | ||
|
|
c32b740dac | ||
|
|
a02091a7ad | ||
|
|
bc311adf6a | ||
|
|
8aca1623f4 | ||
|
|
a8be40579e |
+4
-1
@@ -64,7 +64,10 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# SETTINGS_SYNC_ENABLED=true
|
||||
|
||||
# Directory for storing encrypted settings files (default: ./data/settings).
|
||||
# For Docker, mount a persistent volume at this path.
|
||||
# For Docker, the working directory is /app, so the default resolves to
|
||||
# /app/data/settings — mount a persistent volume there:
|
||||
# volumes:
|
||||
# - bulwark-settings:/app/data/settings
|
||||
# SETTINGS_DATA_DIR=./data/settings
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
name: Publish Docker Image on Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
IMAGE_NAME: ghcr.io/${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=semver,pattern=v{{version}}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern=v{{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern=v{{major}}
|
||||
type=semver,pattern={{major}}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
@@ -2,7 +2,9 @@ name: Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- "Dockerfile"
|
||||
- ".dockerignore"
|
||||
@@ -17,7 +19,6 @@ on:
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- ".github/workflows/docker-publish.yml"
|
||||
tags: ["v*.*.*"]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -114,10 +115,8 @@ jobs:
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,prefix=
|
||||
type=raw,value={{branch}}
|
||||
type=sha,prefix={{branch}}-
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
# Changelog
|
||||
|
||||
## 1.4.7 (2026-03-21)
|
||||
|
||||
### Features
|
||||
|
||||
- **Calendar**: Add task management features with task creation, editing, and status tracking
|
||||
- **Calendar**: Add option to show week numbers in mini-calendar
|
||||
- **Email**: Add resizable image component and rich text editor with image upload support
|
||||
- **Files**: Support uploading folders via drag-and-drop and toolbar button
|
||||
- **Filters**: Add expanded visual view for filter rules
|
||||
- **Auth**: Add non-interactive SSO login flow for embedded/iframe deployments (#69)
|
||||
- **DevOps**: Add separate Docker build workflow for releases and dev branch images
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Calendar**: Handle updates and deletions for synthetic JMAP IDs in calendar events with fallback to destroy and recreate
|
||||
- **Security**: Extend CryptoEngine to support legacy algorithms and integrate with LinerEngine for decryption
|
||||
- **Auth**: Refactor logout to use synchronous flow with full page redirect
|
||||
- **Email**: Update iframe sandbox attributes to allow popups to escape sandbox
|
||||
- **i18n**: Add missing translation keys across all locales
|
||||
- **Docker**: Update .env.example to clarify Docker volume mounting for settings data directory
|
||||
|
||||
## 1.4.6 (2026-03-21)
|
||||
|
||||
### Features
|
||||
|
||||
- **Demo**: Add full demo mode with fixture data for emails, calendars, contacts, files, filters, identities, mailboxes, and vacation responses
|
||||
- **Demo**: Implement JMAP client interface abstraction to support demo and live backends
|
||||
- **Contacts**: Add no-category filter, drag-and-drop to category, and category combo box in contact form
|
||||
- **Email**: Add hover actions for emails with configurable quick-action buttons
|
||||
- **Settings**: Implement keyword migration functionality for upgrading legacy email tags
|
||||
- **Security**: Enhance S/MIME certificate extraction and add legacy PBE (password-based encryption) support
|
||||
- **Tour**: Add interactive guided tour overlay for new user onboarding
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Settings**: Add missing `showTimeInMonthView` and `showOnMobile` type definitions to settings store
|
||||
- **UI**: Adjust padding and size of sidebar buttons for improved layout
|
||||
|
||||
## 1.4.5 (2026-03-20)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
||||
Built with Next.js and the JMAP protocol.
|
||||
|
||||
[](LICENSE)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ function OAuthCallbackInner() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const t = useTranslations("login");
|
||||
const { loginWithOAuth } = useAuthStore();
|
||||
const { loginWithOAuth, loginWithServerSso } = useAuthStore();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -32,6 +32,9 @@ function OAuthCallbackInner() {
|
||||
}
|
||||
|
||||
const savedState = sessionStorage.getItem("oauth_state");
|
||||
|
||||
if (savedState) {
|
||||
// Classic flow — sessionStorage has the PKCE state (same-tab OAuth)
|
||||
if (!state || state !== savedState) {
|
||||
setError("invalid_state");
|
||||
return;
|
||||
@@ -70,6 +73,30 @@ function OAuthCallbackInner() {
|
||||
.catch(() => {
|
||||
setError("token_exchange_failed");
|
||||
});
|
||||
} else if (state) {
|
||||
// Server-side SSO flow — state was stored in encrypted httpOnly cookie
|
||||
loginWithServerSso(code, state)
|
||||
.then((success) => {
|
||||
if (success) {
|
||||
let redirectTo = `/${params.locale}`;
|
||||
try {
|
||||
const saved = sessionStorage.getItem('redirect_after_login');
|
||||
if (saved) {
|
||||
sessionStorage.removeItem('redirect_after_login');
|
||||
redirectTo = saved;
|
||||
}
|
||||
} catch { /* sessionStorage may be unavailable */ }
|
||||
router.push(redirectTo);
|
||||
} else {
|
||||
setError("token_exchange_failed");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError("token_exchange_failed");
|
||||
});
|
||||
} else {
|
||||
setError("invalid_state");
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (error) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "date-fns";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { isCalendarViewMode } from "@/stores/calendar-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
@@ -23,6 +23,9 @@ import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
|
||||
import { CalendarWeekView } from "@/components/calendar/calendar-week-view";
|
||||
import { CalendarDayView } from "@/components/calendar/calendar-day-view";
|
||||
import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view";
|
||||
import { TaskListView } from "@/components/calendar/task-list-view";
|
||||
import { TaskToolbar } from "@/components/calendar/task-toolbar";
|
||||
import { TaskModal } from "@/components/calendar/task-modal";
|
||||
import { MiniCalendar } from "@/components/calendar/mini-calendar";
|
||||
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
|
||||
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
|
||||
@@ -35,6 +38,7 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { useTaskStore } from "@/stores/task-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
||||
import { getUserParticipantId } from "@/lib/calendar-participants";
|
||||
@@ -63,7 +67,8 @@ export default function CalendarPage() {
|
||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
|
||||
refreshAllSubscriptions,
|
||||
} = useCalendarStore();
|
||||
const { firstDayOfWeek, timeFormat } = useSettingsStore();
|
||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar } = useSettingsStore();
|
||||
const taskStore = useTaskStore();
|
||||
const { identities } = useIdentityStore();
|
||||
const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month";
|
||||
|
||||
@@ -83,6 +88,8 @@ export default function CalendarPage() {
|
||||
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
|
||||
const [detailAnchorRect, setDetailAnchorRect] = useState<DOMRect | null>(null);
|
||||
const [pendingPreview, setPendingPreview] = useState<PendingEventPreview | null>(null);
|
||||
const [showTaskModal, setShowTaskModal] = useState(false);
|
||||
const [editTask, setEditTask] = useState<import("@/lib/jmap/types").CalendarTask | null>(null);
|
||||
const hasFetched = useRef(false);
|
||||
|
||||
// Sidebar resize state
|
||||
@@ -105,7 +112,7 @@ export default function CalendarPage() {
|
||||
useEffect(() => {
|
||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||
router.push("/login");
|
||||
redirectToLogin();
|
||||
} else if (client && !supportsCalendar) {
|
||||
router.push("/");
|
||||
}
|
||||
@@ -166,9 +173,18 @@ export default function CalendarPage() {
|
||||
end: format(addDays(agendaStart, 30), "yyyy-MM-dd'T'23:59:59"),
|
||||
};
|
||||
}
|
||||
case "tasks":
|
||||
return null;
|
||||
}
|
||||
}, [selectedDate, normalizedViewMode, firstDayOfWeek]);
|
||||
|
||||
// Fetch tasks when tasks view is active or when tasks are shown on calendar grid
|
||||
useEffect(() => {
|
||||
if (client && enableCalendarTasks && (normalizedViewMode === "tasks" || showTasksOnCalendar)) {
|
||||
taskStore.fetchTasks(client);
|
||||
}
|
||||
}, [client, enableCalendarTasks, normalizedViewMode, showTasksOnCalendar]);
|
||||
|
||||
useEffect(() => {
|
||||
if (client && calendars.length > 0 && dateRange) {
|
||||
fetchEvents(client, dateRange.start, dateRange.end);
|
||||
@@ -182,6 +198,7 @@ export default function CalendarPage() {
|
||||
case "week": next = subWeeks(selectedDate, 1); break;
|
||||
case "day": next = subDays(selectedDate, 1); break;
|
||||
case "agenda": next = subMonths(selectedDate, 1); break;
|
||||
case "tasks": return;
|
||||
}
|
||||
setSelectedDate(next);
|
||||
setMiniMonth(next);
|
||||
@@ -194,6 +211,7 @@ export default function CalendarPage() {
|
||||
case "week": next = addWeeks(selectedDate, 1); break;
|
||||
case "day": next = addDays(selectedDate, 1); break;
|
||||
case "agenda": next = addMonths(selectedDate, 1); break;
|
||||
case "tasks": return;
|
||||
}
|
||||
setSelectedDate(next);
|
||||
setMiniMonth(next);
|
||||
@@ -254,6 +272,34 @@ export default function CalendarPage() {
|
||||
setShowEventModal(true);
|
||||
}, []);
|
||||
|
||||
const openCreateTaskModal = useCallback(() => {
|
||||
setEditTask(null);
|
||||
setShowTaskModal(true);
|
||||
}, []);
|
||||
|
||||
const openEditTaskModal = useCallback((task: import("@/lib/jmap/types").CalendarTask) => {
|
||||
setEditTask(task);
|
||||
setShowTaskModal(true);
|
||||
}, []);
|
||||
|
||||
const handleSaveTask = useCallback(async (data: Partial<import("@/lib/jmap/types").CalendarTask>) => {
|
||||
if (!client) return;
|
||||
if (editTask) {
|
||||
await taskStore.updateTask(client, editTask.id, data);
|
||||
} else {
|
||||
await taskStore.createTask(client, data);
|
||||
}
|
||||
setShowTaskModal(false);
|
||||
setEditTask(null);
|
||||
}, [client, editTask, taskStore]);
|
||||
|
||||
const handleDeleteTask = useCallback(async (id: string) => {
|
||||
if (!client) return;
|
||||
await taskStore.deleteTask(client, id);
|
||||
setShowTaskModal(false);
|
||||
setEditTask(null);
|
||||
}, [client, taskStore]);
|
||||
|
||||
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const closeDetail = useCallback(() => {
|
||||
@@ -424,9 +470,22 @@ export default function CalendarPage() {
|
||||
try {
|
||||
if (type === "edit" && updates) {
|
||||
switch (scope) {
|
||||
case "this":
|
||||
case "this": {
|
||||
// Synthetic IDs (from expandRecurrences) can't be updated directly.
|
||||
// Patch the master event's recurrenceOverrides instead.
|
||||
const master = await findMasterEvent(event);
|
||||
if (master && event.recurrenceId) {
|
||||
const patchUpdates: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (['id', 'uid', '@type', 'calendarIds', 'recurrenceRules', 'recurrenceOverrides', 'excludedRecurrenceRules'].includes(key)) continue;
|
||||
patchUpdates[`recurrenceOverrides/${event.recurrenceId}/${key}`] = value;
|
||||
}
|
||||
await updateEvent(client, master.id, patchUpdates as Partial<CalendarEvent>, sendScheduling);
|
||||
} else {
|
||||
await updateEvent(client, event.id, updates, sendScheduling);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "this_and_future": {
|
||||
const result = await truncateRecurrenceAtEvent(event);
|
||||
if (!result) {
|
||||
@@ -484,9 +543,20 @@ export default function CalendarPage() {
|
||||
toast.success(t("notifications.event_updated"));
|
||||
} else {
|
||||
switch (scope) {
|
||||
case "this":
|
||||
case "this": {
|
||||
// Synthetic IDs (from expandRecurrences) can't be destroyed directly.
|
||||
// Exclude the instance via recurrenceOverrides on the master event.
|
||||
const delMaster = await findMasterEvent(event);
|
||||
if (delMaster && event.recurrenceId) {
|
||||
await updateEvent(
|
||||
client, delMaster.id,
|
||||
{ [`recurrenceOverrides/${event.recurrenceId}`]: { excluded: true } } as Partial<CalendarEvent>,
|
||||
);
|
||||
} else {
|
||||
await deleteEvent(client, event.id, sendScheduling);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "this_and_future": {
|
||||
const result = await truncateRecurrenceAtEvent(event);
|
||||
if (!result) {
|
||||
@@ -598,6 +668,7 @@ export default function CalendarPage() {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") return;
|
||||
if (target.getAttribute("contenteditable") === "true") return;
|
||||
if (showEventModal || detailEvent) return;
|
||||
|
||||
switch (e.key) {
|
||||
@@ -608,6 +679,7 @@ export default function CalendarPage() {
|
||||
case "w": setViewMode("week"); break;
|
||||
case "d": setViewMode("day"); break;
|
||||
case "a": setViewMode("agenda"); break;
|
||||
case "k": if (enableCalendarTasks) setViewMode("tasks"); break;
|
||||
case "n": openCreateModal(); break;
|
||||
}
|
||||
};
|
||||
@@ -668,6 +740,8 @@ export default function CalendarPage() {
|
||||
timeFormat={timeFormat}
|
||||
isMobile={isMobile}
|
||||
pendingPreview={pendingPreview}
|
||||
tasks={enableCalendarTasks && showTasksOnCalendar ? taskStore.tasks : undefined}
|
||||
onToggleTaskComplete={(task) => { if (client) taskStore.toggleTaskComplete(client, task); }}
|
||||
/>
|
||||
);
|
||||
case "day":
|
||||
@@ -683,6 +757,8 @@ export default function CalendarPage() {
|
||||
timeFormat={timeFormat}
|
||||
isMobile={isMobile}
|
||||
pendingPreview={pendingPreview}
|
||||
tasks={enableCalendarTasks && showTasksOnCalendar ? taskStore.tasks : undefined}
|
||||
onToggleTaskComplete={(task) => { if (client) taskStore.toggleTaskComplete(client, task); }}
|
||||
/>
|
||||
);
|
||||
case "agenda":
|
||||
@@ -697,6 +773,33 @@ export default function CalendarPage() {
|
||||
timeFormat={timeFormat}
|
||||
/>
|
||||
);
|
||||
case "tasks":
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<TaskToolbar
|
||||
filter={taskStore.filter}
|
||||
showCompleted={taskStore.showCompleted}
|
||||
onFilterChange={taskStore.setFilter}
|
||||
onShowCompletedChange={taskStore.setShowCompleted}
|
||||
onCreateTask={openCreateTaskModal}
|
||||
/>
|
||||
<TaskListView
|
||||
tasks={taskStore.tasks}
|
||||
calendars={calendars}
|
||||
selectedCalendarIds={selectedCalendarIds}
|
||||
filter={taskStore.filter}
|
||||
showCompleted={taskStore.showCompleted}
|
||||
onSelectTask={openEditTaskModal}
|
||||
onToggleComplete={(task) => { if (client) taskStore.toggleTaskComplete(client, task); }}
|
||||
selectedTaskId={taskStore.selectedTaskId}
|
||||
onQuickCreate={(title) => {
|
||||
if (client) {
|
||||
taskStore.createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [calendars[0]?.id ?? ""]: true } });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -721,7 +824,7 @@ export default function CalendarPage() {
|
||||
collapsed
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
|
||||
onLogout={logout}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
@@ -751,6 +854,7 @@ export default function CalendarPage() {
|
||||
onChangeMonth={handleMiniMonthChange}
|
||||
events={events}
|
||||
firstDayOfWeek={firstDayOfWeek}
|
||||
showWeekNumbers={showWeekNumbers}
|
||||
/>
|
||||
<CalendarSidebarPanel
|
||||
calendars={calendars}
|
||||
@@ -791,10 +895,12 @@ export default function CalendarPage() {
|
||||
calendars={calendars}
|
||||
selectedCalendarIds={selectedCalendarIds}
|
||||
onToggleVisibility={toggleCalendarVisibility}
|
||||
enableCalendarTasks={enableCalendarTasks}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="flex flex-1 overflow-hidden relative"
|
||||
data-tour="calendar-view"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
@@ -821,6 +927,21 @@ export default function CalendarPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Desktop task panel */}
|
||||
{!isMobile && showTaskModal && (
|
||||
<div className="w-[400px] border-l border-border flex-shrink-0 overflow-hidden">
|
||||
<TaskModal
|
||||
key={editTask?.id ?? 'new-task'}
|
||||
task={editTask}
|
||||
calendars={calendars}
|
||||
onSave={handleSaveTask}
|
||||
onDelete={handleDeleteTask}
|
||||
onClose={() => { setShowTaskModal(false); setEditTask(null); }}
|
||||
isMobile={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating Create Event Button (mobile) */}
|
||||
{isMobile && (
|
||||
<Button
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ArrowLeft, Users } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -16,7 +15,7 @@ import { ContactsSidebar, type ContactCategory } from "@/components/contacts/con
|
||||
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
|
||||
import { exportContacts } from "@/components/contacts/contact-export";
|
||||
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -39,7 +38,6 @@ type View =
|
||||
| "bulk-add-to-group";
|
||||
|
||||
export default function ContactsPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("contacts");
|
||||
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
@@ -109,9 +107,9 @@ export default function ContactsPage() {
|
||||
useEffect(() => {
|
||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||
router.push("/login");
|
||||
redirectToLogin();
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading, router]);
|
||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (client && supportsSync && !hasFetched.current) {
|
||||
@@ -126,9 +124,24 @@ export default function ContactsPage() {
|
||||
const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null;
|
||||
const selectedGroupMembers = selectedGroupId ? getGroupMembers(selectedGroupId) : [];
|
||||
|
||||
// Collect all unique keywords across contacts
|
||||
const allKeywords = useMemo(() => {
|
||||
const kws = new Set<string>();
|
||||
for (const contact of individuals) {
|
||||
if (!contact.keywords) continue;
|
||||
for (const [kw, active] of Object.entries(contact.keywords)) {
|
||||
if (active) kws.add(kw);
|
||||
}
|
||||
}
|
||||
return Array.from(kws).sort((a, b) => a.localeCompare(b));
|
||||
}, [individuals]);
|
||||
|
||||
// Contacts to display based on active category
|
||||
const displayedContacts = useMemo(() => {
|
||||
if (activeCategory === "all") return individuals;
|
||||
if (activeCategory === "uncategorized") {
|
||||
return individuals.filter(c => !c.keywords || Object.keys(c.keywords).filter(k => c.keywords![k]).length === 0);
|
||||
}
|
||||
if ("addressBookId" in activeCategory) {
|
||||
const bookId = activeCategory.addressBookId;
|
||||
return individuals.filter(c => {
|
||||
@@ -146,6 +159,7 @@ export default function ContactsPage() {
|
||||
// Label for the current category
|
||||
const categoryLabel = useMemo(() => {
|
||||
if (activeCategory === "all") return t("tabs.all");
|
||||
if (activeCategory === "uncategorized") return t("no_category");
|
||||
if ("addressBookId" in activeCategory) {
|
||||
const book = addressBooks.find(b => b.id === activeCategory.addressBookId);
|
||||
return book?.name || t("tabs.all");
|
||||
@@ -182,6 +196,31 @@ export default function ContactsPage() {
|
||||
}
|
||||
}, [client, moveContactToAddressBook, t]);
|
||||
|
||||
const handleDropContactsToCategory = useCallback(async (contactIds: string[], keyword: string) => {
|
||||
if (!client && supportsSync) return;
|
||||
try {
|
||||
for (const contactId of contactIds) {
|
||||
const contact = contacts.find(c => c.id === contactId);
|
||||
if (!contact) continue;
|
||||
const existingKeywords = contact.keywords || {};
|
||||
if (existingKeywords[keyword]) continue; // already has this keyword
|
||||
const updatedKeywords = { ...existingKeywords, [keyword]: true };
|
||||
if (supportsSync && client) {
|
||||
await updateContact(client, contactId, { keywords: updatedKeywords });
|
||||
} else {
|
||||
updateLocalContact(contactId, { keywords: updatedKeywords });
|
||||
}
|
||||
}
|
||||
const msg = contactIds.length === 1
|
||||
? t("category_added", { name: keyword })
|
||||
: t("category_added_plural", { count: contactIds.length, name: keyword });
|
||||
toast.success(msg);
|
||||
} catch (error) {
|
||||
console.error('Failed to add contacts to category:', error);
|
||||
toast.error(t("toast.error_update"));
|
||||
}
|
||||
}, [client, supportsSync, contacts, updateContact, updateLocalContact, t]);
|
||||
|
||||
const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
|
||||
return importContacts(
|
||||
supportsSync && client ? client : null,
|
||||
@@ -430,7 +469,7 @@ export default function ContactsPage() {
|
||||
const renderRightPanel = () => {
|
||||
switch (view) {
|
||||
case "create":
|
||||
return <ContactForm addressBooks={addressBooks} onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||
|
||||
case "edit":
|
||||
if (!selectedContact) return null;
|
||||
@@ -438,6 +477,7 @@ export default function ContactsPage() {
|
||||
<ContactForm
|
||||
contact={selectedContact}
|
||||
addressBooks={addressBooks}
|
||||
allKeywords={allKeywords}
|
||||
onSave={handleSaveEdit}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
@@ -552,7 +592,7 @@ export default function ContactsPage() {
|
||||
collapsed
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
|
||||
onLogout={logout}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
@@ -590,6 +630,7 @@ export default function ContactsPage() {
|
||||
onEditGroup={handleEditGroupFromSidebar}
|
||||
onDeleteGroup={handleDeleteGroupFromSidebar}
|
||||
onDropContacts={handleDropContacts}
|
||||
onDropContactsToCategory={handleDropContactsToCategory}
|
||||
/>
|
||||
</div>
|
||||
<ResizeHandle
|
||||
@@ -606,6 +647,7 @@ export default function ContactsPage() {
|
||||
|
||||
{/* Panel 2: Contact list */}
|
||||
<div
|
||||
data-tour="contacts-list"
|
||||
className={cn(
|
||||
"border-r border-border bg-background flex flex-col flex-shrink-0",
|
||||
isMobile ? "w-full" : "",
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ArrowLeft } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
@@ -112,9 +112,9 @@ export default function FilesPage() {
|
||||
useEffect(() => {
|
||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||
router.push("/login");
|
||||
redirectToLogin();
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading, router]);
|
||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||
|
||||
// Initialize JMAP files client
|
||||
useEffect(() => {
|
||||
@@ -357,7 +357,7 @@ export default function FilesPage() {
|
||||
collapsed
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
|
||||
onLogout={logout}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { notFound } from "next/navigation";
|
||||
import { IntlProvider } from "@/components/providers/intl-provider";
|
||||
import { ThemeProvider } from "@/components/providers/theme-provider";
|
||||
import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider";
|
||||
import { EmbeddedBridgeProvider } from "@/components/providers/embedded-bridge-provider";
|
||||
import { TourProvider } from "@/components/tour/tour-provider";
|
||||
import { locales } from "@/i18n/routing";
|
||||
|
||||
export default async function LocaleLayout({
|
||||
@@ -26,7 +28,11 @@ export default async function LocaleLayout({
|
||||
<IntlProvider locale={locale} messages={messages}>
|
||||
<ThemeProvider>
|
||||
<CalendarAlertProvider>
|
||||
<EmbeddedBridgeProvider>
|
||||
<TourProvider>
|
||||
{children}
|
||||
</TourProvider>
|
||||
</EmbeddedBridgeProvider>
|
||||
</CalendarAlertProvider>
|
||||
</ThemeProvider>
|
||||
</IntlProvider>
|
||||
|
||||
+249
-5
@@ -11,12 +11,12 @@ import { useThemeStore } from "@/stores/theme-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield } from "lucide-react";
|
||||
import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play } from "lucide-react";
|
||||
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
||||
|
||||
const APP_VERSION = "1.4.3";
|
||||
const APP_VERSION = "1.4.7";
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{ value: "light" as const, icon: Sun, label: "Light" },
|
||||
@@ -30,9 +30,9 @@ export default function LoginPage() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
||||
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError } = useConfig();
|
||||
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -54,6 +54,7 @@ export default function LoginPage() {
|
||||
const [oauthMetadata, setOauthMetadata] = useState<OAuthMetadata | null>(null);
|
||||
const [oauthDiscoveryDone, setOauthDiscoveryDone] = useState(false);
|
||||
const [oauthLoading, setOauthLoading] = useState(false);
|
||||
const [demoLoading, setDemoLoading] = useState(false);
|
||||
|
||||
const suggestionsRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -172,6 +173,63 @@ export default function LoginPage() {
|
||||
});
|
||||
}, [oauthEnabled, serverUrl, oauthIssuerUrl]);
|
||||
|
||||
// Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely
|
||||
const ssoError = searchParams.get("sso_error");
|
||||
const autoSsoTriggered = useRef(false);
|
||||
|
||||
const startServerSideSso = useCallback(async () => {
|
||||
setOauthLoading(true);
|
||||
try {
|
||||
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`;
|
||||
const res = await fetch('/api/auth/sso/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ redirect_uri: redirectUri, locale: params.locale }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
setOauthLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { authorize_url } = await res.json();
|
||||
|
||||
// Navigate to the authorize URL
|
||||
const isIframe = (() => { try { return window.self !== window.top; } catch { return true; } })();
|
||||
if (isIframe) {
|
||||
// In an iframe, try top-level navigation
|
||||
try {
|
||||
window.top!.location.href = authorize_url;
|
||||
} catch {
|
||||
// Cross-origin restriction — fall back to current frame
|
||||
window.location.href = authorize_url;
|
||||
}
|
||||
} else {
|
||||
window.location.href = authorize_url;
|
||||
}
|
||||
} catch {
|
||||
setOauthLoading(false);
|
||||
}
|
||||
}, [params.locale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return;
|
||||
if (ssoError || isAddAccountMode || isAuthenticated) return;
|
||||
if (autoSsoTriggered.current) return;
|
||||
|
||||
// Guard against redirect loops
|
||||
try {
|
||||
if (sessionStorage.getItem("sso_attempted")) return;
|
||||
sessionStorage.setItem("sso_attempted", "1");
|
||||
// Clear the flag after 30 seconds so retries are possible
|
||||
setTimeout(() => { try { sessionStorage.removeItem("sso_attempted"); } catch { /* ignore */ } }, 30000);
|
||||
} catch { /* sessionStorage unavailable */ }
|
||||
|
||||
autoSsoTriggered.current = true;
|
||||
startServerSideSso();
|
||||
}, [autoSsoEnabled, oauthOnly, oauthDiscoveryDone, oauthMetadata, ssoError, isAddAccountMode, isAuthenticated, startServerSideSso]);
|
||||
|
||||
const handleThemeSelect = useCallback((newTheme: "light" | "dark" | "system") => {
|
||||
setTheme(newTheme);
|
||||
setShowThemeMenu(false);
|
||||
@@ -206,7 +264,7 @@ export default function LoginPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!serverUrl) {
|
||||
if (!serverUrl && !demoMode) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
|
||||
<div className="w-full max-w-md mx-auto px-4 text-center">
|
||||
@@ -353,9 +411,167 @@ export default function LoginPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDemoLogin = async () => {
|
||||
setDemoLoading(true);
|
||||
const success = await loginDemo();
|
||||
if (success) {
|
||||
router.push('/');
|
||||
}
|
||||
setDemoLoading(false);
|
||||
};
|
||||
|
||||
const currentThemeOption = THEME_OPTIONS.find(o => o.value === theme) || THEME_OPTIONS[2];
|
||||
const CurrentThemeIcon = currentThemeOption.icon;
|
||||
|
||||
// Demo-only mode: show only a large demo login button
|
||||
if (demoMode && !isAddAccountMode) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
|
||||
{/* Theme toggle */}
|
||||
<div className="absolute top-5 right-5" ref={themeMenuRef} suppressHydrationWarning>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowThemeMenu(!showThemeMenu)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-xl border text-sm transition-all duration-200",
|
||||
showThemeMenu
|
||||
? "bg-secondary border-border text-foreground shadow-md"
|
||||
: "bg-background/60 backdrop-blur-sm border-border/50 text-muted-foreground hover:text-foreground hover:bg-secondary/80 hover:border-border"
|
||||
)}
|
||||
aria-label={`Theme: ${currentThemeOption.label}`}
|
||||
aria-expanded={showThemeMenu}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
<CurrentThemeIcon className="w-4 h-4" />
|
||||
<span className="hidden sm:inline" suppressHydrationWarning>{currentThemeOption.label}</span>
|
||||
</button>
|
||||
|
||||
{showThemeMenu && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-2 w-40 rounded-xl border border-border bg-background shadow-lg overflow-hidden animate-fade-in z-50"
|
||||
role="listbox"
|
||||
aria-label="Theme selection"
|
||||
>
|
||||
{THEME_OPTIONS.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const isActive = theme === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onClick={() => handleThemeSelect(option.value)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3.5 py-2.5 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-primary/10 text-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="flex-1 text-left">{option.label}</span>
|
||||
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-[440px] mx-auto">
|
||||
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl shadow-black/5 dark:shadow-black/20 overflow-hidden">
|
||||
{/* Header with logo */}
|
||||
<div className="px-8 pt-12 pb-4 text-center">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 mb-6">
|
||||
<img
|
||||
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl}
|
||||
alt={appName}
|
||||
className="max-w-20 max-h-20 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-foreground tracking-tight">
|
||||
{appName}
|
||||
</h1>
|
||||
<p className="text-base text-muted-foreground mt-2 max-w-xs mx-auto leading-relaxed">
|
||||
{t("demo_tagline")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Large demo button */}
|
||||
<div className="px-8 pb-10 pt-4">
|
||||
{error && (
|
||||
<div className={cn(
|
||||
"mb-5 p-3.5 bg-red-500/10 border border-red-500/20 rounded-xl flex items-start gap-3",
|
||||
shakeError && "animate-shake"
|
||||
)}>
|
||||
<AlertCircle className="w-4.5 h-4.5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400 leading-relaxed">
|
||||
{t(`error.${error}`) || t("error.generic")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-14 font-semibold text-lg bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-lg shadow-primary/25 hover:shadow-xl hover:shadow-primary/30 hover:scale-[1.02] active:scale-[0.98]"
|
||||
onClick={handleDemoLogin}
|
||||
disabled={demoLoading || isLoading}
|
||||
>
|
||||
{demoLoading ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
{t("demo_launching")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<Play className="w-5 h-5" />
|
||||
{t("demo_login_button")}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground mt-4 leading-relaxed">
|
||||
{t("demo_no_signup")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 flex flex-col items-center gap-2">
|
||||
{loginCompanyName && (
|
||||
<p className="text-center text-xs text-muted-foreground/60 font-medium">
|
||||
{loginCompanyName}
|
||||
</p>
|
||||
)}
|
||||
{(loginImprintUrl || loginPrivacyPolicyUrl || loginWebsiteUrl) && (
|
||||
<div className="flex items-center gap-3 flex-wrap justify-center">
|
||||
{loginWebsiteUrl && (
|
||||
<a href={loginWebsiteUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors">
|
||||
{t("website")}
|
||||
</a>
|
||||
)}
|
||||
{loginImprintUrl && (
|
||||
<a href={loginImprintUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors">
|
||||
{t("imprint")}
|
||||
</a>
|
||||
)}
|
||||
{loginPrivacyPolicyUrl && (
|
||||
<a href={loginPrivacyPolicyUrl} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground/50 hover:text-muted-foreground transition-colors">
|
||||
{t("privacy_policy")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-center text-xs text-muted-foreground/40">
|
||||
v{APP_VERSION}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
|
||||
{/* Theme toggle - top right, dropdown style */}
|
||||
@@ -747,6 +963,34 @@ export default function LoginPage() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Demo Mode Button */}
|
||||
{demoMode && !isAddAccountMode && (
|
||||
<div className="mt-4 pt-4 border-t border-border/40">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 font-medium text-[15px] rounded-xl border-border/60 hover:bg-muted/50"
|
||||
onClick={handleDemoLogin}
|
||||
disabled={demoLoading || isLoading}
|
||||
>
|
||||
{demoLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t("demo_launching")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Play className="w-4 h-4" />
|
||||
{t("try_demo")}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-center text-xs text-muted-foreground mt-2">
|
||||
{t("demo_description")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+7
-13
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef, useMemo, useCallback } from "react";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { EmailList } from "@/components/email/email-list";
|
||||
@@ -13,7 +12,7 @@ import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-hea
|
||||
import { ThreadGroup, Email } from "@/lib/jmap/types";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
@@ -48,7 +47,6 @@ import { Button } from "@/components/ui/button";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const tCommon = useTranslations('common');
|
||||
const { appName } = useConfig();
|
||||
@@ -285,9 +283,9 @@ export default function Home() {
|
||||
useEffect(() => {
|
||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||
router.push('/login');
|
||||
redirectToLogin();
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading, router]);
|
||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||
|
||||
// Load mailboxes and emails when authenticated (only if not already loaded)
|
||||
useEffect(() => {
|
||||
@@ -768,12 +766,7 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
if (!useAuthStore.getState().isAuthenticated) {
|
||||
router.push('/login');
|
||||
}
|
||||
};
|
||||
const handleLogout = logout;
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
if (!client) return;
|
||||
@@ -1174,6 +1167,7 @@ export default function Home() {
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
||||
data-search-input
|
||||
data-tour="search-input"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
@@ -1579,8 +1573,8 @@ export default function Home() {
|
||||
onNavigatePrev={handleNavigatePrev}
|
||||
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||
onEditDraft={handleEditDraft}
|
||||
currentUserEmail={client?.["username"]}
|
||||
currentUserName={client?.["username"]?.split("@")[0]}
|
||||
currentUserEmail={client?.getUsername()}
|
||||
currentUserName={client?.getUsername()?.split("@")[0]}
|
||||
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox={selectedMailbox}
|
||||
|
||||
@@ -44,7 +44,7 @@ import { FilesSettingsComponent } from '@/components/settings/files-settings';
|
||||
import { ContactsSettings } from '@/components/settings/contacts-settings';
|
||||
import { SmimeSettings } from '@/components/settings/smime-settings';
|
||||
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useIsDesktop } from '@/hooks/use-media-query';
|
||||
import { NavigationRail } from '@/components/layout/navigation-rail';
|
||||
@@ -122,9 +122,9 @@ export default function SettingsPage() {
|
||||
useEffect(() => {
|
||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||
router.push('/login');
|
||||
redirectToLogin();
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading, router]);
|
||||
}, [initialCheckDone, isAuthenticated, authLoading]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
@@ -286,7 +286,7 @@ export default function SettingsPage() {
|
||||
{/* Logout */}
|
||||
<div className="border-t border-border px-5 py-3">
|
||||
<button
|
||||
onClick={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
|
||||
onClick={logout}
|
||||
className="w-full flex items-center gap-3 py-2.5 text-sm text-destructive hover:bg-muted rounded-md px-2 transition-colors duration-150"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
@@ -317,7 +317,7 @@ export default function SettingsPage() {
|
||||
collapsed
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
|
||||
onLogout={logout}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
@@ -352,7 +352,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
<div className="flex-1 overflow-y-auto py-2" data-tour="settings-tabs">
|
||||
<div className="px-2 space-y-0.5">
|
||||
{groupedTabs.map((group, groupIndex) => (
|
||||
<div key={group.group}>
|
||||
|
||||
@@ -3,12 +3,10 @@ import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
|
||||
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
|
||||
const COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
...getCookieOptions(),
|
||||
maxAge: SESSION_COOKIE_MAX_AGE,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { decryptPayload } from '@/lib/auth/crypto';
|
||||
import { exchangeCodeForTokens } from '@/lib/oauth/token-exchange';
|
||||
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
|
||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||
const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
try {
|
||||
const { code, state } = await request.json();
|
||||
|
||||
if (!code || !state) {
|
||||
return NextResponse.json({ error: 'Missing code or state' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Read and decrypt the pending SSO cookie
|
||||
const pendingCookie = cookieStore.get(SSO_PENDING_COOKIE)?.value;
|
||||
if (!pendingCookie) {
|
||||
logger.warn('SSO complete: no pending cookie found');
|
||||
return NextResponse.json({ error: 'No pending SSO session. Please start the login flow again.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const pending = decryptPayload(pendingCookie);
|
||||
if (!pending) {
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
return NextResponse.json({ error: 'Invalid SSO session' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate state
|
||||
if (pending.state !== state) {
|
||||
logger.warn('SSO complete: state mismatch');
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
return NextResponse.json({ error: 'State mismatch' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate TTL
|
||||
const createdAt = pending.created_at as number;
|
||||
if (!createdAt || Date.now() - createdAt > SSO_PENDING_MAX_AGE_MS) {
|
||||
logger.warn('SSO complete: pending session expired');
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
return NextResponse.json({ error: 'SSO session expired. Please try again.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const codeVerifier = pending.code_verifier as string;
|
||||
const redirectUri = pending.redirect_uri as string;
|
||||
|
||||
if (!codeVerifier || !redirectUri) {
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
return NextResponse.json({ error: 'Invalid SSO session data' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri);
|
||||
|
||||
// Store refresh token
|
||||
if (tokens.refresh_token) {
|
||||
const cookieName = refreshTokenCookieName(0);
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
}
|
||||
|
||||
// Delete pending cookie
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
|
||||
return NextResponse.json({
|
||||
access_token: tokens.access_token,
|
||||
expires_in: tokens.expires_in,
|
||||
});
|
||||
} catch (error) {
|
||||
// Clean up pending cookie on any error
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
logger.error('SSO complete error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Token exchange failed' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { encryptPayload } from '@/lib/auth/crypto';
|
||||
import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server';
|
||||
import { getRequiredConfig } from '@/lib/oauth/token-exchange';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
|
||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
if (!process.env.SESSION_SECRET) {
|
||||
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
|
||||
}
|
||||
|
||||
const { redirect_uri, locale } = await request.json();
|
||||
|
||||
if (!redirect_uri || typeof redirect_uri !== 'string') {
|
||||
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate redirect_uri origin matches the request origin to prevent open redirects
|
||||
const requestOrigin = request.headers.get('origin') || request.nextUrl.origin;
|
||||
try {
|
||||
const redirectOrigin = new URL(redirect_uri).origin;
|
||||
if (redirectOrigin !== requestOrigin) {
|
||||
logger.warn('SSO start: redirect_uri origin mismatch', { redirectOrigin, requestOrigin });
|
||||
return NextResponse.json({ error: 'Invalid redirect_uri' }, { status: 400 });
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid redirect_uri' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { clientId, discoveryUrl } = getRequiredConfig();
|
||||
const metadata = await discoverOAuth(discoveryUrl);
|
||||
|
||||
if (!metadata?.authorization_endpoint) {
|
||||
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
|
||||
}
|
||||
|
||||
// Generate PKCE + state server-side
|
||||
const codeVerifier = generateCodeVerifierServer();
|
||||
const codeChallenge = generateCodeChallengeServer(codeVerifier);
|
||||
const state = generateStateServer();
|
||||
|
||||
// Encrypt and store in httpOnly cookie
|
||||
const pendingData = {
|
||||
state,
|
||||
code_verifier: codeVerifier,
|
||||
redirect_uri,
|
||||
created_at: Date.now(),
|
||||
};
|
||||
|
||||
const encrypted = encryptPayload(pendingData);
|
||||
const cookieStore = await cookies();
|
||||
const baseCookieOpts = getCookieOptions();
|
||||
cookieStore.set(SSO_PENDING_COOKIE, encrypted, {
|
||||
...baseCookieOpts,
|
||||
maxAge: SSO_PENDING_MAX_AGE,
|
||||
});
|
||||
|
||||
// Build authorize URL
|
||||
const authUrl = new URL(metadata.authorization_endpoint);
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('client_id', clientId);
|
||||
authUrl.searchParams.set('redirect_uri', redirect_uri);
|
||||
authUrl.searchParams.set('scope', OAUTH_SCOPES);
|
||||
authUrl.searchParams.set('state', state);
|
||||
authUrl.searchParams.set('code_challenge', codeChallenge);
|
||||
authUrl.searchParams.set('code_challenge_method', 'S256');
|
||||
|
||||
if (locale) {
|
||||
authUrl.searchParams.set('ui_locales', locale);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
authorize_url: authUrl.toString(),
|
||||
state,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('SSO start error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||
|
||||
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || '';
|
||||
|
||||
const COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
maxAge: 30 * 24 * 60 * 60,
|
||||
};
|
||||
import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
|
||||
function getSlot(request: NextRequest): number {
|
||||
const raw = request.nextUrl.searchParams.get('slot');
|
||||
@@ -22,44 +13,6 @@ function getSlot(request: NextRequest): number {
|
||||
return slot;
|
||||
}
|
||||
|
||||
|
||||
function getRequiredConfig() {
|
||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
||||
const serverUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
|
||||
const issuerUrl = process.env.OAUTH_ISSUER_URL;
|
||||
if (!clientId || !serverUrl) {
|
||||
throw new Error(`OAuth misconfigured: ${[!clientId && 'OAUTH_CLIENT_ID', !serverUrl && 'JMAP_SERVER_URL'].filter(Boolean).join(', ')} not set`);
|
||||
}
|
||||
const discoveryUrl = issuerUrl?.trim() || serverUrl;
|
||||
if (issuerUrl !== undefined && !issuerUrl.trim()) {
|
||||
logger.warn('OAUTH_ISSUER_URL is set but empty, falling back to JMAP_SERVER_URL for discovery');
|
||||
}
|
||||
return { clientId, serverUrl, discoveryUrl };
|
||||
}
|
||||
|
||||
async function getTokenEndpoint(): Promise<string> {
|
||||
const { discoveryUrl } = getRequiredConfig();
|
||||
const metadata = await discoverOAuth(discoveryUrl);
|
||||
if (!metadata?.token_endpoint) {
|
||||
throw new Error('OAuth token endpoint not found');
|
||||
}
|
||||
return metadata.token_endpoint;
|
||||
}
|
||||
|
||||
async function getMetadata(): Promise<import('@/lib/oauth/discovery').OAuthMetadata | null> {
|
||||
const { discoveryUrl } = getRequiredConfig();
|
||||
return discoverOAuth(discoveryUrl);
|
||||
}
|
||||
|
||||
function buildOAuthParams(base: Record<string, string>): URLSearchParams {
|
||||
const { clientId } = getRequiredConfig();
|
||||
const params = new URLSearchParams({ ...base, client_id: clientId });
|
||||
if (CLIENT_SECRET) {
|
||||
params.set('client_secret', CLIENT_SECRET);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { code, code_verifier, redirect_uri, slot: bodySlot } = await request.json();
|
||||
@@ -69,43 +22,18 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||
const tokenEndpoint = await getTokenEndpoint();
|
||||
|
||||
const params = buildOAuthParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri,
|
||||
code_verifier,
|
||||
});
|
||||
|
||||
const tokenResponse = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
logger.error('Token exchange failed', { status: tokenResponse.status, error: errorText });
|
||||
return NextResponse.json({ error: 'Token exchange failed' }, { status: 401 });
|
||||
}
|
||||
|
||||
const tokens = await tokenResponse.json();
|
||||
|
||||
if (!tokens.access_token) {
|
||||
logger.error('Token response missing access_token', { response: JSON.stringify(tokens).substring(0, 500) });
|
||||
return NextResponse.json({ error: 'Invalid token response' }, { status: 502 });
|
||||
}
|
||||
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri);
|
||||
|
||||
const response = NextResponse.json({
|
||||
access_token: tokens.access_token,
|
||||
expires_in: tokens.expires_in || 3600,
|
||||
expires_in: tokens.expires_in,
|
||||
});
|
||||
|
||||
if (tokens.refresh_token) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(cookieName, tokens.refresh_token, COOKIE_OPTIONS);
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
}
|
||||
|
||||
return response;
|
||||
@@ -154,7 +82,7 @@ export async function PUT(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (tokens.refresh_token) {
|
||||
cookieStore.set(cookieName, tokens.refresh_token, COOKIE_OPTIONS);
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -35,5 +35,9 @@ export async function GET() {
|
||||
loginImprintUrl: process.env.LOGIN_IMPRINT_URL || '',
|
||||
loginPrivacyPolicyUrl: process.env.LOGIN_PRIVACY_POLICY_URL || '',
|
||||
loginWebsiteUrl: process.env.LOGIN_WEBSITE_URL || '',
|
||||
demoMode: process.env.DEMO_MODE === 'true',
|
||||
autoSsoEnabled: process.env.AUTO_SSO_ENABLED === 'true',
|
||||
embeddedMode: !!process.env.ALLOWED_FRAME_ANCESTORS && process.env.ALLOWED_FRAME_ANCESTORS !== "'none'",
|
||||
parentOrigin: process.env.NEXT_PUBLIC_PARENT_ORIGIN || '',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -496,3 +496,95 @@ body {
|
||||
-webkit-backdrop-filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* TipTap Rich Text Editor */
|
||||
.tiptap {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tiptap p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.tiptap h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.tiptap h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.tiptap ul {
|
||||
list-style-type: disc;
|
||||
padding-left: 1.5rem;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.tiptap ol {
|
||||
list-style-type: decimal;
|
||||
padding-left: 1.5rem;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.tiptap li {
|
||||
margin: 0.125rem 0;
|
||||
}
|
||||
|
||||
.tiptap blockquote {
|
||||
border-left: 3px solid var(--color-border);
|
||||
padding-left: 1rem;
|
||||
margin: 0.5rem 0;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.tiptap pre {
|
||||
background-color: var(--color-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
overflow-x: auto;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.tiptap code {
|
||||
background-color: var(--color-muted);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.tiptap a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tiptap img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.tiptap hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.tiptap p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: var(--color-muted-foreground);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.tiptap .ProseMirror-selectednode img {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -31,10 +31,14 @@ export default async function RootLayout({
|
||||
}) {
|
||||
const locale = await getLocale();
|
||||
const nonce = (await headers()).get("x-nonce") ?? "";
|
||||
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
|
||||
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<head>
|
||||
{parentOrigin && (
|
||||
<meta name="parent-origin" content={parentOrigin} />
|
||||
)}
|
||||
<script
|
||||
nonce={nonce}
|
||||
suppressHydrationWarning
|
||||
|
||||
@@ -4,10 +4,11 @@ import { useMemo, useEffect, useRef, useState } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { format, isSameDay, isToday, parseISO } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check } from "lucide-react";
|
||||
import { EventCard, parseDuration } from "./event-card";
|
||||
import { QuickEventInput } from "./quick-event-input";
|
||||
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
|
||||
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||
import type { PendingEventPreview } from "./event-modal";
|
||||
|
||||
@@ -22,6 +23,8 @@ interface CalendarDayViewProps {
|
||||
timeFormat?: "12h" | "24h";
|
||||
isMobile?: boolean;
|
||||
pendingPreview?: PendingEventPreview | null;
|
||||
tasks?: CalendarTask[];
|
||||
onToggleTaskComplete?: (task: CalendarTask) => void;
|
||||
}
|
||||
|
||||
const HOUR_HEIGHT = 64;
|
||||
@@ -38,6 +41,8 @@ export function CalendarDayView({
|
||||
timeFormat = "24h",
|
||||
isMobile,
|
||||
pendingPreview,
|
||||
tasks,
|
||||
onToggleTaskComplete,
|
||||
}: CalendarDayViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const intlFormatter = useFormatter();
|
||||
@@ -68,6 +73,16 @@ export function CalendarDayView({
|
||||
return { timedEvents: timed, allDayEvents: allDay };
|
||||
}, [events, selectedDate]);
|
||||
|
||||
const dayTasks = useMemo(() => {
|
||||
if (!tasks?.length) return [];
|
||||
return tasks.filter(task => {
|
||||
if (!task.due) return false;
|
||||
try {
|
||||
return isSameDay(parseISO(task.due), selectedDate);
|
||||
} catch { return false; }
|
||||
});
|
||||
}, [tasks, selectedDate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
const now = new Date();
|
||||
@@ -125,8 +140,10 @@ export function CalendarDayView({
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{allDayEvents.length > 0 && (
|
||||
{(allDayEvents.length > 0 || dayTasks.length > 0) && (
|
||||
<div className="px-4 py-2 border-b border-border">
|
||||
{allDayEvents.length > 0 && (
|
||||
<>
|
||||
<div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div>
|
||||
<div className="space-y-1">
|
||||
{allDayEvents.map((ev) => {
|
||||
@@ -144,6 +161,42 @@ export function CalendarDayView({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{dayTasks.length > 0 && (
|
||||
<>
|
||||
<div className={cn("text-[10px] text-muted-foreground mb-1", allDayEvents.length > 0 && "mt-2")}>{t("tasks.label")}</div>
|
||||
<div className="space-y-0.5">
|
||||
{dayTasks.map((task) => {
|
||||
const isCompleted = task.progress === "completed";
|
||||
const cal = calendars.find(c => task.calendarIds[c.id]);
|
||||
const color = cal?.color || "#3b82f6";
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center gap-1.5 px-1.5 py-0.5 rounded text-xs cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
style={{ borderLeft: `3px solid ${color}` }}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onToggleTaskComplete?.(task); }}
|
||||
className={cn(
|
||||
"flex-shrink-0 w-3.5 h-3.5 rounded-full border flex items-center justify-center",
|
||||
isCompleted
|
||||
? "bg-green-500 border-green-500 text-white"
|
||||
: "border-muted-foreground/40 hover:border-primary"
|
||||
)}
|
||||
>
|
||||
{isCompleted && <Check className="h-2.5 w-2.5" />}
|
||||
</button>
|
||||
<span className={cn("truncate", isCompleted && "line-through text-muted-foreground")}>
|
||||
{task.title || t("tasks.no_title")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Globe, Plus, RefreshCw, Share2, Trash2 } from "lucide-react";
|
||||
import { Globe, ListTodo, Plus, RefreshCw, Share2, Trash2 } from "lucide-react";
|
||||
import { cn, formatDateTime } from "@/lib/utils";
|
||||
import type { Calendar } from "@/lib/jmap/types";
|
||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useTaskStore } from "@/stores/task-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import type { JMAPClient } from "@/lib/jmap/client";
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
interface CalendarSidebarPanelProps {
|
||||
calendars: Calendar[];
|
||||
@@ -17,7 +18,7 @@ interface CalendarSidebarPanelProps {
|
||||
onToggleVisibility: (id: string) => void;
|
||||
onColorChange?: (calendarId: string, color: string) => void;
|
||||
onSubscribe?: () => void;
|
||||
client?: JMAPClient | null;
|
||||
client?: IJMAPClient | null;
|
||||
}
|
||||
|
||||
export function CalendarSidebarPanel({
|
||||
@@ -35,6 +36,15 @@ export function CalendarSidebarPanel({
|
||||
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
||||
const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks);
|
||||
const tasks = useTaskStore((s) => s.tasks);
|
||||
const setViewMode = useCalendarStore((s) => s.setViewMode);
|
||||
|
||||
const pendingTaskCount = useMemo(() => tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled').length, [tasks]);
|
||||
const overdueTaskCount = useMemo(() => {
|
||||
const now = new Date();
|
||||
return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length;
|
||||
}, [tasks]);
|
||||
|
||||
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
||||
const [contextMenuCalId, setContextMenuCalId] = useState<string | null>(null);
|
||||
@@ -209,6 +219,21 @@ export function CalendarSidebarPanel({
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
{enableCalendarTasks && (
|
||||
<button
|
||||
onClick={() => setViewMode('tasks')}
|
||||
className="flex items-center gap-2 w-full px-1.5 py-1.5 mb-3 rounded-md text-sm hover:bg-muted transition-colors"
|
||||
>
|
||||
<ListTodo className="w-4 h-4 text-muted-foreground" />
|
||||
<span>{t('tasks.label')}</span>
|
||||
{pendingTaskCount > 0 && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">{pendingTaskCount}</span>
|
||||
)}
|
||||
{overdueTaskCount > 0 && (
|
||||
<span className="text-xs text-destructive font-medium">{overdueTaskCount} {t('tasks.filter_overdue').toLowerCase()}</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
||||
{t("my_calendars")}
|
||||
</h3>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ListTodo } from "lucide-react";
|
||||
import { addDays, startOfWeek } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarViewMode } from "@/stores/calendar-store";
|
||||
@@ -25,6 +25,7 @@ interface CalendarToolbarProps {
|
||||
calendars?: Calendar[];
|
||||
selectedCalendarIds?: string[];
|
||||
onToggleVisibility?: (id: string) => void;
|
||||
enableCalendarTasks?: boolean;
|
||||
}
|
||||
|
||||
export function CalendarToolbar({
|
||||
@@ -42,10 +43,13 @@ export function CalendarToolbar({
|
||||
calendars,
|
||||
selectedCalendarIds,
|
||||
onToggleVisibility,
|
||||
enableCalendarTasks,
|
||||
}: CalendarToolbarProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const formatter = useFormatter();
|
||||
const views: CalendarViewMode[] = ["month", "week", "day", "agenda"];
|
||||
const views: CalendarViewMode[] = enableCalendarTasks
|
||||
? ["month", "week", "day", "agenda", "tasks"]
|
||||
: ["month", "week", "day", "agenda"];
|
||||
const [showCalendarDropdown, setShowCalendarDropdown] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -86,6 +90,8 @@ export function CalendarToolbar({
|
||||
return isMobile
|
||||
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
|
||||
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
|
||||
case "tasks":
|
||||
return t("views.tasks");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -326,7 +332,7 @@ export function CalendarToolbar({
|
||||
)}
|
||||
|
||||
{!isMobile && (
|
||||
<Button size="sm" onClick={onCreateEvent}>
|
||||
<Button size="sm" onClick={onCreateEvent} data-tour="create-event-button">
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{t("events.create")}
|
||||
</Button>
|
||||
|
||||
@@ -6,10 +6,11 @@ import {
|
||||
startOfWeek, addDays, format, isSameDay, isToday, parseISO,
|
||||
} from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check } from "lucide-react";
|
||||
import { EventCard, parseDuration } from "./event-card";
|
||||
import { QuickEventInput } from "./quick-event-input";
|
||||
import { buildWeekSegments, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
|
||||
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||
import type { PendingEventPreview } from "./event-modal";
|
||||
|
||||
@@ -26,6 +27,8 @@ interface CalendarWeekViewProps {
|
||||
timeFormat?: "12h" | "24h";
|
||||
isMobile?: boolean;
|
||||
pendingPreview?: PendingEventPreview | null;
|
||||
tasks?: CalendarTask[];
|
||||
onToggleTaskComplete?: (task: CalendarTask) => void;
|
||||
}
|
||||
|
||||
const HOUR_HEIGHT = 60;
|
||||
@@ -44,6 +47,8 @@ export function CalendarWeekView({
|
||||
timeFormat = "24h",
|
||||
isMobile,
|
||||
pendingPreview,
|
||||
tasks,
|
||||
onToggleTaskComplete,
|
||||
}: CalendarWeekViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const intlFormatter = useFormatter();
|
||||
@@ -96,9 +101,36 @@ export function CalendarWeekView({
|
||||
return allDaySegments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
|
||||
}, [allDaySegments]);
|
||||
|
||||
// Tasks grouped by day for the week
|
||||
const tasksByDay = useMemo(() => {
|
||||
if (!tasks?.length) return new Map<string, CalendarTask[]>();
|
||||
const map = new Map<string, CalendarTask[]>();
|
||||
for (const task of tasks) {
|
||||
if (!task.due) continue;
|
||||
try {
|
||||
const key = format(parseISO(task.due), "yyyy-MM-dd");
|
||||
const existing = map.get(key) || [];
|
||||
existing.push(task);
|
||||
map.set(key, existing);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
return map;
|
||||
}, [tasks]);
|
||||
|
||||
// Max tasks on any single day in this week
|
||||
const taskRowCount = useMemo(() => {
|
||||
let max = 0;
|
||||
for (const day of weekDays) {
|
||||
const key = format(day, "yyyy-MM-dd");
|
||||
const count = tasksByDay.get(key)?.length ?? 0;
|
||||
if (count > max) max = count;
|
||||
}
|
||||
return max;
|
||||
}, [tasksByDay, weekDays]);
|
||||
|
||||
const hasAllDay = useMemo(() => {
|
||||
return allDaySegments.length > 0;
|
||||
}, [allDaySegments]);
|
||||
return allDaySegments.length > 0 || taskRowCount > 0;
|
||||
}, [allDaySegments, taskRowCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
@@ -151,13 +183,13 @@ export function CalendarWeekView({
|
||||
<div className="flex border-b border-border">
|
||||
<div
|
||||
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10" : "w-14")}
|
||||
style={{ minHeight: Math.max(28, allDayRowCount * 24 + 4) }}
|
||||
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
|
||||
>
|
||||
{t("events.all_day")}
|
||||
</div>
|
||||
<div
|
||||
className={cn("flex-1 relative grid gap-px bg-border", isMobile ? "grid-cols-3" : "grid-cols-7")}
|
||||
style={{ minHeight: Math.max(28, allDayRowCount * 24 + 4) }}
|
||||
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
|
||||
>
|
||||
{weekDays.map((day) => (
|
||||
<div key={format(day, "yyyy-MM-dd")} className="bg-background min-h-[28px]" />
|
||||
@@ -191,6 +223,49 @@ export function CalendarWeekView({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Task chips in all-day area */}
|
||||
{taskRowCount > 0 && (
|
||||
<div className="absolute inset-x-0 pointer-events-none" style={{ top: allDayRowCount * 24 + 2 }}>
|
||||
{weekDays.map((day, dayIndex) => {
|
||||
const key = format(day, "yyyy-MM-dd");
|
||||
const dayTasks = tasksByDay.get(key) || [];
|
||||
return dayTasks.map((task, taskIndex) => {
|
||||
const isCompleted = task.progress === "completed";
|
||||
const cal = calendars.find(c => task.calendarIds[c.id]);
|
||||
const color = cal?.color || "#3b82f6";
|
||||
return (
|
||||
<div
|
||||
key={`task-${task.id}`}
|
||||
className="absolute px-0.5 pointer-events-auto"
|
||||
style={{
|
||||
left: `calc(${(dayIndex / colCount) * 100}% + 1px)`,
|
||||
width: `calc(${(1 / colCount) * 100}% - 2px)`,
|
||||
top: taskIndex * 24,
|
||||
height: 20,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded text-[10px] leading-[20px] font-medium px-1.5 truncate flex items-center gap-1 cursor-pointer hover:opacity-80"
|
||||
style={{ backgroundColor: `${color}20`, borderLeft: `3px solid ${color}` }}
|
||||
onClick={() => onToggleTaskComplete?.(task)}
|
||||
>
|
||||
<span className={cn(
|
||||
"w-2.5 h-2.5 rounded-full border flex-shrink-0 flex items-center justify-center",
|
||||
isCompleted ? "bg-green-500 border-green-500" : "border-current"
|
||||
)}>
|
||||
{isCompleted && <Check className="h-2 w-2 text-white" />}
|
||||
</span>
|
||||
<span className={cn("truncate", isCompleted && "line-through text-muted-foreground")}>
|
||||
{task.title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -192,6 +192,10 @@ export function EventDetailPopover({
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
const target = e.target as HTMLElement;
|
||||
const tag = target?.tagName?.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "select") return;
|
||||
if (target?.getAttribute("contenteditable") === "true") return;
|
||||
if (e.key === "e" && !noteExpanded) {
|
||||
e.preventDefault();
|
||||
onEdit();
|
||||
|
||||
@@ -729,7 +729,7 @@ export function EventModal({
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
|
||||
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} data-tour="event-modal" className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{isEdit ? t("events.edit") : t("events.create")}
|
||||
|
||||
@@ -6,14 +6,14 @@ import { Button } from "@/components/ui/button";
|
||||
import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import type { JMAPClient } from "@/lib/jmap/client";
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
interface ICalImportModalProps {
|
||||
calendars: Calendar[];
|
||||
client: JMAPClient;
|
||||
client: IJMAPClient;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@ import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, Loader2, Globe } from "lucide-react";
|
||||
import type { JMAPClient } from "@/lib/jmap/client";
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
interface ICalSubscriptionModalProps {
|
||||
client: JMAPClient;
|
||||
client: IJMAPClient;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, Fragment } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react";
|
||||
import {
|
||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||
addMonths, subMonths, addYears, subYears, setMonth, setYear,
|
||||
eachDayOfInterval, getMonth, getYear,
|
||||
eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek,
|
||||
isSameDay, isSameMonth, isToday, format,
|
||||
} from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -26,6 +26,7 @@ interface MiniCalendarProps {
|
||||
onChangeMonth: (date: Date) => void;
|
||||
events?: CalendarEvent[];
|
||||
firstDayOfWeek?: number;
|
||||
showWeekNumbers?: boolean;
|
||||
}
|
||||
|
||||
export function MiniCalendar({
|
||||
@@ -35,6 +36,7 @@ export function MiniCalendar({
|
||||
onChangeMonth,
|
||||
events = [],
|
||||
firstDayOfWeek = 1,
|
||||
showWeekNumbers = false,
|
||||
}: MiniCalendarProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const intlFormatter = useFormatter();
|
||||
@@ -61,6 +63,17 @@ export function MiniCalendar({
|
||||
? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const
|
||||
: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
|
||||
|
||||
// Compute week numbers for each row (one per 7-day chunk)
|
||||
const weekNumbers = useMemo(() => {
|
||||
if (!showWeekNumbers) return [];
|
||||
const nums: number[] = [];
|
||||
for (let i = 0; i < days.length; i += 7) {
|
||||
// Use the first day of each row to determine the week number
|
||||
nums.push(weekStart === 1 ? getISOWeek(days[i]) : getWeek(days[i], { weekStartsOn: 0 }));
|
||||
}
|
||||
return nums;
|
||||
}, [days, showWeekNumbers, weekStart]);
|
||||
|
||||
const currentYear = getYear(displayMonth);
|
||||
const currentMonth = getMonth(displayMonth);
|
||||
const decadeStart = Math.floor(currentYear / 10) * 10;
|
||||
@@ -135,21 +148,34 @@ export function MiniCalendar({
|
||||
</div>
|
||||
|
||||
{pickerView === "days" && (
|
||||
<div className="grid grid-cols-7 gap-0">
|
||||
<div className={cn("grid gap-0", showWeekNumbers ? "grid-cols-[auto_repeat(7,1fr)]" : "grid-cols-7")}>
|
||||
{showWeekNumbers && (
|
||||
<div className="text-center text-[10px] font-medium text-muted-foreground py-1 w-5" />
|
||||
)}
|
||||
{dayHeaders.map((d) => (
|
||||
<div key={d} className="text-center text-[10px] font-medium text-muted-foreground py-1">
|
||||
{t(`days.${d}`)}
|
||||
</div>
|
||||
))}
|
||||
{days.map((day) => {
|
||||
{days.map((day, index) => {
|
||||
const inMonth = isSameMonth(day, displayMonth);
|
||||
const selected = isSameDay(day, selectedDate);
|
||||
const today = isToday(day);
|
||||
const hasEvent = eventDates.has(format(day, "yyyy-MM-dd"));
|
||||
const isFirstDayOfRow = index % 7 === 0;
|
||||
|
||||
return (
|
||||
<Fragment key={day.toISOString()}>
|
||||
{showWeekNumbers && isFirstDayOfRow && (
|
||||
<div
|
||||
key={`wk-${index}`}
|
||||
className="flex items-center justify-center w-5 text-[9px] text-muted-foreground/60 font-medium"
|
||||
>
|
||||
{weekNumbers[index / 7]}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
key={day.toISOString()}
|
||||
key={`day-${day.toISOString()}`}
|
||||
onClick={() => onSelectDate(day)}
|
||||
className={cn(
|
||||
"relative flex items-center justify-center w-7 h-7 text-xs rounded-full transition-colors",
|
||||
@@ -164,6 +190,7 @@ export function MiniCalendar({
|
||||
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { useMemo, useCallback, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { format, parseISO, isPast, isToday, isTomorrow } from "date-fns";
|
||||
import { Check, Circle, Flag, CalendarDays, ListTodo } from "lucide-react";
|
||||
import { Check, Circle, Flag, CalendarDays, ListTodo, Plus } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarTask, Calendar } from "@/lib/jmap/types";
|
||||
import type { TaskViewFilter } from "@/stores/task-store";
|
||||
@@ -18,6 +18,7 @@ interface TaskListViewProps {
|
||||
onSelectTask: (task: CalendarTask) => void;
|
||||
onToggleComplete: (task: CalendarTask) => void;
|
||||
selectedTaskId?: string | null;
|
||||
onQuickCreate?: (title: string) => void;
|
||||
}
|
||||
|
||||
function getTaskPriorityIcon(priority: number) {
|
||||
@@ -69,9 +70,11 @@ export function TaskListView({
|
||||
onSelectTask,
|
||||
onToggleComplete,
|
||||
selectedTaskId,
|
||||
onQuickCreate,
|
||||
}: TaskListViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const [quickAddTitle, setQuickAddTitle] = useState("");
|
||||
|
||||
const filteredTasks = useMemo(() => {
|
||||
let result = tasks.filter(task => {
|
||||
@@ -128,15 +131,57 @@ export function TaskListView({
|
||||
|
||||
if (filteredTasks.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col flex-1">
|
||||
{onQuickCreate && (
|
||||
<div className="px-4 py-2 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={quickAddTitle}
|
||||
onChange={(e) => setQuickAddTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && quickAddTitle.trim()) {
|
||||
onQuickCreate(quickAddTitle.trim());
|
||||
setQuickAddTitle("");
|
||||
}
|
||||
}}
|
||||
placeholder={t("tasks.quick_add_placeholder")}
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col items-center justify-center flex-1 text-muted-foreground py-12">
|
||||
<ListTodo className="h-12 w-12 mb-3 opacity-30" />
|
||||
<p className="text-sm">{t("tasks.no_tasks")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{onQuickCreate && (
|
||||
<div className="px-4 py-2 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={quickAddTitle}
|
||||
onChange={(e) => setQuickAddTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && quickAddTitle.trim()) {
|
||||
onQuickCreate(quickAddTitle.trim());
|
||||
setQuickAddTitle("");
|
||||
}
|
||||
}}
|
||||
placeholder={t("tasks.quick_add_placeholder")}
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="divide-y divide-border">
|
||||
{filteredTasks.map(task => {
|
||||
const cal = calendars.find(c => task.calendarIds[c.id]);
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Trash2, CalendarDays, Bell, Flag } from "lucide-react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarTask, Calendar, CalendarEventAlert } from "@/lib/jmap/types";
|
||||
|
||||
interface TaskModalProps {
|
||||
task?: CalendarTask | null;
|
||||
calendars: Calendar[];
|
||||
onSave: (data: Partial<CalendarTask>) => void | Promise<void>;
|
||||
onDelete?: (id: string) => void;
|
||||
onClose: () => void;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
type PriorityLevel = "none" | "high" | "medium" | "low";
|
||||
type AlertOption = "none" | "at_time" | "5" | "15" | "30" | "60" | "1440";
|
||||
|
||||
function priorityToLevel(p: number): PriorityLevel {
|
||||
if (p >= 1 && p <= 4) return "high";
|
||||
if (p === 5) return "medium";
|
||||
if (p >= 6 && p <= 9) return "low";
|
||||
return "none";
|
||||
}
|
||||
|
||||
function levelToPriority(l: PriorityLevel): number {
|
||||
switch (l) {
|
||||
case "high": return 1;
|
||||
case "medium": return 5;
|
||||
case "low": return 9;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function TaskModal({
|
||||
task,
|
||||
calendars,
|
||||
onSave,
|
||||
onDelete,
|
||||
onClose,
|
||||
isMobile,
|
||||
}: TaskModalProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const isEdit = !!task;
|
||||
const titleRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const writableCalendars = calendars.filter(c => !c.isShared || c.myRights?.mayWriteAll || c.myRights?.mayWriteOwn);
|
||||
const defaultCalendarId = writableCalendars[0]?.id ?? calendars[0]?.id ?? "";
|
||||
|
||||
const [title, setTitle] = useState(task?.title ?? "");
|
||||
const [description, setDescription] = useState(task?.description ?? "");
|
||||
const [dueDate, setDueDate] = useState(task?.due ? format(parseISO(task.due), "yyyy-MM-dd") : "");
|
||||
const [dueTime, setDueTime] = useState(task?.due && !task.showWithoutTime ? format(parseISO(task.due), "HH:mm") : "");
|
||||
const [showTime, setShowTime] = useState(task?.due ? !task.showWithoutTime : false);
|
||||
const [priority, setPriority] = useState<PriorityLevel>(priorityToLevel(task?.priority ?? 0));
|
||||
const [progress, setProgress] = useState<CalendarTask["progress"]>(task?.progress ?? "needs-action");
|
||||
const [calendarId, setCalendarId] = useState(() => {
|
||||
if (task) {
|
||||
const ids = Object.keys(task.calendarIds);
|
||||
return ids[0] ?? defaultCalendarId;
|
||||
}
|
||||
return defaultCalendarId;
|
||||
});
|
||||
const [alertOption, setAlertOption] = useState<AlertOption>(() => {
|
||||
if (!task?.alerts) return "none";
|
||||
const first = Object.values(task.alerts)[0];
|
||||
if (!first || first.trigger["@type"] !== "OffsetTrigger") return "none";
|
||||
const offset = first.trigger.offset;
|
||||
if (offset === "PT0S") return "at_time";
|
||||
const m = offset.match(/-?PT?(\d+)M$/);
|
||||
if (m) return m[1] as AlertOption;
|
||||
const h = offset.match(/-?PT?(\d+)H$/);
|
||||
if (h) return String(parseInt(h[1]) * 60) as AlertOption;
|
||||
const d = offset.match(/-?P(\d+)D/);
|
||||
if (d) return String(parseInt(d[1]) * 1440) as AlertOption;
|
||||
return "none";
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
titleRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!title.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
let due: string | null = null;
|
||||
let showWithoutTime = true;
|
||||
if (dueDate) {
|
||||
if (showTime && dueTime) {
|
||||
due = `${dueDate}T${dueTime}:00`;
|
||||
showWithoutTime = false;
|
||||
} else {
|
||||
due = `${dueDate}T00:00:00`;
|
||||
showWithoutTime = true;
|
||||
}
|
||||
}
|
||||
|
||||
let alerts: Record<string, CalendarEventAlert> | null = null;
|
||||
if (alertOption !== "none") {
|
||||
const offset = alertOption === "at_time" ? "PT0S" : `-PT${alertOption}M`;
|
||||
alerts = {
|
||||
"default-alert": {
|
||||
"@type": "Alert",
|
||||
trigger: { "@type": "OffsetTrigger", offset, relativeTo: "start" },
|
||||
action: "display",
|
||||
acknowledged: null,
|
||||
relatedTo: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const data: Partial<CalendarTask> = {
|
||||
"@type": "Task",
|
||||
title: title.trim(),
|
||||
description: description.trim() || "",
|
||||
due,
|
||||
showWithoutTime,
|
||||
priority: levelToPriority(priority),
|
||||
progress,
|
||||
calendarIds: { [calendarId]: true },
|
||||
alerts,
|
||||
};
|
||||
|
||||
if (isEdit && task) {
|
||||
data.id = task.id;
|
||||
}
|
||||
|
||||
await onSave(data);
|
||||
onClose();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [title, description, dueDate, dueTime, showTime, priority, progress, calendarId, alertOption, isEdit, task, onSave, onClose]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
}, [onClose, handleSave]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background" onKeyDown={handleKeyDown}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{isEdit ? t("tasks.edit") : t("tasks.create")}
|
||||
</h2>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{/* Title */}
|
||||
<Input
|
||||
ref={titleRef}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("tasks.title_placeholder")}
|
||||
className="text-base font-medium"
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t("tasks.description_placeholder")}
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
|
||||
/>
|
||||
|
||||
{/* Due Date */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
|
||||
<CalendarDays className="h-3.5 w-3.5" />
|
||||
{t("tasks.due_date")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
||||
/>
|
||||
{dueDate && (
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showTime}
|
||||
onChange={(e) => setShowTime(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
{t("tasks.include_time")}
|
||||
</label>
|
||||
)}
|
||||
{showTime && (
|
||||
<input
|
||||
type="time"
|
||||
value={dueTime}
|
||||
onChange={(e) => setDueTime(e.target.value)}
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
|
||||
<Flag className="h-3.5 w-3.5" />
|
||||
{t("tasks.priority")}
|
||||
</label>
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value as PriorityLevel)}
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
|
||||
>
|
||||
<option value="none">{t("tasks.priority_none")}</option>
|
||||
<option value="high">{t("tasks.priority_high")}</option>
|
||||
<option value="medium">{t("tasks.priority_medium")}</option>
|
||||
<option value="low">{t("tasks.priority_low")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("tasks.progress")}
|
||||
</label>
|
||||
<select
|
||||
value={progress}
|
||||
onChange={(e) => setProgress(e.target.value as CalendarTask["progress"])}
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
|
||||
>
|
||||
<option value="needs-action">{t("tasks.progress_needs_action")}</option>
|
||||
<option value="in-process">{t("tasks.progress_in_process")}</option>
|
||||
<option value="completed">{t("tasks.progress_completed")}</option>
|
||||
<option value="cancelled">{t("tasks.progress_cancelled")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Calendar */}
|
||||
{writableCalendars.length > 1 && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("tasks.calendar")}
|
||||
</label>
|
||||
<select
|
||||
value={calendarId}
|
||||
onChange={(e) => setCalendarId(e.target.value)}
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
|
||||
>
|
||||
{writableCalendars.map((cal) => (
|
||||
<option key={cal.id} value={cal.id}>{cal.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alert */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
|
||||
<Bell className="h-3.5 w-3.5" />
|
||||
{t("tasks.alert")}
|
||||
</label>
|
||||
<select
|
||||
value={alertOption}
|
||||
onChange={(e) => setAlertOption(e.target.value as AlertOption)}
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
|
||||
>
|
||||
<option value="none">{t("tasks.alert_none")}</option>
|
||||
<option value="at_time">{t("tasks.alert_at_time")}</option>
|
||||
<option value="5">{t("tasks.alert_5min")}</option>
|
||||
<option value="15">{t("tasks.alert_15min")}</option>
|
||||
<option value="30">{t("tasks.alert_30min")}</option>
|
||||
<option value="60">{t("tasks.alert_1hr")}</option>
|
||||
<option value="1440">{t("tasks.alert_1day")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
|
||||
<div>
|
||||
{isEdit && onDelete && task && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => onDelete(task.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
{t("tasks.delete")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
{t("tasks.cancel")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!title.trim() || saving}>
|
||||
{t("tasks.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TaskViewFilter } from "@/stores/task-store";
|
||||
|
||||
interface TaskToolbarProps {
|
||||
filter: TaskViewFilter;
|
||||
showCompleted: boolean;
|
||||
onFilterChange: (filter: TaskViewFilter) => void;
|
||||
onShowCompletedChange: (show: boolean) => void;
|
||||
onCreateTask: () => void;
|
||||
}
|
||||
|
||||
const FILTERS: TaskViewFilter[] = ["all", "pending", "completed", "overdue"];
|
||||
|
||||
export function TaskToolbar({
|
||||
filter,
|
||||
showCompleted,
|
||||
onFilterChange,
|
||||
onShowCompletedChange,
|
||||
onCreateTask,
|
||||
}: TaskToolbarProps) {
|
||||
const t = useTranslations("calendar");
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border flex-wrap">
|
||||
<div className="flex border border-border rounded-md overflow-hidden">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => onFilterChange(f)}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
f === filter
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t(`tasks.filter_${f}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none ml-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showCompleted}
|
||||
onChange={(e) => onShowCompletedChange(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
{t("tasks.show_completed")}
|
||||
</label>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<Button size="sm" onClick={onCreateTask}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{t("tasks.create")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -48,6 +48,7 @@ interface AddressEntry {
|
||||
interface ContactFormProps {
|
||||
contact?: ContactCard | null;
|
||||
addressBooks?: AddressBook[];
|
||||
allKeywords?: string[];
|
||||
onSave: (data: Partial<ContactCard>) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
@@ -123,7 +124,7 @@ function Select({ value, onChange, children, className }: {
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactForm({ contact, addressBooks, onSave, onCancel }: ContactFormProps) {
|
||||
export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCancel }: ContactFormProps) {
|
||||
const t = useTranslations("contacts.form");
|
||||
const isEditing = !!contact;
|
||||
|
||||
@@ -819,14 +820,14 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
|
||||
|
||||
{/* Categories */}
|
||||
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen category="digital">
|
||||
<div>
|
||||
<Input
|
||||
value={keywordsStr}
|
||||
onChange={(e) => setKeywordsStr(e.target.value)}
|
||||
<CategoryComboBox
|
||||
keywordsStr={keywordsStr}
|
||||
onChange={setKeywordsStr}
|
||||
allKeywords={allKeywords || []}
|
||||
placeholder={t("categories_placeholder")}
|
||||
hint={t("categories_hint")}
|
||||
addLabel={t("category_add")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1.5">{t("categories_hint")}</p>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Gender */}
|
||||
@@ -895,3 +896,142 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryComboBox({
|
||||
keywordsStr,
|
||||
onChange,
|
||||
allKeywords,
|
||||
placeholder,
|
||||
hint,
|
||||
addLabel,
|
||||
}: {
|
||||
keywordsStr: string;
|
||||
onChange: (value: string) => void;
|
||||
allKeywords: string[];
|
||||
placeholder: string;
|
||||
hint: string;
|
||||
addLabel: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Parse current keywords from comma-separated string
|
||||
const currentKeywords = useMemo(() => {
|
||||
return keywordsStr.split(",").map(k => k.trim()).filter(Boolean);
|
||||
}, [keywordsStr]);
|
||||
|
||||
// Suggestions: existing keywords not already selected
|
||||
const suggestions = useMemo(() => {
|
||||
const lower = inputValue.toLowerCase();
|
||||
return allKeywords.filter(kw =>
|
||||
!currentKeywords.includes(kw) &&
|
||||
(!lower || kw.toLowerCase().includes(lower))
|
||||
);
|
||||
}, [allKeywords, currentKeywords, inputValue]);
|
||||
|
||||
// Can add a new keyword if typed text is non-empty and not already in the list
|
||||
const canAddNew = inputValue.trim() &&
|
||||
!currentKeywords.includes(inputValue.trim()) &&
|
||||
!allKeywords.some(kw => kw.toLowerCase() === inputValue.trim().toLowerCase());
|
||||
|
||||
const addKeyword = useCallback((keyword: string) => {
|
||||
const trimmed = keyword.trim();
|
||||
if (!trimmed || currentKeywords.includes(trimmed)) return;
|
||||
const next = [...currentKeywords, trimmed].join(", ");
|
||||
onChange(next);
|
||||
setInputValue("");
|
||||
}, [currentKeywords, onChange]);
|
||||
|
||||
const removeKeyword = useCallback((keyword: string) => {
|
||||
const next = currentKeywords.filter(k => k !== keyword).join(", ");
|
||||
onChange(next);
|
||||
}, [currentKeywords, onChange]);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (inputValue.trim()) {
|
||||
addKeyword(inputValue);
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="relative">
|
||||
{/* Keyword badges */}
|
||||
{currentKeywords.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{currentKeywords.map(kw => (
|
||||
<span
|
||||
key={kw}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-primary/10 text-primary border border-primary/20"
|
||||
>
|
||||
{kw}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeKeyword(kw)}
|
||||
className="hover:text-destructive transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input with dropdown */}
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => { setInputValue(e.target.value); setIsOpen(true); }}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={currentKeywords.length === 0 ? placeholder : ""}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1.5">{hint}</p>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && (suggestions.length > 0 || canAddNew) && (
|
||||
<div className="absolute left-0 right-0 top-[calc(100%-1.5rem)] mt-1 rounded-md border border-border bg-popover text-popover-foreground shadow-md z-50 max-h-48 overflow-y-auto py-1">
|
||||
{suggestions.map(kw => (
|
||||
<button
|
||||
key={kw}
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||
onClick={() => { addKeyword(kw); inputRef.current?.focus(); }}
|
||||
>
|
||||
<Tag className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
{kw}
|
||||
</button>
|
||||
))}
|
||||
{canAddNew && (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left text-primary"
|
||||
onClick={() => { addKeyword(inputValue); inputRef.current?.focus(); }}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
{addLabel}: "{inputValue.trim()}"
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
|
||||
? Array.from(selectedContactIds)
|
||||
: [contact.id];
|
||||
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
e.dataTransfer.setData("application/x-contact-ids", JSON.stringify(ids));
|
||||
e.dataTransfer.setData("text/plain", name || email || contact.id);
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { cn } from "@/lib/utils";
|
||||
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName } from "@/stores/contact-store";
|
||||
|
||||
export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string };
|
||||
export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized";
|
||||
|
||||
interface ContactsSidebarProps {
|
||||
groups: ContactCard[];
|
||||
@@ -24,6 +24,7 @@ interface ContactsSidebarProps {
|
||||
onEditGroup?: (groupId: string) => void;
|
||||
onDeleteGroup?: (groupId: string) => void;
|
||||
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -56,6 +57,7 @@ export function ContactsSidebar({
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
onDropContacts,
|
||||
onDropContactsToCategory,
|
||||
className,
|
||||
}: ContactsSidebarProps) {
|
||||
const t = useTranslations("contacts");
|
||||
@@ -146,6 +148,11 @@ export function ContactsSidebar({
|
||||
return Object.entries(counts).sort(([a], [b]) => a.localeCompare(b));
|
||||
}, [individuals]);
|
||||
|
||||
// Count of contacts without any keywords
|
||||
const uncategorizedCount = useMemo(() => {
|
||||
return individuals.filter(c => !c.keywords || Object.keys(c.keywords).filter(k => c.keywords![k]).length === 0).length;
|
||||
}, [individuals]);
|
||||
|
||||
// Resolve actual group member counts against living contacts
|
||||
const memberCountByGroup = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
@@ -311,7 +318,6 @@ export function ContactsSidebar({
|
||||
)}
|
||||
|
||||
{/* Categories section (from contact keywords) */}
|
||||
{allKeywords.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => toggleSection("categories")}
|
||||
@@ -327,30 +333,41 @@ export function ContactsSidebar({
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{!collapsed.categories && allKeywords.map(([keyword, count]) => {
|
||||
const isActive = typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === keyword;
|
||||
return (
|
||||
{!collapsed.categories && (
|
||||
<>
|
||||
{/* No Category item */}
|
||||
<button
|
||||
key={keyword}
|
||||
onClick={() => onSelectCategory({ keyword })}
|
||||
onClick={() => onSelectCategory("uncategorized")}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
||||
isActive
|
||||
activeCategory === "uncategorized"
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-foreground/80 hover:bg-muted"
|
||||
)}
|
||||
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
|
||||
>
|
||||
<Tag className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="truncate">{keyword}</span>
|
||||
<Tag className="w-3.5 h-3.5 flex-shrink-0 opacity-50" />
|
||||
<span className="truncate italic">{t("no_category")}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||
{count}
|
||||
{uncategorizedCount}
|
||||
</span>
|
||||
</button>
|
||||
{allKeywords.map(([keyword, count]) => {
|
||||
const isActive = typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === keyword;
|
||||
return (
|
||||
<CategoryItem
|
||||
key={keyword}
|
||||
keyword={keyword}
|
||||
count={count}
|
||||
isActive={isActive}
|
||||
onSelect={() => onSelectCategory({ keyword })}
|
||||
onDropContacts={onDropContactsToCategory}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Shared accounts with address books */}
|
||||
{sharedBookGroups.map((group) => (
|
||||
@@ -415,6 +432,71 @@ export function ContactsSidebar({
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryItem({
|
||||
keyword,
|
||||
count,
|
||||
isActive,
|
||||
onSelect,
|
||||
onDropContacts,
|
||||
}: {
|
||||
keyword: string;
|
||||
count: number;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
onDropContacts?: (contactIds: string[], keyword: string) => void;
|
||||
}) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent<HTMLButtonElement>) => {
|
||||
if (!e.dataTransfer.types.includes("application/x-contact-ids")) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setIsDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: DragEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const data = e.dataTransfer.getData("application/x-contact-ids");
|
||||
if (!data || !onDropContacts) return;
|
||||
try {
|
||||
const contactIds = JSON.parse(data) as string[];
|
||||
if (contactIds.length > 0) {
|
||||
onDropContacts(contactIds, keyword);
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid data
|
||||
}
|
||||
}, [keyword, onDropContacts]);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-foreground/80 hover:bg-muted",
|
||||
isDragOver && "bg-primary/20 ring-2 ring-primary/50"
|
||||
)}
|
||||
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
|
||||
>
|
||||
<Tag className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="truncate">{keyword}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AddressBookItem({
|
||||
book,
|
||||
isActive,
|
||||
|
||||
@@ -28,6 +28,14 @@ import { TemplatePicker } from "@/components/templates/template-picker";
|
||||
import { TemplateForm } from "@/components/templates/template-form";
|
||||
import type { EmailTemplate } from "@/lib/template-types";
|
||||
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
||||
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||
|
||||
/** Strip HTML tags and decode entities to get a plain-text version */
|
||||
function htmlToPlainText(html: string): string {
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = html;
|
||||
return tmp.textContent || tmp.innerText || '';
|
||||
}
|
||||
|
||||
export interface ComposerDraftData {
|
||||
to: string;
|
||||
@@ -125,23 +133,28 @@ export function EmailComposer({
|
||||
};
|
||||
|
||||
const getInitialBody = () => {
|
||||
const prefix = initialDraftText || "";
|
||||
const prefix = initialDraftText ? `<p>${initialDraftText.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</p>` : "";
|
||||
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
|
||||
|
||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
||||
const from = replyTo.from?.[0];
|
||||
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
||||
|
||||
// When HTML body is available, don't include quoted text in the textarea
|
||||
// The HTML original will be shown separately below the textarea
|
||||
// Build quoted content as HTML
|
||||
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||
return prefix;
|
||||
const quoteHeader = mode === 'forward'
|
||||
? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>`
|
||||
: `On ${date}, ${fromStr} wrote:<br>`;
|
||||
return `${prefix}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${replyTo.htmlBody}</blockquote>`;
|
||||
}
|
||||
|
||||
if (replyTo.body) {
|
||||
const escapedOriginal = replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
||||
if (mode === 'forward') {
|
||||
return `${prefix}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ""}\n\n${replyTo.body}`;
|
||||
return `${prefix}<br><br>---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>${escapedOriginal}`;
|
||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||
return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n> ${(replyTo.body || '').split('\n').join('\n> ')}`;
|
||||
return `${prefix}<br><br>On ${date}, ${fromStr} wrote:<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
|
||||
}
|
||||
}
|
||||
return prefix;
|
||||
};
|
||||
@@ -157,18 +170,6 @@ export function EmailComposer({
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastSavedDataRef = useRef<string>("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const autoResizeTextarea = useCallback(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = el.scrollHeight + 'px';
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
autoResizeTextarea();
|
||||
}, [body, autoResizeTextarea]);
|
||||
const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean; abortController?: AbortController }>>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({});
|
||||
@@ -367,9 +368,12 @@ export function EmailComposer({
|
||||
? substitutePlaceholders(template.body, filledValues)
|
||||
: template.body;
|
||||
|
||||
// Convert template plain text body to HTML for the rich text editor
|
||||
const htmlBody = `<p>${filledBody.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</p>`;
|
||||
|
||||
if (mode === 'compose') {
|
||||
setSubject(filledSubject);
|
||||
setBody(filledBody);
|
||||
setBody(htmlBody);
|
||||
if (template.defaultRecipients?.to?.length) {
|
||||
setTo(template.defaultRecipients.to.join(', ') + ', ');
|
||||
}
|
||||
@@ -382,7 +386,7 @@ export function EmailComposer({
|
||||
setShowBcc(true);
|
||||
}
|
||||
} else {
|
||||
setBody((prev) => filledBody + prev);
|
||||
setBody((prev) => htmlBody + prev);
|
||||
}
|
||||
|
||||
if (template.identityId) {
|
||||
@@ -394,8 +398,10 @@ export function EmailComposer({
|
||||
|
||||
useEffect(() => {
|
||||
const handleTemplateKey = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement)?.tagName?.toLowerCase();
|
||||
const target = e.target as HTMLElement;
|
||||
const tag = target?.tagName?.toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
||||
if (target?.getAttribute('contenteditable') === 'true') return;
|
||||
if (e.key === 't' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
setShowTemplatePicker(true);
|
||||
@@ -445,6 +451,18 @@ export function EmailComposer({
|
||||
}
|
||||
}, [client, t]);
|
||||
|
||||
const handleImageUpload = useCallback(async (file: File): Promise<string | null> => {
|
||||
if (!client) return null;
|
||||
try {
|
||||
const { blobId } = await client.uploadBlob(file);
|
||||
return await client.fetchBlobAsObjectUrl(blobId, file.name, file.type);
|
||||
} catch (error) {
|
||||
debug.error(`Failed to upload inline image ${file.name}:`, error);
|
||||
toast.error(t('upload_failed', { filename: file.name }));
|
||||
return null;
|
||||
}
|
||||
}, [client, t]);
|
||||
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!event.target.files) return;
|
||||
await addFiles(Array.from(event.target.files));
|
||||
@@ -511,7 +529,7 @@ export function EmailComposer({
|
||||
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
||||
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
|
||||
|
||||
if (!toAddresses.length && !subject && !body) {
|
||||
if (!toAddresses.length && !subject && !htmlToPlainText(body).trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -547,7 +565,7 @@ export function EmailComposer({
|
||||
const savedDraftId = await client.createDraft(
|
||||
toAddresses,
|
||||
subject || t('no_subject'),
|
||||
body,
|
||||
htmlToPlainText(body),
|
||||
ccAddresses,
|
||||
bccAddresses,
|
||||
currentIdentity?.id,
|
||||
@@ -611,7 +629,8 @@ export function EmailComposer({
|
||||
}, []);
|
||||
|
||||
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
|
||||
const hasContent = body || attachments.some(att => att.blobId && !att.uploading);
|
||||
const bodyPlainText = htmlToPlainText(body).trim();
|
||||
const hasContent = bodyPlainText || attachments.some(att => att.blobId && !att.uploading);
|
||||
const canSend = toAddresses.length > 0 && !!subject && hasContent;
|
||||
|
||||
const getSendTooltip = (): string | undefined => {
|
||||
@@ -660,26 +679,8 @@ export function EmailComposer({
|
||||
: currentIdentity.email
|
||||
: undefined;
|
||||
|
||||
// Append signature from the selected identity
|
||||
let finalBody = appendPlainTextSignature(body, currentIdentity);
|
||||
|
||||
// Append quoted original text for the plain text part in reply/forward
|
||||
if (replyTo && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||
const originalText = replyTo.body || '';
|
||||
if (originalText) {
|
||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : '';
|
||||
const fromAddr = replyTo.from?.[0];
|
||||
const fromStr = fromAddr ? `${fromAddr.name || fromAddr.email}` : tCommon('unknown');
|
||||
|
||||
if (mode === 'forward') {
|
||||
finalBody += `\n\n---------- ${t('prefix.forward')} ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`;
|
||||
} else {
|
||||
finalBody += `\n\nOn ${date}, ${fromStr} wrote:\n> ${originalText.split('\n').join('\n> ')}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build HTML signature block (prefer htmlSignature, fall back to escaped textSignature)
|
||||
// Body is already HTML from the rich text editor.
|
||||
// Build HTML signature block
|
||||
const buildSignatureHtml = (): string => {
|
||||
if (currentIdentity?.htmlSignature) {
|
||||
return `<br><br>-- <br>${sanitizeEmailHtml(currentIdentity.htmlSignature)}`;
|
||||
@@ -690,26 +691,13 @@ export function EmailComposer({
|
||||
return '';
|
||||
};
|
||||
|
||||
// Build HTML body
|
||||
let finalHtmlBody: string | undefined;
|
||||
const signatureHtml = buildSignatureHtml();
|
||||
|
||||
if (replyTo?.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||
// Reply/forward with original HTML content
|
||||
const escapedBody = body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : '';
|
||||
const fromAddr = replyTo.from?.[0];
|
||||
const fromStr = fromAddr ? `${fromAddr.name || fromAddr.email}` : tCommon('unknown');
|
||||
const quoteHeader = mode === 'forward'
|
||||
? `---------- ${t('prefix.forward')} ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>`
|
||||
: `On ${date}, ${fromStr} wrote:<br>`;
|
||||
// Build final HTML body: editor content + signature
|
||||
const finalHtmlBody = `<div>${body}</div>${signatureHtml}`;
|
||||
|
||||
finalHtmlBody = `<div>${escapedBody}</div>${signatureHtml}<br><div><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${replyTo.htmlBody}</blockquote></div>`;
|
||||
} else if (signatureHtml) {
|
||||
// New compose or plain-text reply — include HTML body with signature
|
||||
const escapedBody = body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
||||
finalHtmlBody = `<div>${escapedBody}</div>${signatureHtml}`;
|
||||
}
|
||||
// Generate plain text version from the HTML body for multipart/alternative
|
||||
const finalBody = appendPlainTextSignature(htmlToPlainText(body), currentIdentity);
|
||||
|
||||
try {
|
||||
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
|
||||
@@ -886,6 +874,7 @@ export function EmailComposer({
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col h-full bg-background relative", className)}
|
||||
data-tour="composer"
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
@@ -1106,23 +1095,17 @@ export function EmailComposer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-4 py-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={cn(
|
||||
"w-full resize-none outline-none text-sm bg-transparent text-foreground placeholder:text-muted-foreground rounded min-h-[100px] overflow-hidden",
|
||||
validationErrors.body && "ring-2 ring-red-500 dark:ring-red-400"
|
||||
)}
|
||||
placeholder={t('body_placeholder')}
|
||||
value={body}
|
||||
onChange={(e) => {
|
||||
setBody(e.target.value);
|
||||
{/* Body - Rich Text Editor */}
|
||||
<RichTextEditor
|
||||
content={body}
|
||||
onChange={(html) => {
|
||||
setBody(html);
|
||||
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
|
||||
}}
|
||||
aria-invalid={validationErrors.body || undefined}
|
||||
onImageUpload={handleImageUpload}
|
||||
placeholder={t('body_placeholder')}
|
||||
hasError={validationErrors.body}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{composerSignatureHtml && (
|
||||
<div
|
||||
@@ -1130,23 +1113,6 @@ export function EmailComposer({
|
||||
dangerouslySetInnerHTML={{ __html: `<div>-- </div>${composerSignatureHtml}` }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Quoted original HTML */}
|
||||
{replyTo?.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward') && (
|
||||
<div className="border-t border-border">
|
||||
<div className="px-4 py-2 text-xs text-muted-foreground">
|
||||
{mode === 'forward'
|
||||
? `---------- ${t('prefix.forward')} ----------`
|
||||
: `${replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : ''}, ${replyTo.from?.[0]?.name || replyTo.from?.[0]?.email || tCommon('unknown')}:`
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
className="email-reply-quote px-4 pb-3 border-l-2 border-muted-foreground/30 ml-4 max-w-none rounded"
|
||||
style={{ backgroundColor: '#ffffff', color: '#1a1a1a', fontSize: '14px' }}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeEmailHtml(replyTo.htmlBody) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import type { HoverAction } from "@/stores/settings-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface EmailHoverActionsProps {
|
||||
email: Email;
|
||||
onToggleStar?: () => void;
|
||||
onMarkAsRead?: (read: boolean) => void;
|
||||
onDelete?: () => void;
|
||||
onArchive?: () => void;
|
||||
onSetColorTag?: (color: string | null) => void;
|
||||
onMarkAsSpam?: () => void;
|
||||
}
|
||||
|
||||
const ACTION_CONFIG: Record<HoverAction, {
|
||||
icon: typeof Trash2;
|
||||
titleKey: string;
|
||||
className?: string;
|
||||
}> = {
|
||||
delete: {
|
||||
icon: Trash2,
|
||||
titleKey: "delete",
|
||||
className: "hover:text-red-600 dark:hover:text-red-400",
|
||||
},
|
||||
star: {
|
||||
icon: Star,
|
||||
titleKey: "star",
|
||||
className: "hover:text-amber-500 dark:hover:text-amber-400",
|
||||
},
|
||||
markRead: {
|
||||
icon: Mail,
|
||||
titleKey: "mark_read",
|
||||
className: "hover:text-blue-600 dark:hover:text-blue-400",
|
||||
},
|
||||
archive: {
|
||||
icon: Archive,
|
||||
titleKey: "archive",
|
||||
className: "hover:text-green-600 dark:hover:text-green-400",
|
||||
},
|
||||
tag: {
|
||||
icon: Tag,
|
||||
titleKey: "tag",
|
||||
className: "hover:text-purple-600 dark:hover:text-purple-400",
|
||||
},
|
||||
spam: {
|
||||
icon: ShieldAlert,
|
||||
titleKey: "spam",
|
||||
className: "hover:text-orange-600 dark:hover:text-orange-400",
|
||||
},
|
||||
};
|
||||
|
||||
export function EmailHoverActions({
|
||||
email,
|
||||
onToggleStar,
|
||||
onMarkAsRead,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onSetColorTag,
|
||||
onMarkAsSpam,
|
||||
}: EmailHoverActionsProps) {
|
||||
const hoverActions = useSettingsStore((state) => state.hoverActions);
|
||||
const t = useTranslations("settings.email_behavior.hover_actions");
|
||||
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
|
||||
if (hoverActions.length === 0) return null;
|
||||
|
||||
const handleAction = (e: React.MouseEvent, action: HoverAction) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
switch (action) {
|
||||
case "delete":
|
||||
onDelete?.();
|
||||
break;
|
||||
case "star":
|
||||
onToggleStar?.();
|
||||
break;
|
||||
case "markRead":
|
||||
onMarkAsRead?.(!isUnread);
|
||||
break;
|
||||
case "archive":
|
||||
onArchive?.();
|
||||
break;
|
||||
case "tag":
|
||||
onSetColorTag?.(null);
|
||||
break;
|
||||
case "spam":
|
||||
onMarkAsSpam?.();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 z-10 hidden group-hover:flex items-center"
|
||||
>
|
||||
<div className="w-8 h-full bg-gradient-to-r from-transparent to-muted" />
|
||||
<div className="flex items-center gap-0.5 h-full bg-muted pr-3 pl-0.5">
|
||||
{hoverActions.map((actionId) => {
|
||||
const config = ACTION_CONFIG[actionId];
|
||||
if (!config) return null;
|
||||
const Icon = config.icon;
|
||||
|
||||
const DisplayIcon = actionId === "markRead"
|
||||
? (isUnread ? MailOpen : Mail)
|
||||
: actionId === "star" && isStarred
|
||||
? Star
|
||||
: Icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={actionId}
|
||||
onClick={(e) => handleAction(e, actionId)}
|
||||
title={t(config.titleKey)}
|
||||
className={cn(
|
||||
"p-1.5 rounded-md transition-colors duration-100 text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10",
|
||||
config.className,
|
||||
)}
|
||||
>
|
||||
<DisplayIcon
|
||||
className={cn(
|
||||
"w-4 h-4",
|
||||
actionId === "star" && isStarred && "fill-amber-400 text-amber-400",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||
import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||
import { EmailHoverActions } from "./email-hover-actions";
|
||||
import { getEmailColorTag } from "@/lib/thread-utils";
|
||||
|
||||
interface EmailListItemProps {
|
||||
@@ -21,9 +22,15 @@ interface EmailListItemProps {
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
onToggleStar?: () => void;
|
||||
onMarkAsRead?: (read: boolean) => void;
|
||||
onDelete?: () => void;
|
||||
onArchive?: () => void;
|
||||
onSetColorTag?: (color: string | null) => void;
|
||||
onMarkAsSpam?: () => void;
|
||||
}
|
||||
|
||||
export function EmailListItem({ email, selected, onClick, onContextMenu }: EmailListItemProps) {
|
||||
export function EmailListItem({ email, selected, onClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, clearSelection } = useEmailStore();
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
@@ -74,7 +81,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
|
||||
{...dragHandlers}
|
||||
{...longPressHandlers}
|
||||
className={cn(
|
||||
"relative group cursor-pointer select-none transition-all duration-200 border-b border-border",
|
||||
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
|
||||
// Apply color tag as background, with selected and unread states
|
||||
colorTag ? colorTag : (
|
||||
selected
|
||||
@@ -217,6 +224,17 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hover Quick Actions */}
|
||||
<EmailHoverActions
|
||||
email={email}
|
||||
onToggleStar={onToggleStar}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onDelete={onDelete}
|
||||
onArchive={onArchive}
|
||||
onSetColorTag={onSetColorTag}
|
||||
onMarkAsSpam={onMarkAsSpam}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -358,7 +358,7 @@ export function EmailList({
|
||||
)}
|
||||
|
||||
{/* Email List */}
|
||||
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative">
|
||||
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative" data-tour="email-list">
|
||||
{/* Loading overlay */}
|
||||
{isLoading && emails.length > 0 && (
|
||||
<div className="absolute inset-0 bg-background/50 z-10 flex items-center justify-center animate-in fade-in duration-150">
|
||||
@@ -422,6 +422,12 @@ export function EmailList({
|
||||
onEmailSelect={(email) => onEmailSelect?.(email)}
|
||||
onContextMenu={openContextMenu}
|
||||
onOpenConversation={onOpenConversation}
|
||||
onToggleStar={onToggleStar ? (email) => onToggleStar(email) : undefined}
|
||||
onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined}
|
||||
onDelete={onDelete ? (email) => onDelete(email) : undefined}
|
||||
onArchive={onArchive ? (email) => onArchive(email) : undefined}
|
||||
onSetColorTag={onSetColorTag}
|
||||
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
Moon,
|
||||
HelpCircle,
|
||||
EditIcon,
|
||||
PlayCircle,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
||||
@@ -80,6 +81,7 @@ import { useThemeStore } from "@/stores/theme-store";
|
||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||
import { UnsubscribeBanner } from "./unsubscribe-banner";
|
||||
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
|
||||
import { useTour } from "@/components/tour/tour-provider";
|
||||
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
||||
import { findCalendarAttachment } from "@/lib/calendar-invitation";
|
||||
import { RecipientPopover } from "./recipient-popover";
|
||||
@@ -871,6 +873,8 @@ export function EmailViewer({
|
||||
const tCommon = useTranslations('common');
|
||||
const tSmime = useTranslations('smime');
|
||||
const tFiles = useTranslations('files');
|
||||
const tDemoWelcome = useTranslations('demo_welcome');
|
||||
const tWelcome = useTranslations('welcome');
|
||||
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
|
||||
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
|
||||
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
|
||||
@@ -898,8 +902,9 @@ export function EmailViewer({
|
||||
// Tablet list visibility
|
||||
const { isTablet, isMobile } = useDeviceDetection();
|
||||
const { tabletListVisible } = useUIStore();
|
||||
const { identities, client } = useAuthStore();
|
||||
const { identities, client, isDemoMode } = useAuthStore();
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const { startTour } = useTour();
|
||||
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
||||
const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false);
|
||||
const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false);
|
||||
@@ -2684,6 +2689,52 @@ export function EmailViewer({
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
if (isDemoMode) {
|
||||
const logoSrc = resolvedTheme === 'dark'
|
||||
? '/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg'
|
||||
: '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg';
|
||||
return (
|
||||
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
|
||||
<div className="text-center p-8 max-w-md">
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt="Bulwark Mail"
|
||||
className="h-12 mx-auto mb-6"
|
||||
/>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-3">{tDemoWelcome('title')}</h3>
|
||||
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">{tDemoWelcome('description')}</p>
|
||||
<div className="flex flex-col gap-3 items-center">
|
||||
<div className="grid grid-cols-2 gap-3 text-left text-sm text-muted-foreground w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-4 h-4 text-primary shrink-0" />
|
||||
<span>{tDemoWelcome('feature_email')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="w-4 h-4 text-primary shrink-0" />
|
||||
<span>{tDemoWelcome('feature_organize')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Keyboard className="w-4 h-4 text-primary shrink-0" />
|
||||
<span>{tDemoWelcome('feature_shortcuts')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-primary shrink-0" />
|
||||
<span>{tDemoWelcome('feature_privacy')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={startTour}
|
||||
className="mt-4 inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors text-sm font-medium"
|
||||
>
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
{tWelcome('start_tour')}
|
||||
</button>
|
||||
<p className="text-xs text-muted-foreground/60 mt-2">{tDemoWelcome('hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
|
||||
<div className="text-center p-8">
|
||||
@@ -3233,6 +3284,7 @@ export function EmailViewer({
|
||||
return (
|
||||
<div
|
||||
key={email.id}
|
||||
data-tour="email-viewer"
|
||||
className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden animate-in fade-in duration-300 relative", className)}
|
||||
>
|
||||
{/* Mobile More menu sidebar overlay */}
|
||||
@@ -4397,7 +4449,7 @@ export function EmailViewer({
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={emailIframeSrcDoc}
|
||||
sandbox="allow-same-origin allow-popups"
|
||||
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
||||
title="Email content"
|
||||
className="w-full border-0 rounded"
|
||||
style={{ minHeight: '100px', colorScheme: isDark && emailHasNativeDarkMode ? 'light dark' : 'light' }}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
|
||||
function ResizableImageView({ node, updateAttributes, selected }: NodeViewProps) {
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const [resizing, setResizing] = useState(false);
|
||||
const startState = useRef<{ x: number; y: number; width: number; height: number; handle: string }>({
|
||||
x: 0, y: 0, width: 0, height: 0, handle: "",
|
||||
});
|
||||
|
||||
const onMouseDown = useCallback((e: React.MouseEvent, handle: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const img = imgRef.current;
|
||||
if (!img) return;
|
||||
startState.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
width: img.offsetWidth,
|
||||
height: img.offsetHeight,
|
||||
handle,
|
||||
};
|
||||
setResizing(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resizing) return;
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
const { x, width, handle } = startState.current;
|
||||
const dx = e.clientX - x;
|
||||
let newWidth: number;
|
||||
|
||||
if (handle === "right" || handle === "bottom-right" || handle === "top-right") {
|
||||
newWidth = Math.max(50, width + dx);
|
||||
} else {
|
||||
newWidth = Math.max(50, width - dx);
|
||||
}
|
||||
|
||||
updateAttributes({ width: Math.round(newWidth) });
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
setResizing(false);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", onMouseMove);
|
||||
document.addEventListener("mouseup", onMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
}, [resizing, updateAttributes]);
|
||||
|
||||
const width = node.attrs.width;
|
||||
const style: React.CSSProperties = {
|
||||
...(width ? { width: `${width}px` } : {}),
|
||||
maxWidth: "100%",
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper as="span" className="inline-block relative" draggable data-drag-handle>
|
||||
<span
|
||||
className={`relative inline-block group ${selected ? "ring-2 ring-primary rounded" : ""}`}
|
||||
style={style}
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={node.attrs.src}
|
||||
alt={node.attrs.alt || ""}
|
||||
title={node.attrs.title || undefined}
|
||||
style={{ width: "100%", height: "auto", display: "block" }}
|
||||
draggable={false}
|
||||
/>
|
||||
{selected && (
|
||||
<>
|
||||
{/* Resize handle: right */}
|
||||
<span
|
||||
onMouseDown={(e) => onMouseDown(e, "right")}
|
||||
className="absolute top-1/2 -right-1.5 -translate-y-1/2 w-3 h-8 bg-primary rounded cursor-ew-resize"
|
||||
/>
|
||||
{/* Resize handle: left */}
|
||||
<span
|
||||
onMouseDown={(e) => onMouseDown(e, "left")}
|
||||
className="absolute top-1/2 -left-1.5 -translate-y-1/2 w-3 h-8 bg-primary rounded cursor-ew-resize"
|
||||
/>
|
||||
{/* Resize handle: bottom-right corner */}
|
||||
<span
|
||||
onMouseDown={(e) => onMouseDown(e, "bottom-right")}
|
||||
className="absolute -bottom-1.5 -right-1.5 w-3 h-3 bg-primary rounded cursor-nwse-resize"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export const ResizableImage = Node.create({
|
||||
name: "image",
|
||||
group: "inline",
|
||||
inline: true,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
src: { default: null },
|
||||
alt: { default: null },
|
||||
title: { default: null },
|
||||
width: { default: null },
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: "img[src]" }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const attrs: Record<string, string> = { ...HTMLAttributes };
|
||||
if (attrs.width) {
|
||||
attrs.style = `width: ${attrs.width}px; max-width: 100%;`;
|
||||
delete attrs.width;
|
||||
}
|
||||
return ["img", mergeAttributes(attrs)];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ResizableImageView);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,337 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useCallback } from "react";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import TextAlign from "@tiptap/extension-text-align";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import Color from "@tiptap/extension-color";
|
||||
import { ResizableImage } from "@/components/email/resizable-image";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Underline as UnderlineIcon,
|
||||
Strikethrough,
|
||||
List,
|
||||
ListOrdered,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
Link as LinkIcon,
|
||||
Undo,
|
||||
Redo,
|
||||
Quote,
|
||||
Code,
|
||||
RemoveFormatting,
|
||||
Heading1,
|
||||
Heading2,
|
||||
} from "lucide-react";
|
||||
|
||||
interface RichTextEditorProps {
|
||||
content: string;
|
||||
onChange: (html: string) => void;
|
||||
onImageUpload?: (file: File) => Promise<string | null>;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
hasError?: boolean;
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
title,
|
||||
disabled,
|
||||
}: {
|
||||
active?: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={cn(
|
||||
"p-1.5 rounded hover:bg-accent transition-colors",
|
||||
active && "bg-accent text-accent-foreground",
|
||||
disabled && "opacity-40 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarSeparator() {
|
||||
return <div className="w-px h-5 bg-border mx-0.5" />;
|
||||
}
|
||||
|
||||
export function RichTextEditor({
|
||||
content,
|
||||
onChange,
|
||||
onImageUpload,
|
||||
placeholder,
|
||||
className,
|
||||
hasError,
|
||||
}: RichTextEditorProps) {
|
||||
const onImageUploadRef = React.useRef(onImageUpload);
|
||||
onImageUploadRef.current = onImageUpload;
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2] },
|
||||
}),
|
||||
Underline,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: { rel: "noopener noreferrer nofollow" },
|
||||
}),
|
||||
TextAlign.configure({
|
||||
types: ["heading", "paragraph"],
|
||||
}),
|
||||
TextStyle,
|
||||
Color,
|
||||
ResizableImage,
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
],
|
||||
content,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "tiptap min-h-[100px] px-4 py-3 text-sm text-foreground",
|
||||
},
|
||||
handleDrop: (view, event) => {
|
||||
const upload = onImageUploadRef.current;
|
||||
if (!upload || !event.dataTransfer?.files?.length) return false;
|
||||
const imageFiles = Array.from(event.dataTransfer.files).filter(f =>
|
||||
f.type.startsWith("image/")
|
||||
);
|
||||
if (imageFiles.length === 0) return false;
|
||||
event.preventDefault();
|
||||
for (const file of imageFiles) {
|
||||
upload(file).then((url) => {
|
||||
if (url) {
|
||||
const { state } = view;
|
||||
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
||||
const node = state.schema.nodes.image.create({ src: url, alt: file.name });
|
||||
const tr = state.tr.insert(pos?.pos ?? state.selection.anchor, node);
|
||||
view.dispatch(tr);
|
||||
}
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
handlePaste: (view, event) => {
|
||||
const upload = onImageUploadRef.current;
|
||||
if (!upload || !event.clipboardData?.files?.length) return false;
|
||||
const imageFiles = Array.from(event.clipboardData.files).filter(f =>
|
||||
f.type.startsWith("image/")
|
||||
);
|
||||
if (imageFiles.length === 0) return false;
|
||||
event.preventDefault();
|
||||
for (const file of imageFiles) {
|
||||
upload(file).then((url) => {
|
||||
if (url) {
|
||||
const { state } = view;
|
||||
const node = state.schema.nodes.image.create({ src: url, alt: file.name });
|
||||
const tr = state.tr.replaceSelectionWith(node);
|
||||
view.dispatch(tr);
|
||||
}
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
onChange(editor.getHTML());
|
||||
},
|
||||
immediatelyRender: false,
|
||||
});
|
||||
|
||||
// Sync external content changes (e.g. template application)
|
||||
useEffect(() => {
|
||||
if (editor && content !== editor.getHTML()) {
|
||||
editor.commands.setContent(content, { emitUpdate: false });
|
||||
}
|
||||
}, [content, editor]);
|
||||
|
||||
const addLink = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const previousUrl = editor.getAttributes("link").href;
|
||||
const url = window.prompt("URL", previousUrl);
|
||||
if (url === null) return;
|
||||
if (url === "") {
|
||||
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
return;
|
||||
}
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.extendMarkRange("link")
|
||||
.setLink({ href: url })
|
||||
.run();
|
||||
}, [editor]);
|
||||
|
||||
if (!editor) {
|
||||
return (
|
||||
<div className={cn("min-h-[100px]", className)} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col", hasError && "ring-2 ring-red-500 dark:ring-red-400 rounded", className)}>
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-0.5 px-3 py-1.5 border-b border-border/50 bg-muted/30">
|
||||
<ToolbarButton
|
||||
active={editor.isActive("bold")}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
title="Bold"
|
||||
>
|
||||
<Bold className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("italic")}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
title="Italic"
|
||||
>
|
||||
<Italic className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("underline")}
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
title="Underline"
|
||||
>
|
||||
<UnderlineIcon className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("strike")}
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
title="Strikethrough"
|
||||
>
|
||||
<Strikethrough className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
<ToolbarButton
|
||||
active={editor.isActive("heading", { level: 1 })}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
title="Heading 1"
|
||||
>
|
||||
<Heading1 className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("heading", { level: 2 })}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
title="Heading 2"
|
||||
>
|
||||
<Heading2 className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
<ToolbarButton
|
||||
active={editor.isActive("bulletList")}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
title="Bullet List"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("orderedList")}
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
title="Ordered List"
|
||||
>
|
||||
<ListOrdered className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("blockquote")}
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
title="Quote"
|
||||
>
|
||||
<Quote className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive("codeBlock")}
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
title="Code Block"
|
||||
>
|
||||
<Code className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
<ToolbarButton
|
||||
active={editor.isActive({ textAlign: "left" })}
|
||||
onClick={() => editor.chain().focus().setTextAlign("left").run()}
|
||||
title="Align Left"
|
||||
>
|
||||
<AlignLeft className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive({ textAlign: "center" })}
|
||||
onClick={() => editor.chain().focus().setTextAlign("center").run()}
|
||||
title="Align Center"
|
||||
>
|
||||
<AlignCenter className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive({ textAlign: "right" })}
|
||||
onClick={() => editor.chain().focus().setTextAlign("right").run()}
|
||||
title="Align Right"
|
||||
>
|
||||
<AlignRight className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
<ToolbarButton
|
||||
active={editor.isActive("link")}
|
||||
onClick={addLink}
|
||||
title="Link"
|
||||
>
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
|
||||
title="Clear Formatting"
|
||||
>
|
||||
<RemoveFormatting className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
title="Undo"
|
||||
>
|
||||
<Undo className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
title="Redo"
|
||||
>
|
||||
<Redo className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { getThreadColorTag, getEmailColorTag } from "@/lib/thread-utils";
|
||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||
import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { ThreadEmailItem } from "./thread-email-item";
|
||||
import { EmailHoverActions } from "./email-hover-actions";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface ThreadListItemProps {
|
||||
@@ -25,6 +26,12 @@ interface ThreadListItemProps {
|
||||
onEmailSelect: (email: Email) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
onOpenConversation?: (thread: ThreadGroup) => void;
|
||||
onToggleStar?: (email: Email) => void;
|
||||
onMarkAsRead?: (email: Email, read: boolean) => void;
|
||||
onDelete?: (email: Email) => void;
|
||||
onArchive?: (email: Email) => void;
|
||||
onSetColorTag?: (emailId: string, color: string | null) => void;
|
||||
onMarkAsSpam?: (email: Email) => void;
|
||||
}
|
||||
|
||||
interface SingleEmailItemProps {
|
||||
@@ -34,10 +41,16 @@ interface SingleEmailItemProps {
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
showPreview: boolean;
|
||||
colorTag: string | null;
|
||||
onToggleStar?: () => void;
|
||||
onMarkAsRead?: (read: boolean) => void;
|
||||
onDelete?: () => void;
|
||||
onArchive?: () => void;
|
||||
onSetColorTag?: (color: string | null) => void;
|
||||
onMarkAsSpam?: () => void;
|
||||
}
|
||||
|
||||
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag }, ref) {
|
||||
function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) {
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const sender = email.from?.[0];
|
||||
@@ -100,7 +113,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{...dragHandlers}
|
||||
{...longPressHandlers}
|
||||
className={cn(
|
||||
"relative group cursor-pointer select-none transition-all duration-200 border-b border-border",
|
||||
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
|
||||
resolvedColorTag ? resolvedColorTag : (
|
||||
selected
|
||||
? "bg-accent"
|
||||
@@ -216,6 +229,17 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hover Quick Actions */}
|
||||
<EmailHoverActions
|
||||
email={email}
|
||||
onToggleStar={onToggleStar}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onDelete={onDelete}
|
||||
onArchive={onArchive}
|
||||
onSetColorTag={onSetColorTag}
|
||||
onMarkAsSpam={onMarkAsSpam}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -232,6 +256,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
onEmailSelect,
|
||||
onContextMenu,
|
||||
onOpenConversation,
|
||||
onToggleStar,
|
||||
onMarkAsRead,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onSetColorTag,
|
||||
onMarkAsSpam,
|
||||
}, ref) {
|
||||
const t = useTranslations('threads');
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
@@ -278,6 +308,12 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
onContextMenu={onContextMenu}
|
||||
showPreview={showPreview}
|
||||
colorTag={colorTag}
|
||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
|
||||
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
|
||||
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -339,7 +375,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{...dragHandlers}
|
||||
{...threadLongPressHandlers}
|
||||
className={cn(
|
||||
"relative group cursor-pointer select-none transition-all duration-200",
|
||||
"relative group cursor-pointer select-none transition-shadow duration-200 overflow-hidden",
|
||||
colorTag ? colorTag : (
|
||||
isSelected
|
||||
? "bg-accent"
|
||||
@@ -493,6 +529,17 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hover Quick Actions for thread header */}
|
||||
<EmailHoverActions
|
||||
email={latestEmail}
|
||||
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
|
||||
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
|
||||
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
|
||||
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
|
||||
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
|
||||
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isExpanded && !isMobile && (
|
||||
|
||||
@@ -21,6 +21,7 @@ import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
||||
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
||||
import { FolderTreeSidebar } from "@/components/files/folder-tree-sidebar";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
|
||||
import type { FileResource } from "@/stores/file-store";
|
||||
|
||||
type SortKey = "name" | "size" | "modified";
|
||||
@@ -624,16 +625,20 @@ export function FileBrowser({
|
||||
e.stopPropagation();
|
||||
setIsDraggingOver(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const { files, hasDirectories } = await getDroppedFilesAndFolders(e.dataTransfer);
|
||||
if (files.length > 0) {
|
||||
if (hasDirectories) {
|
||||
await onUploadFolder(files);
|
||||
} else {
|
||||
await onUploadFiles(files);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}
|
||||
}, [onUploadFiles]);
|
||||
}, [onUploadFiles, onUploadFolder]);
|
||||
|
||||
const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
@@ -945,6 +950,16 @@ export function FileBrowser({
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => folderInputRef.current?.click()}
|
||||
title={t("upload_folder")}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<FolderUp className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -1195,6 +1210,14 @@ export function FileBrowser({
|
||||
setIsUploading(false);
|
||||
}
|
||||
}}
|
||||
onUploadFolder={async (files: File[]) => {
|
||||
setIsUploading(true);
|
||||
try {
|
||||
await onUploadFolder(files);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}}
|
||||
onCreateFolder={() => setShowNewFolder(true)}
|
||||
onCreateTextFile={() => setShowNewTextFile(true)}
|
||||
/>
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Upload, FolderPlus, FilePlus } from "lucide-react";
|
||||
import { Upload, FolderPlus, FilePlus, FolderUp } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
|
||||
|
||||
interface FileUploadAreaProps {
|
||||
onUpload: (files: File[]) => Promise<void>;
|
||||
onUploadFolder?: (files: File[]) => Promise<void>;
|
||||
onCreateFolder: () => void;
|
||||
onCreateTextFile?: () => void;
|
||||
}
|
||||
|
||||
export function FileUploadArea({ onUpload, onCreateFolder, onCreateTextFile }: FileUploadAreaProps) {
|
||||
export function FileUploadArea({ onUpload, onUploadFolder, onCreateFolder, onCreateTextFile }: FileUploadAreaProps) {
|
||||
const t = useTranslations("files");
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
@@ -32,11 +34,15 @@ export function FileUploadArea({ onUpload, onCreateFolder, onCreateTextFile }: F
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const { files, hasDirectories } = await getDroppedFilesAndFolders(e.dataTransfer);
|
||||
if (files.length > 0) {
|
||||
if (hasDirectories && onUploadFolder) {
|
||||
await onUploadFolder(files);
|
||||
} else {
|
||||
await onUpload(files);
|
||||
}
|
||||
}, [onUpload]);
|
||||
}
|
||||
}, [onUpload, onUploadFolder]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full p-8">
|
||||
|
||||
@@ -42,7 +42,11 @@ export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl }: Im
|
||||
}, [name, getImageUrl]);
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key === "Escape") { onClose(); return; }
|
||||
const target = e.target as HTMLElement;
|
||||
const tag = target?.tagName?.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "select") return;
|
||||
if (target?.getAttribute("contenteditable") === "true") return;
|
||||
if (e.key === "+" || e.key === "=") setZoom((z) => Math.min(z + 0.25, 5));
|
||||
if (e.key === "-") setZoom((z) => Math.max(z - 0.25, 0.25));
|
||||
if (e.key === "r") setRotation((r) => r + 90);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { X, Keyboard } from "lucide-react";
|
||||
import { KEYBOARD_SHORTCUTS } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { useTour } from "@/components/tour/tour-provider";
|
||||
|
||||
interface KeyboardShortcutsModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -13,6 +14,7 @@ interface KeyboardShortcutsModalProps {
|
||||
|
||||
export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsModalProps) {
|
||||
const t = useTranslations();
|
||||
const { startTour } = useTour();
|
||||
|
||||
const modalRef = useFocusTrap({
|
||||
isActive: isOpen,
|
||||
@@ -144,6 +146,14 @@ export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsMod
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t("shortcuts.tip")}
|
||||
</p>
|
||||
<p className="text-sm text-center mt-2">
|
||||
<button
|
||||
onClick={() => { onClose(); startTour(); }}
|
||||
className="text-primary hover:text-primary/80 underline underline-offset-2 transition-colors"
|
||||
>
|
||||
{t("tour.take_a_tour")}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -101,15 +101,11 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
||||
const handleLogout = () => {
|
||||
setOpen(false);
|
||||
logout();
|
||||
if (useAccountStore.getState().accounts.length === 0) {
|
||||
router.push("/login" as never);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogoutAll = () => {
|
||||
setOpen(false);
|
||||
logoutAll();
|
||||
router.push("/login" as never);
|
||||
};
|
||||
|
||||
const handleSetDefault = (accountId: string) => {
|
||||
|
||||
@@ -303,6 +303,7 @@ export function NavigationRail({
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||
data-tour={`nav-${item.id}`}
|
||||
className={cn(
|
||||
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
|
||||
collapsed
|
||||
@@ -401,6 +402,7 @@ export function NavigationRail({
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
|
||||
data-tour="nav-settings"
|
||||
className={cn(
|
||||
"flex items-center justify-center w-10 h-10 rounded-md transition-colors",
|
||||
isSettingsActive
|
||||
@@ -418,6 +420,7 @@ export function NavigationRail({
|
||||
{onShowShortcuts && (
|
||||
<button
|
||||
onClick={onShowShortcuts}
|
||||
data-tour="nav-shortcuts"
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
title={t("keyboard_shortcuts")}
|
||||
>
|
||||
@@ -426,7 +429,9 @@ export function NavigationRail({
|
||||
)}
|
||||
|
||||
{quota && quota.total > 0 && (
|
||||
<div data-tour="storage-quota">
|
||||
<StorageQuotaCircle quota={quota} usagePercent={quotaUsagePercent} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPushConnected != null && (
|
||||
|
||||
@@ -18,6 +18,7 @@ interface SidebarAppFormData {
|
||||
url: string;
|
||||
icon: string;
|
||||
openMode: 'tab' | 'inline';
|
||||
showOnMobile: boolean;
|
||||
}
|
||||
|
||||
function SidebarAppForm({
|
||||
@@ -37,6 +38,7 @@ function SidebarAppForm({
|
||||
url: app?.url || '',
|
||||
icon: app?.icon || 'Globe',
|
||||
openMode: app?.openMode || 'tab',
|
||||
showOnMobile: app?.showOnMobile ?? false,
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ import {
|
||||
Settings,
|
||||
X,
|
||||
Tag,
|
||||
RotateCcw,
|
||||
FlaskConical,
|
||||
PlayCircle,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
|
||||
import { Mailbox } from "@/lib/jmap/types";
|
||||
@@ -40,6 +44,7 @@ import { debug } from "@/lib/debug";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { AccountSwitcher } from "./account-switcher";
|
||||
import { useTour } from "@/components/tour/tour-provider";
|
||||
|
||||
interface SidebarProps {
|
||||
mailboxes: Mailbox[];
|
||||
@@ -328,6 +333,68 @@ function TagItem({
|
||||
);
|
||||
}
|
||||
|
||||
function DemoBanner() {
|
||||
const t = useTranslations('sidebar');
|
||||
const { isDemoMode, loginDemo } = useAuthStore();
|
||||
const { startTour, resetTourCompletion } = useTour();
|
||||
const router = useRouter();
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
if (!isDemoMode) return null;
|
||||
|
||||
const handleReset = async () => {
|
||||
setIsResetting(true);
|
||||
// Navigate to home first so the mail page re-fetches data
|
||||
router.push('/');
|
||||
await loginDemo();
|
||||
setIsResetting(false);
|
||||
};
|
||||
|
||||
const handleStartTour = () => {
|
||||
resetTourCompletion();
|
||||
router.push('/');
|
||||
setTimeout(() => startTour(), 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tour="demo-banner"
|
||||
className={cn(
|
||||
"flex flex-col gap-1.5 w-full px-3 py-2 text-xs",
|
||||
"bg-primary/10 dark:bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FlaskConical className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="truncate font-medium">{t("demo_banner")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={handleStartTour}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-primary/10 hover:bg-primary/20 transition-colors"
|
||||
title={t("demo_tour")}
|
||||
>
|
||||
<PlayCircle className="w-3 h-3" />
|
||||
{t("demo_tour")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={isResetting}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-primary/10 hover:bg-primary/20 transition-colors disabled:opacity-50"
|
||||
title={t("demo_reset")}
|
||||
>
|
||||
{isResetting ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
)}
|
||||
{t("demo_reset")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VacationBanner() {
|
||||
const t = useTranslations('sidebar');
|
||||
const router = useRouter();
|
||||
@@ -454,12 +521,12 @@ export function Sidebar({
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={cn("flex items-center border-b border-border", isCollapsed ? "justify-center px-2 py-3" : "gap-2 px-4 py-3")}>
|
||||
<div className={cn("flex items-center border-b border-border", isCollapsed ? "justify-center px-2 py-2" : "gap-1 px-2 py-2")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onSidebarClose}
|
||||
className="lg:hidden h-11 w-11 flex-shrink-0"
|
||||
className="lg:hidden h-9 w-9 flex-shrink-0"
|
||||
aria-label={t("close")}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
@@ -480,7 +547,7 @@ export function Sidebar({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleSidebarCollapsed}
|
||||
className="hidden lg:flex flex-shrink-0"
|
||||
className="hidden lg:flex h-8 w-8 flex-shrink-0"
|
||||
title={isCollapsed ? t("expand_tooltip") : t("collapse_tooltip")}
|
||||
>
|
||||
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
|
||||
@@ -491,11 +558,14 @@ export function Sidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Demo Banner */}
|
||||
{!isCollapsed && <DemoBanner />}
|
||||
|
||||
{/* Vacation Banner */}
|
||||
{!isCollapsed && <VacationBanner />}
|
||||
|
||||
{/* Mailbox List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex-1 overflow-y-auto" data-tour="sidebar">
|
||||
<div className="py-1">
|
||||
{mailboxes.length === 0 ? (
|
||||
<div className="px-4 py-2 text-sm text-muted-foreground">
|
||||
@@ -582,7 +652,7 @@ export function Sidebar({
|
||||
</div>
|
||||
|
||||
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
|
||||
<div className="relative">
|
||||
<div className="relative" data-tour="keyword-tags">
|
||||
{emailKeywords.map((kw) => {
|
||||
const isSelected = selectedKeyword === kw.id;
|
||||
return (
|
||||
@@ -606,11 +676,11 @@ export function Sidebar({
|
||||
{/* Compose Button */}
|
||||
<div className={cn("border-t border-border", isCollapsed ? "flex justify-center py-3" : "px-3 py-3")}>
|
||||
{isCollapsed ? (
|
||||
<Button onClick={onCompose} variant="ghost" size="icon" title={t("compose_hint")}>
|
||||
<Button onClick={onCompose} variant="ghost" size="icon" title={t("compose_hint")} data-tour="compose-button">
|
||||
<PenSquare className="w-5 h-5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={onCompose} className="w-full" title={t("compose_hint")}>
|
||||
<Button onClick={onCompose} className="w-full" title={t("compose_hint")} data-tour="compose-button">
|
||||
<PenSquare className="w-4 h-4 mr-2" />
|
||||
{t("compose")}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { isEmbedded, listenFromParent } from "@/lib/iframe-bridge";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
|
||||
export function EmbeddedBridgeProvider({ children }: { children: React.ReactNode }) {
|
||||
const { parentOrigin, embeddedMode } = useConfig();
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
|
||||
useEffect(() => {
|
||||
if (!embeddedMode || !isEmbedded()) return;
|
||||
|
||||
const unsubscribe = listenFromParent((msg) => {
|
||||
switch (msg.type) {
|
||||
case "sso:trigger-login": {
|
||||
// Navigate to login page to start SSO flow
|
||||
const segments = window.location.pathname.split("/").filter(Boolean);
|
||||
const locale = segments[0] || "en";
|
||||
window.location.href = `/${locale}/login`;
|
||||
break;
|
||||
}
|
||||
case "sso:trigger-logout":
|
||||
logout();
|
||||
break;
|
||||
}
|
||||
}, parentOrigin || undefined);
|
||||
|
||||
return unsubscribe;
|
||||
}, [embeddedMode, parentOrigin, logout]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -8,13 +8,21 @@ import { formatFileSize } from '@/lib/utils';
|
||||
|
||||
export function AccountSettings() {
|
||||
const t = useTranslations('settings.account');
|
||||
const { username, serverUrl } = useAuthStore();
|
||||
const { username, serverUrl, isDemoMode, primaryIdentity } = useAuthStore();
|
||||
const { quota } = useEmailStore();
|
||||
|
||||
const quotaPercentage = quota ? Math.round((quota.used / quota.total) * 100) : 0;
|
||||
const displayName = primaryIdentity?.name || (isDemoMode ? 'Demo User' : undefined);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
{/* Display Name (show in demo mode or when identity has a name) */}
|
||||
{displayName && (
|
||||
<SettingItem label={t('name_label')}>
|
||||
<span className="text-sm text-foreground">{displayName}</span>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Email Address */}
|
||||
<SettingItem label={t('email.label')}>
|
||||
<span className="text-sm text-foreground">{username || t('../../common.unknown')}</span>
|
||||
@@ -49,6 +57,16 @@ export function AccountSettings() {
|
||||
</div>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{/* Demo mode indicator */}
|
||||
{isDemoMode && (
|
||||
<SettingItem label={t('account_type_label')}>
|
||||
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-amber-600 dark:text-amber-400">
|
||||
<span className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
|
||||
{t('demo_account')}
|
||||
</span>
|
||||
</SettingItem>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import { useSettingsStore, type ToolbarPosition, type Density } from '@/stores/s
|
||||
import { LanguageSwitcher } from '@/components/ui/language-switcher';
|
||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTour } from '@/components/tour/tour-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PlayCircle } from 'lucide-react';
|
||||
|
||||
const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = {
|
||||
'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false },
|
||||
@@ -61,8 +64,10 @@ function DensityPreview({ density }: { density: Density }) {
|
||||
|
||||
export function AppearanceSettings() {
|
||||
const t = useTranslations('settings.appearance');
|
||||
const tTour = useTranslations('tour');
|
||||
const { theme, setTheme } = useThemeStore();
|
||||
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, updateSetting } = useSettingsStore();
|
||||
const { startTour, resetTourCompletion } = useTour();
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
@@ -141,6 +146,19 @@ export function AppearanceSettings() {
|
||||
onChange={(checked) => updateSetting('animationsEnabled', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Restart Tour */}
|
||||
<SettingItem label={tTour('restart_title')} description={tTour('restart_desc')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { resetTourCompletion(); startTour(); }}
|
||||
className="text-xs h-7"
|
||||
>
|
||||
<PlayCircle className="w-3.5 h-3.5 mr-1" />
|
||||
{tTour('restart_button')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,9 +15,12 @@ export function CalendarSettings() {
|
||||
timeFormat,
|
||||
firstDayOfWeek,
|
||||
showTimeInMonthView,
|
||||
showWeekNumbers,
|
||||
calendarNotificationsEnabled,
|
||||
calendarNotificationSound,
|
||||
calendarInvitationParsingEnabled,
|
||||
enableCalendarTasks,
|
||||
showTasksOnCalendar,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
|
||||
@@ -68,6 +71,38 @@ export function CalendarSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('show_week_numbers')}
|
||||
description={t('show_week_numbers_desc')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={showWeekNumbers}
|
||||
onChange={(checked) => updateSetting('showWeekNumbers', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('enable_tasks')}
|
||||
description={t('enable_tasks_desc')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={enableCalendarTasks}
|
||||
onChange={(checked) => updateSetting('enableCalendarTasks', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{enableCalendarTasks && (
|
||||
<SettingItem
|
||||
label={t('show_tasks_on_calendar')}
|
||||
description={t('show_tasks_on_calendar_desc')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={showTasksOnCalendar}
|
||||
onChange={(checked) => updateSetting('showTasksOnCalendar', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
<SettingItem
|
||||
label={t('notifications_enabled')}
|
||||
description={t('notifications_enabled_desc')}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import type { ArchiveMode } from '@/stores/settings-store';
|
||||
import type { ArchiveMode, HoverAction } from '@/stores/settings-store';
|
||||
import { ALL_HOVER_ACTIONS } from '@/stores/settings-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
|
||||
import { ChevronRight, AlertTriangle, FolderSync, Loader2 } from 'lucide-react';
|
||||
@@ -27,6 +29,7 @@ export function EmailSettings() {
|
||||
attachmentPosition,
|
||||
emailAlwaysLightMode,
|
||||
archiveMode,
|
||||
hoverActions,
|
||||
trustedSenders,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
@@ -185,6 +188,39 @@ export function EmailSettings() {
|
||||
<ToggleSwitch checked={showPreview} onChange={(checked) => updateSetting('showPreview', checked)} />
|
||||
</SettingItem>
|
||||
|
||||
{/* Quick Hover Actions */}
|
||||
<div className="py-3 border-b border-border space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">{t('hover_actions.label')}</label>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('hover_actions.description')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ALL_HOVER_ACTIONS.map((action) => {
|
||||
const isEnabled = hoverActions.includes(action.id);
|
||||
return (
|
||||
<button
|
||||
key={action.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newActions = isEnabled
|
||||
? hoverActions.filter((a: HoverAction) => a !== action.id)
|
||||
: [...hoverActions, action.id];
|
||||
updateSetting('hoverActions', newActions);
|
||||
}}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-xs rounded-md transition-colors duration-150',
|
||||
isEnabled
|
||||
? 'bg-primary text-primary-foreground font-medium'
|
||||
: 'bg-muted hover:bg-accent text-foreground'
|
||||
)}
|
||||
>
|
||||
{t(`hover_actions.${action.labelKey}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingItem label={t('attachment_click_action.label')} description={t('attachment_click_action.description')}>
|
||||
<Select
|
||||
value={mailAttachmentAction}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SieveEditorModal } from "@/components/filters/sieve-editor-modal";
|
||||
import { useFilterStore } from "@/stores/filter-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import type { FilterRule } from "@/lib/jmap/sieve-types";
|
||||
import {
|
||||
@@ -25,31 +26,98 @@ import {
|
||||
function RuleSummary({ rule }: { rule: FilterRule }) {
|
||||
const t = useTranslations("settings.filters");
|
||||
|
||||
const conditionSummary = rule.conditions
|
||||
.slice(0, 2)
|
||||
.map((c) => {
|
||||
const conditions = rule.conditions.slice(0, 2).map((c) => {
|
||||
const field = t(`condition_fields.${c.field}`);
|
||||
const comparator = t(`comparators.${c.comparator}`);
|
||||
return `${field} ${comparator} "${c.value}"`;
|
||||
})
|
||||
.join(rule.matchType === "all" ? ` ${t("and")} ` : ` ${t("or")} `);
|
||||
});
|
||||
|
||||
const joiner = rule.matchType === "all" ? t("and") : t("or");
|
||||
|
||||
const extra = rule.conditions.length > 2
|
||||
? ` (+${rule.conditions.length - 2})`
|
||||
: "";
|
||||
|
||||
const actionSummary = rule.actions
|
||||
.slice(0, 2)
|
||||
.map((a) => {
|
||||
const actions = rule.actions.slice(0, 2).map((a) => {
|
||||
const action = t(`action_types.${a.type}`);
|
||||
return a.value ? `${action} "${a.value}"` : action;
|
||||
})
|
||||
.join(", ");
|
||||
});
|
||||
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{conditionSummary}{extra} → {actionSummary}
|
||||
<div className="text-xs text-muted-foreground break-words">
|
||||
<span className="inline">
|
||||
{conditions.map((cond, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && <span className="italic opacity-70"> {joiner} </span>}
|
||||
{cond}
|
||||
</span>
|
||||
))}
|
||||
{extra}
|
||||
</span>
|
||||
<span className="mx-1 opacity-50">→</span>
|
||||
<span className="inline">
|
||||
{actions.map((act, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && ", "}
|
||||
{act}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VisualRuleSummary({ rule }: { rule: FilterRule }) {
|
||||
const t = useTranslations("settings.filters");
|
||||
|
||||
const joiner = rule.matchType === "all" ? t("and") : t("or");
|
||||
const matchLabel = rule.matchType === "all" ? t("match_all_conditions") : t("match_any_condition");
|
||||
|
||||
return (
|
||||
<div className="mt-1.5 space-y-1 text-xs">
|
||||
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-blue-500 dark:text-blue-400">
|
||||
{t("if")}
|
||||
</span>
|
||||
{rule.conditions.map((c, i) => {
|
||||
const field = t(`condition_fields.${c.field}`);
|
||||
const comparator = t(`comparators.${c.comparator}`);
|
||||
return (
|
||||
<span key={i} className="contents">
|
||||
{i > 0 && (
|
||||
<span className="text-[10px] text-muted-foreground/70 italic">{joiner}</span>
|
||||
)}
|
||||
<span className="inline-flex items-baseline gap-1 px-1.5 py-px rounded-sm bg-muted/60 text-foreground">
|
||||
<span className="font-medium text-blue-600 dark:text-blue-400">{field}</span>
|
||||
<span className="text-muted-foreground">{comparator}</span>
|
||||
<span className="text-foreground">“{c.value}”</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<span className="text-[10px] text-muted-foreground/60 italic">({matchLabel})</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-emerald-500 dark:text-emerald-400">
|
||||
{t("then")}
|
||||
</span>
|
||||
{rule.actions.map((a, i) => {
|
||||
const action = t(`action_types.${a.type}`);
|
||||
return (
|
||||
<span key={i} className="contents">
|
||||
{i > 0 && (
|
||||
<span className="text-muted-foreground/50">›</span>
|
||||
)}
|
||||
<span className="inline-flex items-baseline gap-1 px-1.5 py-px rounded-sm bg-muted/60 text-foreground">
|
||||
<span className="font-medium text-emerald-600 dark:text-emerald-400">{action}</span>
|
||||
{a.value && <span className="text-muted-foreground">“{a.value}”</span>}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,6 +126,8 @@ export function FilterSettings() {
|
||||
const tNotifications = useTranslations("notifications");
|
||||
const { client } = useAuthStore();
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const expandedFilterView = useSettingsStore((s) => s.expandedFilterView);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
|
||||
const {
|
||||
rules,
|
||||
@@ -337,23 +407,25 @@ export function FilterSettings() {
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`flex items-center gap-3 p-3 rounded-md border transition-colors ${
|
||||
className={`flex items-start gap-3 p-3 rounded-md border transition-colors ${
|
||||
dragOverIndex === index
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:bg-muted/50"
|
||||
} ${!rule.enabled ? "opacity-60" : ""}`}
|
||||
>
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground"
|
||||
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground pt-0.5"
|
||||
aria-label={t("drag_to_reorder")}
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<div className="pt-0.5">
|
||||
<ToggleSwitch
|
||||
checked={rule.enabled}
|
||||
onChange={() => handleToggle(rule.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-1 min-w-0 cursor-pointer"
|
||||
@@ -374,7 +446,11 @@ export function FilterSettings() {
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{rule.name}
|
||||
</p>
|
||||
{expandedFilterView ? (
|
||||
<VisualRuleSummary rule={rule} />
|
||||
) : (
|
||||
<RuleSummary rule={rule} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deleteConfirmId === rule.id ? (
|
||||
@@ -435,12 +511,23 @@ export function FilterSettings() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{isSaving && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t("saving")}
|
||||
</div>
|
||||
)}
|
||||
{!isOpaque && rules.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{t("expanded_view")}</span>
|
||||
<ToggleSwitch
|
||||
checked={expandedFilterView}
|
||||
onChange={(v) => updateSetting("expandedFilterView", v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showRuleModal && (
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { SettingsSection } from "./settings-section";
|
||||
import { Plus, Pencil, Trash2, GripVertical, Check, X, RotateCcw } from "lucide-react";
|
||||
import { Plus, Pencil, Trash2, GripVertical, Check, X, RotateCcw, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PALETTE_KEYS = Object.keys(KEYWORD_PALETTE);
|
||||
@@ -91,16 +93,14 @@ function KeywordEditForm({
|
||||
const [color, setColor] = useState(initial?.color || "blue");
|
||||
const isEditing = !!initial;
|
||||
|
||||
const normalizedId = isEditing
|
||||
? initial.id
|
||||
: label
|
||||
const normalizedId = label
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
|
||||
const isDuplicate = !isEditing && normalizedId.length > 0 && existingIds.includes(normalizedId);
|
||||
const isDuplicate = normalizedId.length > 0 && existingIds.includes(normalizedId);
|
||||
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate;
|
||||
|
||||
const handleSave = () => {
|
||||
@@ -159,10 +159,13 @@ function KeywordEditForm({
|
||||
|
||||
export function KeywordSettings() {
|
||||
const t = useTranslations("settings.keywords");
|
||||
const { emailKeywords, addKeyword, updateKeyword, removeKeyword, reorderKeywords } =
|
||||
const { emailKeywords, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords } =
|
||||
useSettingsStore();
|
||||
const { client } = useAuthStore();
|
||||
const { fetchTagCounts } = useEmailStore();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [isMigrating, setIsMigrating] = useState(false);
|
||||
|
||||
const existingIds = emailKeywords.map((k) => k.id);
|
||||
|
||||
@@ -171,8 +174,32 @@ export function KeywordSettings() {
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
const handleEdit = (keyword: KeywordDefinition) => {
|
||||
updateKeyword(keyword.id, { label: keyword.label, color: keyword.color });
|
||||
const handleEdit = async (keyword: KeywordDefinition) => {
|
||||
const oldId = editingId;
|
||||
if (!oldId) return;
|
||||
|
||||
const idChanged = oldId !== keyword.id;
|
||||
|
||||
if (idChanged && client) {
|
||||
setIsMigrating(true);
|
||||
try {
|
||||
const oldJmapKeyword = `$label:${oldId}`;
|
||||
const newJmapKeyword = `$label:${keyword.id}`;
|
||||
await client.migrateKeyword(oldJmapKeyword, newJmapKeyword);
|
||||
renameKeyword(oldId, keyword);
|
||||
fetchTagCounts(client);
|
||||
} catch (error) {
|
||||
console.error("Failed to migrate keyword:", error);
|
||||
const toastModule = await import('sonner');
|
||||
toastModule.toast.error(t("migration_error"));
|
||||
setIsMigrating(false);
|
||||
return;
|
||||
}
|
||||
setIsMigrating(false);
|
||||
} else {
|
||||
updateKeyword(oldId, { label: keyword.label, color: keyword.color });
|
||||
}
|
||||
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
@@ -187,6 +214,12 @@ export function KeywordSettings() {
|
||||
return (
|
||||
<SettingsSection title={t("title")} description={t("description")}>
|
||||
<div className="space-y-2">
|
||||
{isMigrating && (
|
||||
<div className="flex items-center gap-2 p-2 text-xs text-muted-foreground bg-accent/50 rounded-md">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
{t("migrating")}
|
||||
</div>
|
||||
)}
|
||||
{emailKeywords.map((keyword) =>
|
||||
editingId === keyword.id ? (
|
||||
<KeywordEditForm
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTour } from "./tour-provider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
|
||||
interface Rect {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const PADDING = 8;
|
||||
const TOOLTIP_GAP = 12;
|
||||
const TOOLTIP_MAX_W = 360;
|
||||
|
||||
function getTargetRect(selector: string): Rect | null {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
// Element might exist but be hidden (zero dimensions)
|
||||
if (r.width === 0 && r.height === 0) return null;
|
||||
return { top: r.top, left: r.left, width: r.width, height: r.height };
|
||||
}
|
||||
|
||||
function computeTooltipPosition(
|
||||
target: Rect,
|
||||
placement: "top" | "bottom" | "left" | "right",
|
||||
tooltipSize: { width: number; height: number }
|
||||
): { top: number; left: number; actualPlacement: string } {
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const tw = Math.max(tooltipSize.width, 200); // minimum fallback width
|
||||
const th = Math.max(tooltipSize.height, 100); // minimum fallback height
|
||||
|
||||
const positions = {
|
||||
bottom: {
|
||||
top: target.top + target.height + PADDING + TOOLTIP_GAP,
|
||||
left: target.left + target.width / 2 - tw / 2,
|
||||
},
|
||||
top: {
|
||||
top: target.top - PADDING - TOOLTIP_GAP - th,
|
||||
left: target.left + target.width / 2 - tw / 2,
|
||||
},
|
||||
right: {
|
||||
top: target.top + target.height / 2 - th / 2,
|
||||
left: target.left + target.width + PADDING + TOOLTIP_GAP,
|
||||
},
|
||||
left: {
|
||||
top: target.top + target.height / 2 - th / 2,
|
||||
left: target.left - PADDING - TOOLTIP_GAP - tw,
|
||||
},
|
||||
};
|
||||
|
||||
const fits = (p: { top: number; left: number }) =>
|
||||
p.top >= 8 && p.left >= 8 && p.top + th <= vh - 8 && p.left + tw <= vw - 8;
|
||||
|
||||
// Try preferred placement first, then fallback order
|
||||
const order: Array<"top" | "bottom" | "left" | "right"> = [placement, "bottom", "right", "left", "top"];
|
||||
for (const dir of order) {
|
||||
const pos = positions[dir];
|
||||
if (fits(pos)) return { ...pos, actualPlacement: dir };
|
||||
}
|
||||
|
||||
// If nothing fits perfectly, use preferred but clamped
|
||||
const pos = positions[placement];
|
||||
return {
|
||||
top: Math.max(8, Math.min(pos.top, vh - th - 8)),
|
||||
left: Math.max(8, Math.min(pos.left, vw - tw - 8)),
|
||||
actualPlacement: placement,
|
||||
};
|
||||
}
|
||||
|
||||
export function TourOverlay() {
|
||||
const t = useTranslations();
|
||||
const { currentStep, totalSteps, steps, nextStep, prevStep, stopTour } = useTour();
|
||||
const step = steps[currentStep];
|
||||
|
||||
const [targetRect, setTargetRect] = useState<Rect | null>(null);
|
||||
const [tooltipPos, setTooltipPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Use refs for callbacks to avoid stale closures in timers/intervals
|
||||
const updatePositionRef = useRef<() => void>(() => {});
|
||||
const nextStepRef = useRef<() => void>(() => {});
|
||||
nextStepRef.current = nextStep;
|
||||
|
||||
const focusTrapRef = useFocusTrap({
|
||||
isActive: visible,
|
||||
onEscape: stopTour,
|
||||
restoreFocus: true,
|
||||
});
|
||||
|
||||
// Set mounted for portal
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!step) return;
|
||||
const rect = getTargetRect(step.target);
|
||||
|
||||
if (rect) {
|
||||
setTargetRect(rect);
|
||||
if (tooltipRef.current) {
|
||||
const { width, height } = tooltipRef.current.getBoundingClientRect();
|
||||
const pos = computeTooltipPosition(rect, step.placement, { width, height });
|
||||
setTooltipPos({ top: pos.top, left: pos.left });
|
||||
}
|
||||
}
|
||||
// If rect is null, keep previous targetRect (element temporarily hidden during scroll/resize)
|
||||
// Only the step-change effect should null out targetRect
|
||||
}, [step]);
|
||||
|
||||
// Keep ref in sync
|
||||
updatePositionRef.current = updatePosition;
|
||||
|
||||
// Wait for target element to appear, then show
|
||||
useEffect(() => {
|
||||
if (!step) return;
|
||||
console.log(`[Tour] Step ${currentStep + 1}/${totalSteps}: "${step.id}" — target: ${step.target}, placement: ${step.placement}, interactive: ${!!step.interactive}`);
|
||||
setVisible(false);
|
||||
// Keep old targetRect and tooltipPos so the cutout/tooltip animate to the new position
|
||||
// instead of disappearing and reappearing
|
||||
|
||||
// Clear any pending timer from a previous step
|
||||
if (pendingTimerRef.current) {
|
||||
clearTimeout(pendingTimerRef.current);
|
||||
pendingTimerRef.current = null;
|
||||
}
|
||||
|
||||
// Run beforeAction if defined (e.g. click an email to open the viewer)
|
||||
if (step.beforeAction) {
|
||||
step.beforeAction();
|
||||
}
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 50; // 5 seconds
|
||||
let cancelled = false;
|
||||
|
||||
const tryFind = () => {
|
||||
if (cancelled) return true;
|
||||
const el = document.querySelector(step.target);
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
console.log(`[Tour] Step ${currentStep + 1} "${step.id}": element FOUND (${rect.width}x${rect.height} at ${Math.round(rect.left)},${Math.round(rect.top)})`);
|
||||
el.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
// Delay after scroll for layout to settle
|
||||
pendingTimerRef.current = setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
console.log(`[Tour] Step ${currentStep + 1} "${step.id}": showing tooltip`);
|
||||
updatePositionRef.current();
|
||||
setVisible(true);
|
||||
// Second position update after tooltip renders with final dimensions
|
||||
requestAnimationFrame(() => {
|
||||
if (!cancelled) updatePositionRef.current();
|
||||
});
|
||||
}, 200);
|
||||
return true;
|
||||
}
|
||||
if (attempts % 10 === 0) {
|
||||
console.log(`[Tour] Step ${currentStep + 1} "${step.id}": element NOT found (attempt ${attempts + 1}/${maxAttempts})`);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (tryFind()) return () => { cancelled = true; };
|
||||
|
||||
// Poll for element appearance (for page navigation)
|
||||
const interval = setInterval(() => {
|
||||
attempts++;
|
||||
if (tryFind() || attempts >= maxAttempts) {
|
||||
clearInterval(interval);
|
||||
if (attempts >= maxAttempts && !cancelled) {
|
||||
// Skip this step if element never appears
|
||||
console.warn(`[Tour] Step ${currentStep + 1} "${step.id}": SKIPPED — element never appeared after ${maxAttempts} attempts`);
|
||||
nextStepRef.current();
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
if (pendingTimerRef.current) {
|
||||
clearTimeout(pendingTimerRef.current);
|
||||
pendingTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [step, currentStep]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Recalculate on resize/scroll (debounced)
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
let rafId: number | null = null;
|
||||
const handler = () => {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(() => {
|
||||
updatePosition();
|
||||
});
|
||||
};
|
||||
window.addEventListener("resize", handler);
|
||||
window.addEventListener("scroll", handler, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", handler);
|
||||
window.removeEventListener("scroll", handler, true);
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [visible, updatePosition]);
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "ArrowRight" || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
nextStep();
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
prevStep();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
stopTour();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler, true);
|
||||
return () => window.removeEventListener("keydown", handler, true);
|
||||
}, [nextStep, prevStep, stopTour]);
|
||||
|
||||
// Re-position after tooltip content renders with new dimensions
|
||||
useEffect(() => {
|
||||
if (!visible || !tooltipRef.current) return;
|
||||
// Use rAF to wait for the browser to lay out the tooltip content
|
||||
const id = requestAnimationFrame(() => {
|
||||
updatePosition();
|
||||
});
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [visible, updatePosition, currentStep]);
|
||||
|
||||
if (!mounted || !step) return null;
|
||||
|
||||
const cutout = targetRect
|
||||
? {
|
||||
x: targetRect.left - PADDING,
|
||||
y: targetRect.top - PADDING,
|
||||
w: targetRect.width + PADDING * 2,
|
||||
h: targetRect.height + PADDING * 2,
|
||||
}
|
||||
: null;
|
||||
|
||||
const isLast = currentStep >= totalSteps - 1;
|
||||
const isFirst = currentStep === 0;
|
||||
const isInteractive = step.interactive;
|
||||
|
||||
const reducedMotion =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
const transitionStyle = reducedMotion ? "none" : "all 300ms ease";
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
{/* SVG overlay with cutout */}
|
||||
<svg
|
||||
className="fixed inset-0 z-[9998]"
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ pointerEvents: isInteractive ? "none" : "auto" }}
|
||||
onClick={stopTour}
|
||||
>
|
||||
<defs>
|
||||
<mask id="tour-mask">
|
||||
<rect fill="white" width="100%" height="100%" />
|
||||
{cutout && (
|
||||
<rect
|
||||
fill="black"
|
||||
x={cutout.x}
|
||||
y={cutout.y}
|
||||
width={cutout.w}
|
||||
height={cutout.h}
|
||||
rx="8"
|
||||
style={{ transition: transitionStyle }}
|
||||
/>
|
||||
)}
|
||||
</mask>
|
||||
</defs>
|
||||
<rect
|
||||
fill="black"
|
||||
opacity="0.5"
|
||||
mask="url(#tour-mask)"
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Click-through cutout zone for interactive steps */}
|
||||
{isInteractive && cutout && (
|
||||
<div
|
||||
className="fixed z-[9998]"
|
||||
style={{
|
||||
top: cutout.y,
|
||||
left: cutout.x,
|
||||
width: cutout.w,
|
||||
height: cutout.h,
|
||||
pointerEvents: "none",
|
||||
transition: transitionStyle,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Non-interactive overlay click blocker around cutout */}
|
||||
{!isInteractive && cutout && (
|
||||
<div
|
||||
className="fixed z-[9998]"
|
||||
style={{
|
||||
top: cutout.y,
|
||||
left: cutout.x,
|
||||
width: cutout.w,
|
||||
height: cutout.h,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tooltip */}
|
||||
<div
|
||||
ref={(node) => {
|
||||
(tooltipRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
(focusTrapRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t(step.titleKey)}
|
||||
className={cn(
|
||||
"fixed z-[9999] transition-all",
|
||||
visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2"
|
||||
)}
|
||||
style={{
|
||||
top: tooltipPos?.top ?? -9999,
|
||||
left: tooltipPos?.left ?? -9999,
|
||||
maxWidth: TOOLTIP_MAX_W,
|
||||
transition: reducedMotion ? "none" : "opacity 200ms ease, transform 200ms ease, top 300ms ease, left 300ms ease",
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="bg-background border border-border rounded-xl shadow-2xl p-4">
|
||||
{/* Step counter */}
|
||||
<p className="text-xs text-muted-foreground mb-1" aria-live="polite">
|
||||
{t("tour.step_counter", { current: currentStep + 1, total: totalSteps })}
|
||||
</p>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="font-semibold text-sm text-foreground">{t(step.titleKey)}</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-muted-foreground mt-1">{t(step.descriptionKey)}</p>
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<button
|
||||
onClick={stopTour}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded hover:bg-muted"
|
||||
>
|
||||
{t("tour.skip")}
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={prevStep}
|
||||
disabled={isFirst}
|
||||
className={cn(
|
||||
"text-xs px-3 py-1.5 rounded-md border border-border transition-colors",
|
||||
isFirst
|
||||
? "opacity-40 cursor-not-allowed"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{t("tour.back")}
|
||||
</button>
|
||||
<button
|
||||
onClick={nextStep}
|
||||
className="text-xs px-3 py-1.5 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{isLast ? t("tour.finish") : t("tour.next")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress dots */}
|
||||
<div className="flex justify-center gap-1 mt-2">
|
||||
{steps.map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
"w-1.5 h-1.5 rounded-full transition-colors",
|
||||
i === currentStep ? "bg-primary" : "bg-muted-foreground/30"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useWebDAVStore } from "@/stores/webdav-store";
|
||||
import { getTourSteps, type TourStep } from "./tour-steps";
|
||||
import { TourOverlay } from "./tour-overlay";
|
||||
|
||||
const TOUR_COMPLETED_KEY = "tour_completed";
|
||||
const TOUR_CURRENT_STEP_KEY = "tour_current_step";
|
||||
|
||||
interface TourContextValue {
|
||||
isActive: boolean;
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
steps: TourStep[];
|
||||
startTour: () => void;
|
||||
stopTour: () => void;
|
||||
nextStep: () => void;
|
||||
prevStep: () => void;
|
||||
hasCompletedTour: boolean;
|
||||
resetTourCompletion: () => void;
|
||||
}
|
||||
|
||||
const TourContext = createContext<TourContextValue | null>(null);
|
||||
|
||||
export function useTour() {
|
||||
const ctx = useContext(TourContext);
|
||||
if (!ctx) throw new Error("useTour must be used within TourProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function TourProvider({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const { isDemoMode } = useAuthStore();
|
||||
const { supportsCalendar } = useCalendarStore();
|
||||
const { supportsWebDAV } = useWebDAVStore();
|
||||
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [hasCompletedTour, setHasCompletedTour] = useState(false);
|
||||
|
||||
const steps = getTourSteps({ isDemoMode, supportsCalendar, supportsWebDAV: supportsWebDAV !== false });
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setHasCompletedTour(localStorage.getItem(TOUR_COMPLETED_KEY) === "true");
|
||||
} catch { /* */ }
|
||||
}, []);
|
||||
|
||||
const startTour = useCallback(() => {
|
||||
let resumeStep = 0;
|
||||
try {
|
||||
const stored = localStorage.getItem(TOUR_CURRENT_STEP_KEY);
|
||||
if (stored) {
|
||||
const parsed = parseInt(stored, 10);
|
||||
if (!isNaN(parsed) && parsed >= 0) resumeStep = parsed;
|
||||
}
|
||||
} catch { /* */ }
|
||||
|
||||
// If the resume step is beyond the current steps, start from 0
|
||||
if (resumeStep >= steps.length) resumeStep = 0;
|
||||
|
||||
setCurrentStep(resumeStep);
|
||||
setIsActive(true);
|
||||
}, [steps.length]);
|
||||
|
||||
const stopTour = useCallback(() => {
|
||||
setIsActive(false);
|
||||
try {
|
||||
localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
|
||||
} catch { /* */ }
|
||||
}, []);
|
||||
|
||||
const completeTour = useCallback(() => {
|
||||
setIsActive(false);
|
||||
setHasCompletedTour(true);
|
||||
try {
|
||||
localStorage.setItem(TOUR_COMPLETED_KEY, "true");
|
||||
localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
|
||||
} catch { /* */ }
|
||||
}, []);
|
||||
|
||||
const nextStep = useCallback(() => {
|
||||
if (currentStep >= steps.length - 1) {
|
||||
completeTour();
|
||||
return;
|
||||
}
|
||||
const next = currentStep + 1;
|
||||
const nextStepDef = steps[next];
|
||||
setCurrentStep(next);
|
||||
try {
|
||||
localStorage.setItem(TOUR_CURRENT_STEP_KEY, String(next));
|
||||
} catch { /* */ }
|
||||
|
||||
// Navigate if the next step requires a different page
|
||||
if (nextStepDef?.page) {
|
||||
router.push(nextStepDef.page);
|
||||
}
|
||||
}, [currentStep, steps, completeTour, router]);
|
||||
|
||||
const prevStep = useCallback(() => {
|
||||
if (currentStep <= 0) return;
|
||||
const prev = currentStep - 1;
|
||||
const prevStepDef = steps[prev];
|
||||
setCurrentStep(prev);
|
||||
try {
|
||||
localStorage.setItem(TOUR_CURRENT_STEP_KEY, String(prev));
|
||||
} catch { /* */ }
|
||||
|
||||
if (prevStepDef?.page) {
|
||||
router.push(prevStepDef.page);
|
||||
} else if (steps[currentStep]?.page) {
|
||||
// Going back from a page-specific step to a non-page step => go to mail
|
||||
router.push("/");
|
||||
}
|
||||
}, [currentStep, steps, router]);
|
||||
|
||||
const resetTourCompletion = useCallback(() => {
|
||||
setHasCompletedTour(false);
|
||||
try {
|
||||
localStorage.removeItem(TOUR_COMPLETED_KEY);
|
||||
localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
|
||||
} catch { /* */ }
|
||||
}, []);
|
||||
|
||||
const value: TourContextValue = {
|
||||
isActive,
|
||||
currentStep,
|
||||
totalSteps: steps.length,
|
||||
steps,
|
||||
startTour,
|
||||
stopTour,
|
||||
nextStep,
|
||||
prevStep,
|
||||
hasCompletedTour,
|
||||
resetTourCompletion,
|
||||
};
|
||||
|
||||
return (
|
||||
<TourContext.Provider value={value}>
|
||||
{children}
|
||||
{isActive && <TourOverlay />}
|
||||
</TourContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
export interface TourStep {
|
||||
id: string;
|
||||
target: string;
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
placement: "top" | "bottom" | "left" | "right";
|
||||
interactive?: boolean;
|
||||
spotlight?: "rect" | "circle";
|
||||
page?: string;
|
||||
demoOnly?: boolean;
|
||||
beforeAction?: () => void;
|
||||
}
|
||||
|
||||
export const BASE_TOUR_STEPS: TourStep[] = [
|
||||
{
|
||||
id: "sidebar",
|
||||
target: '[data-tour="sidebar"]',
|
||||
titleKey: "tour.sidebar_title",
|
||||
descriptionKey: "tour.sidebar_desc",
|
||||
placement: "right",
|
||||
},
|
||||
{
|
||||
id: "compose",
|
||||
target: '[data-tour="compose-button"]',
|
||||
titleKey: "tour.compose_title",
|
||||
descriptionKey: "tour.compose_desc",
|
||||
placement: "right",
|
||||
interactive: true,
|
||||
},
|
||||
{
|
||||
id: "search",
|
||||
target: '[data-tour="search-input"]',
|
||||
titleKey: "tour.search_title",
|
||||
descriptionKey: "tour.search_desc",
|
||||
placement: "bottom",
|
||||
interactive: true,
|
||||
},
|
||||
{
|
||||
id: "email-list",
|
||||
target: '[data-tour="email-list"]',
|
||||
titleKey: "tour.email_list_title",
|
||||
descriptionKey: "tour.email_list_desc",
|
||||
placement: "right",
|
||||
},
|
||||
{
|
||||
id: "email-viewer",
|
||||
target: '[data-tour="email-viewer"]',
|
||||
titleKey: "tour.email_viewer_title",
|
||||
descriptionKey: "tour.email_viewer_desc",
|
||||
placement: "left",
|
||||
beforeAction: () => {
|
||||
// Click the "Welcome to Bulwark Mail!" email (or the first email) to open the viewer
|
||||
const emailList = document.querySelector('[data-tour="email-list"]');
|
||||
if (!emailList) return;
|
||||
// Try to find the welcome email by subject text
|
||||
const items = emailList.querySelectorAll('.cursor-pointer');
|
||||
let target: HTMLElement | null = null;
|
||||
for (const item of items) {
|
||||
if (item.textContent?.includes("Welcome to Bulwark Mail")) {
|
||||
target = item as HTMLElement;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Fallback to first email if welcome email not found
|
||||
if (!target) target = emailList.querySelector('.cursor-pointer') as HTMLElement | null;
|
||||
if (target) target.click();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "keywords",
|
||||
target: '[data-tour="keyword-tags"]',
|
||||
titleKey: "tour.keywords_title",
|
||||
descriptionKey: "tour.keywords_desc",
|
||||
placement: "right",
|
||||
},
|
||||
{
|
||||
id: "nav-calendar",
|
||||
target: '[data-tour="nav-calendar"]',
|
||||
titleKey: "tour.calendar_title",
|
||||
descriptionKey: "tour.calendar_desc",
|
||||
placement: "right",
|
||||
},
|
||||
{
|
||||
id: "nav-contacts",
|
||||
target: '[data-tour="nav-contacts"]',
|
||||
titleKey: "tour.contacts_title",
|
||||
descriptionKey: "tour.contacts_desc",
|
||||
placement: "right",
|
||||
},
|
||||
{
|
||||
id: "nav-settings",
|
||||
target: '[data-tour="nav-settings"]',
|
||||
titleKey: "tour.settings_title",
|
||||
descriptionKey: "tour.settings_desc",
|
||||
placement: "right",
|
||||
},
|
||||
{
|
||||
id: "shortcuts",
|
||||
target: '[data-tour="nav-shortcuts"]',
|
||||
titleKey: "tour.shortcuts_title",
|
||||
descriptionKey: "tour.shortcuts_desc",
|
||||
placement: "right",
|
||||
interactive: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const DEMO_TOUR_STEPS: TourStep[] = [
|
||||
{
|
||||
id: "compose-open",
|
||||
target: '[data-tour="composer"]',
|
||||
titleKey: "tour.compose_open_title",
|
||||
descriptionKey: "tour.compose_open_desc",
|
||||
placement: "left",
|
||||
demoOnly: true,
|
||||
beforeAction: () => {
|
||||
// Click the compose button to open the composer
|
||||
const btn = document.querySelector('[data-tour="compose-button"]') as HTMLElement | null;
|
||||
if (btn) btn.click();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "calendar-view",
|
||||
target: '[data-tour="calendar-view"]',
|
||||
titleKey: "tour.calendar_view_title",
|
||||
descriptionKey: "tour.calendar_view_desc",
|
||||
placement: "bottom",
|
||||
page: "/calendar",
|
||||
demoOnly: true,
|
||||
},
|
||||
{
|
||||
id: "create-event",
|
||||
target: '[data-tour="create-event-button"]',
|
||||
titleKey: "tour.create_event_title",
|
||||
descriptionKey: "tour.create_event_desc",
|
||||
placement: "bottom",
|
||||
page: "/calendar",
|
||||
interactive: true,
|
||||
demoOnly: true,
|
||||
},
|
||||
{
|
||||
id: "event-modal",
|
||||
target: '[data-tour="event-modal"]',
|
||||
titleKey: "tour.event_modal_title",
|
||||
descriptionKey: "tour.event_modal_desc",
|
||||
placement: "left",
|
||||
page: "/calendar",
|
||||
interactive: true,
|
||||
demoOnly: true,
|
||||
beforeAction: () => {
|
||||
// Click the create event button to open the modal
|
||||
const btn = document.querySelector('[data-tour="create-event-button"]') as HTMLElement | null;
|
||||
if (btn) btn.click();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "contacts-list",
|
||||
target: '[data-tour="contacts-list"]',
|
||||
titleKey: "tour.contacts_list_title",
|
||||
descriptionKey: "tour.contacts_list_desc",
|
||||
placement: "right",
|
||||
page: "/contacts",
|
||||
demoOnly: true,
|
||||
},
|
||||
{
|
||||
id: "settings-tabs",
|
||||
target: '[data-tour="settings-tabs"]',
|
||||
titleKey: "tour.settings_tabs_title",
|
||||
descriptionKey: "tour.settings_tabs_desc",
|
||||
placement: "right",
|
||||
page: "/settings",
|
||||
demoOnly: true,
|
||||
},
|
||||
{
|
||||
id: "nav-files",
|
||||
target: '[data-tour="nav-files"]',
|
||||
titleKey: "tour.files_title",
|
||||
descriptionKey: "tour.files_desc",
|
||||
placement: "right",
|
||||
demoOnly: true,
|
||||
},
|
||||
{
|
||||
id: "demo-banner",
|
||||
target: '[data-tour="demo-banner"]',
|
||||
titleKey: "tour.demo_banner_title",
|
||||
descriptionKey: "tour.demo_banner_desc",
|
||||
placement: "bottom",
|
||||
page: "/",
|
||||
demoOnly: true,
|
||||
},
|
||||
{
|
||||
id: "quota",
|
||||
target: '[data-tour="storage-quota"]',
|
||||
titleKey: "tour.quota_title",
|
||||
descriptionKey: "tour.quota_desc",
|
||||
placement: "right",
|
||||
demoOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function getTourSteps(options: {
|
||||
isDemoMode: boolean;
|
||||
supportsCalendar: boolean;
|
||||
supportsWebDAV: boolean;
|
||||
}): TourStep[] {
|
||||
let steps = [...BASE_TOUR_STEPS];
|
||||
|
||||
if (!options.supportsCalendar) {
|
||||
steps = steps.filter((s) => s.id !== "nav-calendar");
|
||||
}
|
||||
|
||||
if (options.isDemoMode) {
|
||||
const demoSteps = DEMO_TOUR_STEPS.filter((s) => {
|
||||
if (s.id === "nav-files" && !options.supportsWebDAV) return false;
|
||||
if ((s.id === "calendar-view" || s.id === "create-event" || s.id === "event-modal") && !options.supportsCalendar) return false;
|
||||
return true;
|
||||
});
|
||||
steps = [...steps, ...demoSteps];
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
@@ -2,15 +2,17 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X, Lightbulb, Settings } from "lucide-react";
|
||||
import { X, Lightbulb, Settings, PlayCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useTour } from "@/components/tour/tour-provider";
|
||||
|
||||
const ONBOARDING_KEY = "onboarding_completed";
|
||||
|
||||
export function WelcomeBanner() {
|
||||
const t = useTranslations("welcome");
|
||||
const router = useRouter();
|
||||
const { startTour } = useTour();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
@@ -78,6 +80,15 @@ export function WelcomeBanner() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2.5 flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { dismiss(); startTour(); }}
|
||||
className="text-xs h-7"
|
||||
>
|
||||
<PlayCircle className="w-3.5 h-3.5 mr-1" />
|
||||
{t("start_tour")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -5,9 +5,10 @@ import { useTranslations, useLocale } from 'next-intl';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useCalendarStore } from '@/stores/calendar-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useTaskStore } from '@/stores/task-store';
|
||||
import { useCalendarNotificationStore } from '@/stores/calendar-notification-store';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
import { getPendingAlerts, buildAlertKey } from '@/lib/calendar-alerts';
|
||||
import { getPendingAlerts, getPendingTaskAlerts, buildAlertKey } from '@/lib/calendar-alerts';
|
||||
import { playNotificationSound } from '@/lib/notification-sound';
|
||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
@@ -18,7 +19,8 @@ const PROACTIVE_THROTTLE_MS = CHECK_INTERVAL_MS * 5;
|
||||
export function useCalendarAlerts() {
|
||||
const { isAuthenticated, client } = useAuthStore();
|
||||
const { events, calendars, supportsCalendar } = useCalendarStore();
|
||||
const { calendarNotificationsEnabled, calendarNotificationSound } = useSettingsStore();
|
||||
const { calendarNotificationsEnabled, calendarNotificationSound, enableCalendarTasks } = useSettingsStore();
|
||||
const { tasks: storeTasks } = useTaskStore();
|
||||
const { acknowledgedAlerts, acknowledgeAlert, cleanupStaleAlerts } = useCalendarNotificationStore();
|
||||
const addToast = useToastStore((s) => s.addToast);
|
||||
const t = useTranslations('calendar.notifications');
|
||||
@@ -69,6 +71,36 @@ export function useCalendarAlerts() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Task alerts
|
||||
if (enableCalendarTasks && storeTasks.length > 0) {
|
||||
const pendingTaskAlerts = getPendingTaskAlerts(storeTasks, calendars, acknowledgedKeys, now);
|
||||
for (const taskAlert of pendingTaskAlerts) {
|
||||
const key = buildAlertKey(taskAlert.taskId, taskAlert.alertId, taskAlert.fireTimeMs);
|
||||
if (shownKeysRef.current.has(key)) continue;
|
||||
|
||||
shownKeysRef.current.add(key);
|
||||
acknowledgeAlert(key, taskAlert.fireTimeMs);
|
||||
|
||||
if (calendarNotificationSound) {
|
||||
playNotificationSound();
|
||||
}
|
||||
|
||||
const taskMsg = taskAlert.calendarName
|
||||
? `${t('task_due')} · ${taskAlert.calendarName}`
|
||||
: t('task_due');
|
||||
|
||||
addToast({
|
||||
type: 'info',
|
||||
title: taskAlert.task.title || t('alert_title'),
|
||||
message: taskMsg,
|
||||
duration: 15000,
|
||||
onClick: () => {
|
||||
window.location.href = `/${locale}/calendar`;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore alert evaluation errors
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ interface ConfigData {
|
||||
loginImprintUrl: string;
|
||||
loginPrivacyPolicyUrl: string;
|
||||
loginWebsiteUrl: string;
|
||||
demoMode: boolean;
|
||||
autoSsoEnabled: boolean;
|
||||
embeddedMode: boolean;
|
||||
parentOrigin: string;
|
||||
}
|
||||
|
||||
interface AppConfig extends ConfigData {
|
||||
@@ -91,6 +95,10 @@ export function useConfig(): AppConfig {
|
||||
loginImprintUrl: configCache?.loginImprintUrl || '',
|
||||
loginPrivacyPolicyUrl: configCache?.loginPrivacyPolicyUrl || '',
|
||||
loginWebsiteUrl: configCache?.loginWebsiteUrl || '',
|
||||
demoMode: configCache?.demoMode || false,
|
||||
autoSsoEnabled: configCache?.autoSsoEnabled || false,
|
||||
embeddedMode: configCache?.embeddedMode || false,
|
||||
parentOrigin: configCache?.parentOrigin || '',
|
||||
isLoading: !configCache,
|
||||
error: null,
|
||||
});
|
||||
@@ -118,6 +126,10 @@ export function useConfig(): AppConfig {
|
||||
loginImprintUrl: configCache.loginImprintUrl,
|
||||
loginPrivacyPolicyUrl: configCache.loginPrivacyPolicyUrl,
|
||||
loginWebsiteUrl: configCache.loginWebsiteUrl,
|
||||
demoMode: configCache.demoMode,
|
||||
autoSsoEnabled: configCache.autoSsoEnabled,
|
||||
embeddedMode: configCache.embeddedMode,
|
||||
parentOrigin: configCache.parentOrigin,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
@@ -146,6 +158,10 @@ export function useConfig(): AppConfig {
|
||||
loginImprintUrl: data.loginImprintUrl,
|
||||
loginPrivacyPolicyUrl: data.loginPrivacyPolicyUrl,
|
||||
loginWebsiteUrl: data.loginWebsiteUrl,
|
||||
demoMode: data.demoMode,
|
||||
autoSsoEnabled: data.autoSsoEnabled,
|
||||
embeddedMode: data.embeddedMode,
|
||||
parentOrigin: data.parentOrigin,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
@@ -122,10 +122,15 @@ describe('JMAPClient contact methods', () => {
|
||||
describe('getContacts', () => {
|
||||
it('should return contacts from server', async () => {
|
||||
const client = createClient();
|
||||
mockFetch({
|
||||
const spy = vi.spyOn(globalThis, 'fetch');
|
||||
mockFetchOnce(spy, {
|
||||
methodResponses: [
|
||||
['ContactCard/query', { ids: ['contact-1'] }, '0'],
|
||||
['ContactCard/get', { list: [mockContact] }, '1'],
|
||||
['ContactCard/query', { ids: ['contact-1'] }, 'q'],
|
||||
],
|
||||
});
|
||||
mockFetchOnce(spy, {
|
||||
methodResponses: [
|
||||
['ContactCard/get', { list: [mockContact] }, 'g'],
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -48,3 +48,38 @@ export function decryptSession(token: string): { serverUrl: string; username: st
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function encryptPayload(payload: Record<string, unknown>): string {
|
||||
const key = getKey();
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||
|
||||
const json = JSON.stringify(payload);
|
||||
const encrypted = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return Buffer.concat([iv, tag, encrypted]).toString('base64');
|
||||
}
|
||||
|
||||
export function decryptPayload(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const key = getKey();
|
||||
const data = Buffer.from(token, 'base64');
|
||||
if (data.length < IV_LENGTH + TAG_LENGTH) return null;
|
||||
|
||||
const iv = data.subarray(0, IV_LENGTH);
|
||||
const tag = data.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
||||
const encrypted = data.subarray(IV_LENGTH + TAG_LENGTH);
|
||||
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||
return JSON.parse(decrypted.toString('utf8'));
|
||||
} catch (error) {
|
||||
logger.warn('Payload decryption failed', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
CalendarOffsetTrigger,
|
||||
CalendarAbsoluteTrigger,
|
||||
Calendar,
|
||||
CalendarTask,
|
||||
} from '@/lib/jmap/types';
|
||||
|
||||
export interface PendingAlert {
|
||||
@@ -121,3 +122,68 @@ export function getPendingAlerts(
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
export interface PendingTaskAlert {
|
||||
taskId: string;
|
||||
alertId: string;
|
||||
fireTimeMs: number;
|
||||
task: CalendarTask;
|
||||
calendarName: string | null;
|
||||
}
|
||||
|
||||
export function computeTaskFireTime(
|
||||
task: CalendarTask,
|
||||
trigger: CalendarOffsetTrigger | CalendarAbsoluteTrigger
|
||||
): number | null {
|
||||
if (trigger['@type'] === 'AbsoluteTrigger') {
|
||||
const t = new Date(trigger.when).getTime();
|
||||
return Number.isNaN(t) ? null : t;
|
||||
}
|
||||
|
||||
const offsetMs = parseAlertOffset(trigger.offset);
|
||||
if (offsetMs === null) return null;
|
||||
|
||||
if (!task.due) return null;
|
||||
const baseTime = new Date(task.due).getTime();
|
||||
if (Number.isNaN(baseTime)) return null;
|
||||
return baseTime + offsetMs;
|
||||
}
|
||||
|
||||
export function getPendingTaskAlerts(
|
||||
tasks: CalendarTask[],
|
||||
calendars: Calendar[],
|
||||
acknowledgedKeys: Set<string>,
|
||||
now: number
|
||||
): PendingTaskAlert[] {
|
||||
const pending: PendingTaskAlert[] = [];
|
||||
|
||||
for (const task of tasks) {
|
||||
if (!task.alerts) continue;
|
||||
if (task.progress === 'completed' || task.progress === 'cancelled') continue;
|
||||
|
||||
const calendar = calendars.find(c => c.id === Object.keys(task.calendarIds)[0]) ?? null;
|
||||
|
||||
for (const [alertId, alert] of Object.entries(task.alerts)) {
|
||||
if (alert.action !== 'display') continue;
|
||||
if (alert.acknowledged) continue;
|
||||
|
||||
const fireTimeMs = computeTaskFireTime(task, alert.trigger);
|
||||
if (fireTimeMs === null) continue;
|
||||
if (fireTimeMs > now) continue;
|
||||
if (fireTimeMs <= now - STALE_THRESHOLD_MS) continue;
|
||||
|
||||
const key = buildAlertKey(task.id, alertId, fireTimeMs);
|
||||
if (acknowledgedKeys.has(key)) continue;
|
||||
|
||||
pending.push({
|
||||
taskId: task.id,
|
||||
alertId,
|
||||
fireTimeMs,
|
||||
task,
|
||||
calendarName: calendar?.name ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,861 @@
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode } from '@/lib/jmap/types';
|
||||
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
|
||||
import { getDemoData, type DemoData } from './demo-data';
|
||||
import { generateDemoId } from './demo-utils';
|
||||
|
||||
/**
|
||||
* In-memory JMAP client for demo mode.
|
||||
* All data lives in memory — no network calls, no cookies.
|
||||
*/
|
||||
export class DemoJMAPClient implements IJMAPClient {
|
||||
private data: DemoData;
|
||||
private blobStore = new Map<string, Blob>();
|
||||
private connectionCallback: ((connected: boolean) => void) | null = null;
|
||||
private stateChangeCallback: ((change: StateChange) => void) | null = null;
|
||||
private lastStates: AccountStates = {};
|
||||
private incomingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.data = getDemoData();
|
||||
}
|
||||
|
||||
// ── Connection lifecycle ──────────────────────────────────────
|
||||
|
||||
async connect(): Promise<void> {
|
||||
// Start simulated incoming email timer
|
||||
this.startIncomingEmailTimer();
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.stopIncomingEmailTimer();
|
||||
this.connectionCallback = null;
|
||||
this.stateChangeCallback = null;
|
||||
}
|
||||
|
||||
async reconnect(): Promise<void> { /* no-op */ }
|
||||
async ping(): Promise<void> { /* no-op */ }
|
||||
|
||||
// ── Session / auth accessors ──────────────────────────────────
|
||||
|
||||
getServerUrl(): string { return 'https://demo.example.com'; }
|
||||
getAuthHeader(): string { return 'Bearer demo-token'; }
|
||||
updateAccessToken(): void { /* no-op */ }
|
||||
getAccountId(): string { return 'demo-account'; }
|
||||
getUsername(): string { return 'demo@example.com'; }
|
||||
|
||||
// ── Capabilities ──────────────────────────────────────────────
|
||||
|
||||
getCapabilities(): Record<string, unknown> {
|
||||
return {
|
||||
'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 },
|
||||
'urn:ietf:params:jmap:mail': {},
|
||||
'urn:ietf:params:jmap:submission': {},
|
||||
'urn:ietf:params:jmap:vacationresponse': {},
|
||||
'urn:ietf:params:jmap:contacts': {},
|
||||
'urn:ietf:params:jmap:calendars': {},
|
||||
'urn:ietf:params:jmap:sieve': {},
|
||||
'urn:ietf:params:jmap:quota': {},
|
||||
'urn:ietf:params:jmap:files': {},
|
||||
};
|
||||
}
|
||||
|
||||
getMaxSizeUpload(): number { return 50_000_000; }
|
||||
getMaxCallsInRequest(): number { return 16; }
|
||||
getMaxObjectsInGet(): number { return 500; }
|
||||
getEventSourceUrl(): string | null { return null; }
|
||||
supportsEmailSubmission(): boolean { return true; }
|
||||
supportsQuota(): boolean { return true; }
|
||||
supportsVacationResponse(): boolean { return true; }
|
||||
supportsContacts(): boolean { return true; }
|
||||
supportsCalendars(): boolean { return true; }
|
||||
supportsSieve(): boolean { return true; }
|
||||
supportsFiles(): boolean { return true; }
|
||||
|
||||
// ── Push / state ──────────────────────────────────────────────
|
||||
|
||||
setupPushNotifications(): boolean { return true; }
|
||||
closePushNotifications(): void { /* no-op in demo */ }
|
||||
onConnectionChange(callback: (connected: boolean) => void): void { this.connectionCallback = callback; }
|
||||
onStateChange(callback: (change: StateChange) => void): void { this.stateChangeCallback = callback; }
|
||||
getLastStates(): AccountStates { return { ...this.lastStates }; }
|
||||
setLastStates(states: AccountStates): void { this.lastStates = { ...states }; }
|
||||
|
||||
// ── Quota ─────────────────────────────────────────────────────
|
||||
|
||||
async getQuota(): Promise<{ used: number; total: number } | null> {
|
||||
return { used: 245_366_784, total: 1_073_741_824 };
|
||||
}
|
||||
|
||||
// ── Mailboxes ─────────────────────────────────────────────────
|
||||
|
||||
async getMailboxes(): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
|
||||
async getAllMailboxes(): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
|
||||
|
||||
async createMailbox(name: string, parentId?: string): Promise<Mailbox> {
|
||||
const mb: Mailbox = {
|
||||
id: generateDemoId('mailbox'),
|
||||
name,
|
||||
sortOrder: 100,
|
||||
totalEmails: 0,
|
||||
unreadEmails: 0,
|
||||
totalThreads: 0,
|
||||
unreadThreads: 0,
|
||||
parentId,
|
||||
isSubscribed: true,
|
||||
myRights: { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true },
|
||||
};
|
||||
this.data.mailboxes.push(mb);
|
||||
return mb;
|
||||
}
|
||||
|
||||
async updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }): Promise<void> {
|
||||
const mb = this.data.mailboxes.find(m => m.id === mailboxId);
|
||||
if (mb) Object.assign(mb, changes);
|
||||
}
|
||||
|
||||
async deleteMailbox(mailboxId: string): Promise<void> {
|
||||
this.data.mailboxes = this.data.mailboxes.filter(m => m.id !== mailboxId);
|
||||
// Also remove emails in this mailbox
|
||||
this.data.emails = this.data.emails.filter(e => !e.mailboxIds[mailboxId]);
|
||||
}
|
||||
|
||||
// ── Emails ────────────────────────────────────────────────────
|
||||
|
||||
async getEmails(mailboxId?: string, _accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[]; hasMore: boolean; total: number }> {
|
||||
let filtered = this.data.emails;
|
||||
if (mailboxId) {
|
||||
filtered = filtered.filter(e => e.mailboxIds[mailboxId]);
|
||||
}
|
||||
filtered.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
|
||||
const total = filtered.length;
|
||||
const emails = filtered.slice(position, position + limit);
|
||||
return { emails, hasMore: position + limit < total, total };
|
||||
}
|
||||
|
||||
async getEmailsInMailbox(mailboxId: string): Promise<Email[]> {
|
||||
return this.data.emails.filter(e => e.mailboxIds[mailboxId]);
|
||||
}
|
||||
|
||||
async getEmail(emailId: string): Promise<Email | null> {
|
||||
return this.data.emails.find(e => e.id === emailId) ?? null;
|
||||
}
|
||||
|
||||
async getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>> {
|
||||
const result: Record<string, { total: number; unread: number }> = {};
|
||||
for (const tagId of tagIds) {
|
||||
const tagged = this.data.emails.filter(e => e.keywords[tagId]);
|
||||
result[tagId] = {
|
||||
total: tagged.length,
|
||||
unread: tagged.filter(e => !e.keywords.$seen).length,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async searchEmails(query: string, mailboxId?: string, _accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[]; hasMore: boolean; total: number }> {
|
||||
const q = query.toLowerCase();
|
||||
let filtered = this.data.emails.filter(e => {
|
||||
const text = [e.subject, e.preview, e.from?.[0]?.name, e.from?.[0]?.email].filter(Boolean).join(' ').toLowerCase();
|
||||
return text.includes(q);
|
||||
});
|
||||
if (mailboxId) filtered = filtered.filter(e => e.mailboxIds[mailboxId]);
|
||||
filtered.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
|
||||
const total = filtered.length;
|
||||
const emails = filtered.slice(position, position + limit);
|
||||
return { emails, hasMore: position + limit < total, total };
|
||||
}
|
||||
|
||||
async advancedSearchEmails(filter: Record<string, unknown>, _accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[]; hasMore: boolean; total: number }> {
|
||||
// Simplified: just return all emails for any advanced filter
|
||||
let filtered = [...this.data.emails];
|
||||
if (filter.inMailbox) filtered = filtered.filter(e => e.mailboxIds[filter.inMailbox as string]);
|
||||
if (filter.text) {
|
||||
const q = (filter.text as string).toLowerCase();
|
||||
filtered = filtered.filter(e => [e.subject, e.preview].filter(Boolean).join(' ').toLowerCase().includes(q));
|
||||
}
|
||||
filtered.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
|
||||
const total = filtered.length;
|
||||
const emails = filtered.slice(position, position + limit);
|
||||
return { emails, hasMore: position + limit < total, total };
|
||||
}
|
||||
|
||||
// ── Email mutations ───────────────────────────────────────────
|
||||
|
||||
async markAsRead(emailId: string, read: boolean = true): Promise<void> {
|
||||
const email = this.data.emails.find(e => e.id === emailId);
|
||||
if (!email) return;
|
||||
if (read) {
|
||||
email.keywords.$seen = true;
|
||||
} else {
|
||||
delete email.keywords.$seen;
|
||||
}
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
|
||||
async batchMarkAsRead(emailIds: string[], read: boolean = true): Promise<void> {
|
||||
for (const id of emailIds) {
|
||||
const email = this.data.emails.find(e => e.id === id);
|
||||
if (email) {
|
||||
if (read) email.keywords.$seen = true;
|
||||
else delete email.keywords.$seen;
|
||||
}
|
||||
}
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
|
||||
async toggleStar(emailId: string, starred: boolean): Promise<void> {
|
||||
const email = this.data.emails.find(e => e.id === emailId);
|
||||
if (!email) return;
|
||||
if (starred) email.keywords.$flagged = true;
|
||||
else delete email.keywords.$flagged;
|
||||
}
|
||||
|
||||
async updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void> {
|
||||
const email = this.data.emails.find(e => e.id === emailId);
|
||||
if (email) email.keywords = { ...email.keywords, ...keywords };
|
||||
}
|
||||
|
||||
async migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number> {
|
||||
let count = 0;
|
||||
for (const email of this.data.emails) {
|
||||
if (email.keywords[oldKeyword]) {
|
||||
delete email.keywords[oldKeyword];
|
||||
email.keywords[newKeyword] = true;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async deleteEmail(emailId: string): Promise<void> {
|
||||
this.data.emails = this.data.emails.filter(e => e.id !== emailId);
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
|
||||
async moveToTrash(emailId: string, trashMailboxId: string): Promise<void> {
|
||||
const email = this.data.emails.find(e => e.id === emailId);
|
||||
if (!email) return;
|
||||
email.mailboxIds = { [trashMailboxId]: true };
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
|
||||
async batchDeleteEmails(emailIds: string[]): Promise<void> {
|
||||
const idSet = new Set(emailIds);
|
||||
this.data.emails = this.data.emails.filter(e => !idSet.has(e.id));
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
|
||||
async batchMoveEmails(emailIds: string[], toMailboxId: string): Promise<void> {
|
||||
for (const id of emailIds) {
|
||||
const email = this.data.emails.find(e => e.id === id);
|
||||
if (email) email.mailboxIds = { [toMailboxId]: true };
|
||||
}
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
|
||||
async moveEmail(emailId: string, toMailboxId: string): Promise<void> {
|
||||
const email = this.data.emails.find(e => e.id === emailId);
|
||||
if (email) email.mailboxIds = { [toMailboxId]: true };
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
|
||||
async emptyMailbox(mailboxId: string): Promise<number> {
|
||||
const before = this.data.emails.length;
|
||||
this.data.emails = this.data.emails.filter(e => !e.mailboxIds[mailboxId]);
|
||||
const removed = before - this.data.emails.length;
|
||||
this.recalcMailboxCounts();
|
||||
return removed;
|
||||
}
|
||||
|
||||
async markAsSpam(emailId: string): Promise<void> {
|
||||
const email = this.data.emails.find(e => e.id === emailId);
|
||||
const junkMb = this.data.mailboxes.find(m => m.role === 'junk');
|
||||
if (email && junkMb) email.mailboxIds = { [junkMb.id]: true };
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
|
||||
async undoSpam(emailId: string, originalMailboxId: string): Promise<void> {
|
||||
const email = this.data.emails.find(e => e.id === emailId);
|
||||
if (email) email.mailboxIds = { [originalMailboxId]: true };
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
|
||||
// ── Threads ───────────────────────────────────────────────────
|
||||
|
||||
async getThread(threadId: string): Promise<Thread | null> {
|
||||
const emails = this.data.emails.filter(e => e.threadId === threadId);
|
||||
if (emails.length === 0) return null;
|
||||
return { id: threadId, emailIds: emails.map(e => e.id) };
|
||||
}
|
||||
|
||||
async getThreadEmails(threadId: string): Promise<Email[]> {
|
||||
return this.data.emails
|
||||
.filter(e => e.threadId === threadId)
|
||||
.sort((a, b) => new Date(a.receivedAt).getTime() - new Date(b.receivedAt).getTime());
|
||||
}
|
||||
|
||||
// ── Compose / Send ────────────────────────────────────────────
|
||||
|
||||
async createDraft(
|
||||
to: string[],
|
||||
subject: string,
|
||||
body: string,
|
||||
cc?: string[],
|
||||
bcc?: string[],
|
||||
_identityId?: string,
|
||||
_fromEmail?: string,
|
||||
draftId?: string,
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
|
||||
_fromName?: string,
|
||||
): Promise<string> {
|
||||
const draftsMb = this.data.mailboxes.find(m => m.role === 'drafts');
|
||||
const id = draftId || generateDemoId('email');
|
||||
const existing = draftId ? this.data.emails.findIndex(e => e.id === draftId) : -1;
|
||||
|
||||
const email: Email = {
|
||||
id, threadId: generateDemoId('thread'),
|
||||
mailboxIds: { [draftsMb?.id || 'demo-mailbox-drafts']: true },
|
||||
keywords: { $seen: true, $draft: true },
|
||||
size: body.length,
|
||||
receivedAt: new Date().toISOString(),
|
||||
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
to: to.map(e => ({ email: e })),
|
||||
cc: cc?.map(e => ({ email: e })),
|
||||
bcc: bcc?.map(e => ({ email: e })),
|
||||
subject,
|
||||
sentAt: new Date().toISOString(),
|
||||
preview: body.substring(0, 200),
|
||||
hasAttachment: !!attachments?.length,
|
||||
textBody: [{ partId: '1', blobId: generateDemoId('blob'), size: body.length, type: 'text/plain' }],
|
||||
htmlBody: [],
|
||||
bodyValues: { '1': { value: body } },
|
||||
attachments: attachments?.map(a => ({ ...a, partId: generateDemoId('part') })),
|
||||
messageId: `<${id}@demo.example.com>`,
|
||||
};
|
||||
|
||||
if (existing >= 0) {
|
||||
this.data.emails[existing] = email;
|
||||
} else {
|
||||
this.data.emails.push(email);
|
||||
}
|
||||
this.recalcMailboxCounts();
|
||||
return id;
|
||||
}
|
||||
|
||||
async sendEmail(
|
||||
to: string[],
|
||||
subject: string,
|
||||
body: string,
|
||||
cc?: string[],
|
||||
bcc?: string[],
|
||||
_identityId?: string,
|
||||
_fromEmail?: string,
|
||||
draftId?: string,
|
||||
_fromName?: string,
|
||||
htmlBody?: string,
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
|
||||
): Promise<void> {
|
||||
// Remove draft if updating
|
||||
if (draftId) {
|
||||
this.data.emails = this.data.emails.filter(e => e.id !== draftId);
|
||||
}
|
||||
const sentMb = this.data.mailboxes.find(m => m.role === 'sent');
|
||||
const email: Email = {
|
||||
id: generateDemoId('email'), threadId: generateDemoId('thread'),
|
||||
mailboxIds: { [sentMb?.id || 'demo-mailbox-sent']: true },
|
||||
keywords: { $seen: true },
|
||||
size: body.length + (htmlBody?.length || 0),
|
||||
receivedAt: new Date().toISOString(),
|
||||
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
to: to.map(e => ({ email: e })),
|
||||
cc: cc?.map(e => ({ email: e })),
|
||||
bcc: bcc?.map(e => ({ email: e })),
|
||||
subject,
|
||||
sentAt: new Date().toISOString(),
|
||||
preview: body.substring(0, 200),
|
||||
hasAttachment: !!attachments?.length,
|
||||
textBody: [{ partId: '1', blobId: generateDemoId('blob'), size: body.length, type: 'text/plain' }],
|
||||
htmlBody: htmlBody ? [{ partId: '2', blobId: generateDemoId('blob'), size: htmlBody.length, type: 'text/html' }] : [],
|
||||
bodyValues: htmlBody ? { '1': { value: body }, '2': { value: htmlBody } } : { '1': { value: body } },
|
||||
attachments: attachments?.map(a => ({ ...a, partId: generateDemoId('part') })),
|
||||
messageId: `<${generateDemoId('msg')}@demo.example.com>`,
|
||||
};
|
||||
this.data.emails.push(email);
|
||||
this.recalcMailboxCounts();
|
||||
}
|
||||
|
||||
async sendImipReply(): Promise<void> { /* no-op in demo */ }
|
||||
async sendImipInvitation(): Promise<void> { /* no-op in demo */ }
|
||||
async sendImipCancellation(): Promise<void> { /* no-op in demo */ }
|
||||
|
||||
// ── Blobs ─────────────────────────────────────────────────────
|
||||
|
||||
async uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }> {
|
||||
const blobId = generateDemoId('blob');
|
||||
this.blobStore.set(blobId, file);
|
||||
return { blobId, size: file.size, type: file.type };
|
||||
}
|
||||
|
||||
getBlobDownloadUrl(blobId: string): string {
|
||||
return `data:application/octet-stream;demo-blob=${blobId}`;
|
||||
}
|
||||
|
||||
async fetchBlob(blobId: string): Promise<Blob> {
|
||||
return this.blobStore.get(blobId) ?? new Blob(['[Demo placeholder content]'], { type: 'text/plain' });
|
||||
}
|
||||
|
||||
async fetchBlobAsObjectUrl(blobId: string): Promise<string> {
|
||||
const blob = await this.fetchBlob(blobId);
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
async fetchBlobArrayBuffer(blobId: string): Promise<ArrayBuffer> {
|
||||
const blob = await this.fetchBlob(blobId);
|
||||
return blob.arrayBuffer();
|
||||
}
|
||||
|
||||
async downloadBlob(blobId: string, name?: string): Promise<void> {
|
||||
const blob = await this.fetchBlob(blobId);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name || 'download';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ── Identities ────────────────────────────────────────────────
|
||||
|
||||
async getIdentities(): Promise<Identity[]> { return [...this.data.identities]; }
|
||||
|
||||
async createIdentity(
|
||||
name: string, email: string,
|
||||
replyTo?: EmailAddress[] | null, bcc?: EmailAddress[] | null,
|
||||
htmlSignature?: string, textSignature?: string,
|
||||
): Promise<Identity> {
|
||||
const identity: Identity = {
|
||||
id: generateDemoId('identity'), name, email,
|
||||
replyTo: replyTo ?? undefined, bcc: bcc ?? undefined,
|
||||
htmlSignature: htmlSignature ?? '', textSignature: textSignature ?? '',
|
||||
mayDelete: true,
|
||||
};
|
||||
this.data.identities.push(identity);
|
||||
return identity;
|
||||
}
|
||||
|
||||
async updateIdentity(identityId: string, updates: { name?: string; replyTo?: EmailAddress[] | null; bcc?: EmailAddress[] | null; htmlSignature?: string; textSignature?: string }): Promise<void> {
|
||||
const identity = this.data.identities.find(i => i.id === identityId);
|
||||
if (identity) Object.assign(identity, updates);
|
||||
}
|
||||
|
||||
async deleteIdentity(identityId: string): Promise<void> {
|
||||
this.data.identities = this.data.identities.filter(i => i.id !== identityId);
|
||||
}
|
||||
|
||||
// ── Vacation ──────────────────────────────────────────────────
|
||||
|
||||
async getVacationResponse(): Promise<VacationResponse> { return { ...this.data.vacationResponse }; }
|
||||
|
||||
async setVacationResponse(updates: Partial<VacationResponse>): Promise<void> {
|
||||
Object.assign(this.data.vacationResponse, updates);
|
||||
}
|
||||
|
||||
// ── Contacts ──────────────────────────────────────────────────
|
||||
|
||||
getContactsAccountId(): string { return 'demo-account'; }
|
||||
|
||||
async getAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
|
||||
async getAllAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
|
||||
|
||||
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
|
||||
if (addressBookId) return this.data.contacts.filter(c => c.addressBookIds[addressBookId]);
|
||||
return [...this.data.contacts];
|
||||
}
|
||||
|
||||
async getAllContacts(): Promise<ContactCard[]> { return [...this.data.contacts]; }
|
||||
|
||||
async getContact(contactId: string): Promise<ContactCard | null> {
|
||||
return this.data.contacts.find(c => c.id === contactId) ?? null;
|
||||
}
|
||||
|
||||
async createContact(contact: Partial<ContactCard>): Promise<ContactCard> {
|
||||
const full: ContactCard = {
|
||||
id: generateDemoId('contact'),
|
||||
addressBookIds: contact.addressBookIds ?? { 'demo-addressbook-personal': true },
|
||||
...contact,
|
||||
} as ContactCard;
|
||||
this.data.contacts.push(full);
|
||||
return full;
|
||||
}
|
||||
|
||||
async updateContact(contactId: string, updates: Partial<ContactCard>): Promise<void> {
|
||||
const contact = this.data.contacts.find(c => c.id === contactId);
|
||||
if (contact) Object.assign(contact, updates);
|
||||
}
|
||||
|
||||
async deleteContact(contactId: string): Promise<void> {
|
||||
this.data.contacts = this.data.contacts.filter(c => c.id !== contactId);
|
||||
}
|
||||
|
||||
async searchContacts(query: string): Promise<ContactCard[]> {
|
||||
const q = query.toLowerCase();
|
||||
return this.data.contacts.filter(c => {
|
||||
const nameStr = c.name?.components?.map(nc => nc.value).join(' ').toLowerCase() ?? '';
|
||||
const emailStr = Object.values(c.emails ?? {}).map(e => e.address).join(' ').toLowerCase();
|
||||
return nameStr.includes(q) || emailStr.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Calendars ─────────────────────────────────────────────────
|
||||
|
||||
getCalendarsAccountId(): string { return 'demo-account'; }
|
||||
|
||||
async getCalendars(): Promise<Calendar[]> { return [...this.data.calendars]; }
|
||||
async getAllCalendars(): Promise<Calendar[]> { return [...this.data.calendars]; }
|
||||
|
||||
async createCalendar(calendar: Partial<Calendar>): Promise<Calendar> {
|
||||
const full: Calendar = {
|
||||
id: generateDemoId('calendar'),
|
||||
name: calendar.name ?? 'New Calendar',
|
||||
description: calendar.description ?? null,
|
||||
color: calendar.color ?? '#6366f1',
|
||||
sortOrder: calendar.sortOrder ?? 99,
|
||||
isSubscribed: true, isVisible: true, isDefault: false,
|
||||
includeInAvailability: 'all',
|
||||
defaultAlertsWithTime: null, defaultAlertsWithoutTime: null,
|
||||
timeZone: null, shareWith: null,
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
|
||||
...calendar,
|
||||
} as Calendar;
|
||||
this.data.calendars.push(full);
|
||||
return full;
|
||||
}
|
||||
|
||||
async updateCalendar(calendarId: string, updates: Partial<Calendar>): Promise<void> {
|
||||
const cal = this.data.calendars.find(c => c.id === calendarId);
|
||||
if (cal) Object.assign(cal, updates);
|
||||
}
|
||||
|
||||
async deleteCalendar(calendarId: string): Promise<void> {
|
||||
this.data.calendars = this.data.calendars.filter(c => c.id !== calendarId);
|
||||
this.data.calendarEvents = this.data.calendarEvents.filter(e => !e.calendarIds[calendarId]);
|
||||
}
|
||||
|
||||
async getCalendarEvents(calendarIds?: string[]): Promise<CalendarEvent[]> {
|
||||
let events = [...this.data.calendarEvents];
|
||||
if (calendarIds?.length) {
|
||||
events = events.filter(e => calendarIds.some(cid => e.calendarIds[cid]));
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
async getCalendarEvent(id: string): Promise<CalendarEvent | null> {
|
||||
return this.data.calendarEvents.find(e => e.id === id) ?? null;
|
||||
}
|
||||
|
||||
async createCalendarEvent(event: Partial<CalendarEvent>): Promise<CalendarEvent> {
|
||||
const full: CalendarEvent = {
|
||||
id: generateDemoId('event'),
|
||||
calendarIds: event.calendarIds ?? { 'demo-calendar-personal': true },
|
||||
'@type': 'Event',
|
||||
uid: generateDemoId('uid'),
|
||||
title: event.title ?? 'New Event',
|
||||
description: event.description ?? '',
|
||||
descriptionContentType: 'text/plain',
|
||||
isDraft: false, isOrigin: true,
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
sequence: 0,
|
||||
start: event.start ?? new Date().toISOString(),
|
||||
duration: event.duration ?? 'PT1H',
|
||||
timeZone: event.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
utcStart: event.utcStart ?? null,
|
||||
utcEnd: event.utcEnd ?? null,
|
||||
showWithoutTime: event.showWithoutTime ?? false,
|
||||
status: 'confirmed', freeBusyStatus: 'busy', privacy: 'public',
|
||||
color: null, keywords: null, categories: null, locale: null,
|
||||
replyTo: null, organizerCalendarAddress: null, participants: null,
|
||||
mayInviteSelf: false, mayInviteOthers: false, hideAttendees: false,
|
||||
recurrenceId: null, recurrenceIdTimeZone: null, recurrenceRules: null,
|
||||
recurrenceOverrides: null, excludedRecurrenceRules: null,
|
||||
useDefaultAlerts: true, alerts: null, locations: null,
|
||||
virtualLocations: null, links: null, relatedTo: null,
|
||||
...event,
|
||||
} as CalendarEvent;
|
||||
this.data.calendarEvents.push(full);
|
||||
return full;
|
||||
}
|
||||
|
||||
async updateCalendarEvent(eventId: string, updates: Partial<CalendarEvent>): Promise<void> {
|
||||
const event = this.data.calendarEvents.find(e => e.id === eventId);
|
||||
if (!event) throw new Error('Event not found');
|
||||
Object.assign(event, updates, { updated: new Date().toISOString() });
|
||||
}
|
||||
|
||||
async deleteCalendarEvent(eventId: string): Promise<void> {
|
||||
this.data.calendarEvents = this.data.calendarEvents.filter(e => e.id !== eventId);
|
||||
}
|
||||
|
||||
async batchDeleteCalendarEvents(eventIds: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
|
||||
const idSet = new Set(eventIds);
|
||||
this.data.calendarEvents = this.data.calendarEvents.filter(e => !idSet.has(e.id));
|
||||
return { destroyed: eventIds, notDestroyed: [] };
|
||||
}
|
||||
|
||||
async queryCalendarEvents(filter: CalendarEventFilter): Promise<CalendarEvent[]> {
|
||||
return this.data.calendarEvents.filter(e => {
|
||||
if (filter.after && e.start < filter.after) return false;
|
||||
if (filter.before && e.start > filter.before) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async queryAllCalendarEvents(filter: CalendarEventFilter): Promise<CalendarEvent[]> {
|
||||
return this.queryCalendarEvents(filter);
|
||||
}
|
||||
|
||||
async parseCalendarEvents(): Promise<Partial<CalendarEvent>[]> {
|
||||
return []; // no-op in demo
|
||||
}
|
||||
|
||||
// ── Calendar Tasks ────────────────────────────────────────────
|
||||
|
||||
async getCalendarTasks(calendarIds?: string[]): Promise<CalendarTask[]> {
|
||||
let tasks = this.data.calendarTasks || [];
|
||||
if (calendarIds) {
|
||||
tasks = tasks.filter(t => Object.keys(t.calendarIds).some(id => calendarIds.includes(id)));
|
||||
}
|
||||
return [...tasks];
|
||||
}
|
||||
|
||||
async createCalendarTask(task: Partial<CalendarTask>): Promise<CalendarTask> {
|
||||
const full: CalendarTask = {
|
||||
id: generateDemoId('task'),
|
||||
uid: generateDemoId('task-uid'),
|
||||
'@type': 'Task',
|
||||
calendarIds: task.calendarIds || { [this.data.calendars[0]?.id || 'cal-1']: true },
|
||||
title: task.title || '',
|
||||
description: task.description || '',
|
||||
due: task.due || null,
|
||||
start: task.start || null,
|
||||
duration: task.duration || null,
|
||||
timeZone: task.timeZone || null,
|
||||
showWithoutTime: task.showWithoutTime ?? true,
|
||||
progress: task.progress || 'needs-action',
|
||||
progressUpdated: null,
|
||||
priority: task.priority || 0,
|
||||
privacy: task.privacy || 'public',
|
||||
keywords: task.keywords || null,
|
||||
categories: task.categories || null,
|
||||
color: task.color || null,
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
recurrenceRules: task.recurrenceRules || null,
|
||||
alerts: task.alerts || null,
|
||||
relatedTo: task.relatedTo || null,
|
||||
};
|
||||
this.data.calendarTasks.push(full);
|
||||
return full;
|
||||
}
|
||||
|
||||
async updateCalendarTask(taskId: string, updates: Partial<CalendarTask>): Promise<void> {
|
||||
const task = this.data.calendarTasks.find(t => t.id === taskId);
|
||||
if (task) Object.assign(task, updates, { updated: new Date().toISOString() });
|
||||
}
|
||||
|
||||
async deleteCalendarTask(taskId: string): Promise<void> {
|
||||
this.data.calendarTasks = this.data.calendarTasks.filter(t => t.id !== taskId);
|
||||
}
|
||||
|
||||
// ── Sieve / Filters ──────────────────────────────────────────
|
||||
|
||||
getSieveAccountId(): string { return 'demo-account'; }
|
||||
|
||||
getSieveCapabilities(): SieveCapabilities | null {
|
||||
return { ...this.data.sieveCapabilities };
|
||||
}
|
||||
|
||||
async getSieveScripts(): Promise<SieveScript[]> { return [...this.data.sieveScripts]; }
|
||||
|
||||
async getSieveScriptContent(blobId: string): Promise<string> {
|
||||
return this.data.sieveContent[blobId] ?? '';
|
||||
}
|
||||
|
||||
async createSieveScript(name: string, content: string, activate?: boolean): Promise<SieveScript> {
|
||||
const blobId = generateDemoId('sieve-blob');
|
||||
const script: SieveScript = { id: generateDemoId('sieve'), name, blobId, isActive: activate ?? false };
|
||||
this.data.sieveScripts.push(script);
|
||||
this.data.sieveContent[blobId] = content;
|
||||
if (activate) {
|
||||
for (const s of this.data.sieveScripts) {
|
||||
if (s.id !== script.id) s.isActive = false;
|
||||
}
|
||||
}
|
||||
return script;
|
||||
}
|
||||
|
||||
async updateSieveScript(scriptId: string, content: string, activate?: boolean): Promise<void> {
|
||||
const script = this.data.sieveScripts.find(s => s.id === scriptId);
|
||||
if (!script) return;
|
||||
const blobId = generateDemoId('sieve-blob');
|
||||
this.data.sieveContent[blobId] = content;
|
||||
script.blobId = blobId;
|
||||
if (activate !== undefined) {
|
||||
script.isActive = activate;
|
||||
if (activate) {
|
||||
for (const s of this.data.sieveScripts) {
|
||||
if (s.id !== scriptId) s.isActive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deleteSieveScript(scriptId: string): Promise<void> {
|
||||
this.data.sieveScripts = this.data.sieveScripts.filter(s => s.id !== scriptId);
|
||||
}
|
||||
|
||||
async validateSieveScript(): Promise<{ isValid: boolean; errors?: string[] }> {
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
// ── Files (FileNode) ─────────────────────────────────────────
|
||||
|
||||
getFilesAccountId(): string { return 'demo-account'; }
|
||||
|
||||
async probeFileNodeSupport(): Promise<boolean> { return true; }
|
||||
|
||||
async listFileNodes(parentId: string | null): Promise<FileNode[]> {
|
||||
return this.data.fileNodes.filter(n => n.parentId === parentId);
|
||||
}
|
||||
|
||||
async getFileNodes(ids: string[] | null): Promise<FileNode[]> {
|
||||
if (ids === null) return [...this.data.fileNodes];
|
||||
return this.data.fileNodes.filter(n => ids.includes(n.id));
|
||||
}
|
||||
|
||||
async createFileDirectory(name: string, parentId: string | null): Promise<FileNode> {
|
||||
const node: FileNode = {
|
||||
id: generateDemoId('file'),
|
||||
parentId, name, type: 'd', blobId: null, size: 0,
|
||||
created: new Date().toISOString(), updated: new Date().toISOString(),
|
||||
};
|
||||
this.data.fileNodes.push(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode> {
|
||||
const node: FileNode = {
|
||||
id: generateDemoId('file'),
|
||||
parentId, name, type, blobId, size,
|
||||
created: new Date().toISOString(), updated: new Date().toISOString(),
|
||||
};
|
||||
this.data.fileNodes.push(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
async updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void> {
|
||||
const node = this.data.fileNodes.find(n => n.id === id);
|
||||
if (node) Object.assign(node, updates, { updated: new Date().toISOString() });
|
||||
}
|
||||
|
||||
async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
|
||||
const idSet = new Set(ids);
|
||||
this.data.fileNodes = this.data.fileNodes.filter(n => !idSet.has(n.id));
|
||||
return { destroyed: ids, notDestroyed: [] };
|
||||
}
|
||||
|
||||
async copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode> {
|
||||
const original = this.data.fileNodes.find(n => n.id === id);
|
||||
if (!original) throw new Error('File node not found');
|
||||
return this.createFileNode(newName, original.blobId ?? '', original.type, original.size, parentId);
|
||||
}
|
||||
|
||||
// ── S/MIME raw-email helpers ──────────────────────────────────
|
||||
|
||||
async importRawEmail(): Promise<string> { return generateDemoId('email'); }
|
||||
async submitEmail(): Promise<void> { /* no-op */ }
|
||||
async sendRawEmail(): Promise<void> { /* no-op */ }
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────
|
||||
|
||||
private recalcMailboxCounts(): void {
|
||||
for (const mb of this.data.mailboxes) {
|
||||
const inMb = this.data.emails.filter(e => e.mailboxIds[mb.id]);
|
||||
mb.totalEmails = inMb.length;
|
||||
mb.unreadEmails = inMb.filter(e => !e.keywords.$seen).length;
|
||||
mb.totalThreads = new Set(inMb.map(e => e.threadId)).size;
|
||||
mb.unreadThreads = new Set(inMb.filter(e => !e.keywords.$seen).map(e => e.threadId)).size;
|
||||
}
|
||||
}
|
||||
|
||||
private startIncomingEmailTimer(): void {
|
||||
this.stopIncomingEmailTimer();
|
||||
|
||||
const scheduleNext = () => {
|
||||
const delay = 60_000 + Math.random() * 60_000; // 60-120 seconds
|
||||
this.incomingTimer = setTimeout(() => {
|
||||
this.simulateIncomingEmail();
|
||||
scheduleNext();
|
||||
}, delay);
|
||||
};
|
||||
scheduleNext();
|
||||
}
|
||||
|
||||
private stopIncomingEmailTimer(): void {
|
||||
if (this.incomingTimer) {
|
||||
clearTimeout(this.incomingTimer);
|
||||
this.incomingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private simulateIncomingEmail(): void {
|
||||
const senders = [
|
||||
{ name: 'Alice Johnson', email: 'alice.johnson@example.com' },
|
||||
{ name: 'Bob Chen', email: 'bob.chen@example.com' },
|
||||
{ name: 'Sarah Kim', email: 'sarah.kim@example.com' },
|
||||
{ name: 'Carlos Rivera', email: 'carlos.rivera@example.com' },
|
||||
];
|
||||
const subjects = [
|
||||
'Quick question about the project',
|
||||
'Meeting rescheduled to tomorrow',
|
||||
'FYI: Updated documentation',
|
||||
'Can you review this PR?',
|
||||
'Lunch today?',
|
||||
'Important: deadline reminder',
|
||||
];
|
||||
|
||||
const sender = senders[Math.floor(Math.random() * senders.length)];
|
||||
const subject = subjects[Math.floor(Math.random() * subjects.length)];
|
||||
const id = generateDemoId('email');
|
||||
|
||||
const email: Email = {
|
||||
id, threadId: generateDemoId('thread'),
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: {},
|
||||
size: 1800,
|
||||
receivedAt: new Date().toISOString(),
|
||||
from: [sender],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
subject, sentAt: new Date().toISOString(),
|
||||
preview: `Hi, ${subject.toLowerCase()}. Let me know what you think.`,
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: generateDemoId('blob'), size: 120, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: `Hi,\n\n${subject}. Let me know what you think.\n\nBest,\n${sender.name}` },
|
||||
},
|
||||
messageId: `<${id}@demo.example.com>`,
|
||||
};
|
||||
|
||||
this.data.emails.unshift(email);
|
||||
this.recalcMailboxCounts();
|
||||
|
||||
// Notify state change to trigger UI refresh
|
||||
this.stateChangeCallback?.({
|
||||
'@type': 'StateChange',
|
||||
changed: { 'demo-account': { Email: generateDemoId('state'), Mailbox: generateDemoId('state') } },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { cloneFixtures } from './demo-utils';
|
||||
import { createDemoMailboxes } from './fixtures/mailboxes';
|
||||
import { createDemoEmails } from './fixtures/emails';
|
||||
import { createDemoContacts, createDemoAddressBooks } from './fixtures/contacts';
|
||||
import { createDemoCalendars, createDemoCalendarEvents } from './fixtures/calendars';
|
||||
import { createDemoCalendarTasks } from './fixtures/tasks';
|
||||
import { createDemoIdentities } from './fixtures/identities';
|
||||
import { createDemoSieveScripts, createDemoSieveCapabilities, createDemoSieveContent } from './fixtures/filters';
|
||||
import { createDemoFileNodes } from './fixtures/files';
|
||||
import { createDemoVacationResponse } from './fixtures/vacation';
|
||||
|
||||
import type { Email, Mailbox, ContactCard, AddressBook, Calendar, CalendarEvent, CalendarTask, Identity, VacationResponse, FileNode } from '@/lib/jmap/types';
|
||||
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
|
||||
|
||||
export interface DemoData {
|
||||
mailboxes: Mailbox[];
|
||||
emails: Email[];
|
||||
contacts: ContactCard[];
|
||||
addressBooks: AddressBook[];
|
||||
calendars: Calendar[];
|
||||
calendarEvents: CalendarEvent[];
|
||||
calendarTasks: CalendarTask[];
|
||||
identities: Identity[];
|
||||
sieveScripts: SieveScript[];
|
||||
sieveCapabilities: SieveCapabilities;
|
||||
sieveContent: Record<string, string>;
|
||||
fileNodes: FileNode[];
|
||||
vacationResponse: VacationResponse;
|
||||
}
|
||||
|
||||
/** Return a fresh deep-cloned copy of all demo data. */
|
||||
export function getDemoData(): DemoData {
|
||||
return cloneFixtures({
|
||||
mailboxes: createDemoMailboxes(),
|
||||
emails: createDemoEmails(),
|
||||
contacts: createDemoContacts(),
|
||||
addressBooks: createDemoAddressBooks(),
|
||||
calendars: createDemoCalendars(),
|
||||
calendarEvents: createDemoCalendarEvents(),
|
||||
calendarTasks: createDemoCalendarTasks(),
|
||||
identities: createDemoIdentities(),
|
||||
sieveScripts: createDemoSieveScripts(),
|
||||
sieveCapabilities: createDemoSieveCapabilities(),
|
||||
sieveContent: createDemoSieveContent(),
|
||||
fileNodes: createDemoFileNodes(),
|
||||
vacationResponse: createDemoVacationResponse(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
let demoIdCounter = 0;
|
||||
|
||||
/** Generate a unique demo ID with the given prefix. */
|
||||
export function generateDemoId(prefix: string = 'demo'): string {
|
||||
return `${prefix}-${Date.now()}-${++demoIdCounter}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an ISO date string relative to "now".
|
||||
* @param daysOffset — whole days from today
|
||||
* @param hoursOffset — additional hours offset (default 0)
|
||||
* @param minutesOffset — additional minutes offset (default 0)
|
||||
*/
|
||||
export function demoDate(daysOffset: number, hoursOffset: number = 0, minutesOffset: number = 0): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + daysOffset);
|
||||
d.setHours(d.getHours() + hoursOffset, d.getMinutes() + minutesOffset, 0, 0);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a local date-time string (YYYY-MM-DDTHH:mm:ss) for JSCalendar "start" fields.
|
||||
*/
|
||||
export function demoISODate(daysOffset: number, hours: number = 0, minutes: number = 0): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + daysOffset);
|
||||
d.setHours(hours, minutes, 0, 0);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
|
||||
}
|
||||
|
||||
/** Deep clone fixture data so in-memory mutations don't corrupt originals. */
|
||||
export function cloneFixtures<T>(data: T): T {
|
||||
return JSON.parse(JSON.stringify(data));
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||
import { demoDate, demoISODate } from '../demo-utils';
|
||||
|
||||
export function createDemoCalendars(): Calendar[] {
|
||||
return [
|
||||
{
|
||||
id: 'demo-calendar-personal',
|
||||
name: 'Personal',
|
||||
description: null,
|
||||
color: '#3b82f6',
|
||||
sortOrder: 1,
|
||||
isSubscribed: true,
|
||||
isVisible: true,
|
||||
isDefault: true,
|
||||
includeInAvailability: 'all',
|
||||
defaultAlertsWithTime: null,
|
||||
defaultAlertsWithoutTime: null,
|
||||
timeZone: null,
|
||||
shareWith: null,
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: false },
|
||||
},
|
||||
{
|
||||
id: 'demo-calendar-work',
|
||||
name: 'Work',
|
||||
description: null,
|
||||
color: '#22c55e',
|
||||
sortOrder: 2,
|
||||
isSubscribed: true,
|
||||
isVisible: true,
|
||||
isDefault: false,
|
||||
includeInAvailability: 'all',
|
||||
defaultAlertsWithTime: null,
|
||||
defaultAlertsWithoutTime: null,
|
||||
timeZone: null,
|
||||
shareWith: null,
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
|
||||
},
|
||||
{
|
||||
id: 'demo-calendar-birthdays',
|
||||
name: 'Birthdays',
|
||||
description: null,
|
||||
color: '#eab308',
|
||||
sortOrder: 3,
|
||||
isSubscribed: true,
|
||||
isVisible: true,
|
||||
isDefault: false,
|
||||
includeInAvailability: 'none',
|
||||
defaultAlertsWithTime: null,
|
||||
defaultAlertsWithoutTime: null,
|
||||
timeZone: null,
|
||||
shareWith: null,
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function createDemoCalendarEvents(): CalendarEvent[] {
|
||||
const baseEvent = {
|
||||
'@type': 'Event' as const,
|
||||
descriptionContentType: 'text/plain',
|
||||
isDraft: false,
|
||||
isOrigin: true,
|
||||
sequence: 0,
|
||||
status: 'confirmed' as const,
|
||||
freeBusyStatus: 'busy' as const,
|
||||
privacy: 'public' as const,
|
||||
color: null,
|
||||
keywords: null,
|
||||
categories: null,
|
||||
locale: null,
|
||||
replyTo: null,
|
||||
organizerCalendarAddress: null,
|
||||
participants: null,
|
||||
mayInviteSelf: false,
|
||||
mayInviteOthers: false,
|
||||
hideAttendees: false,
|
||||
recurrenceId: null,
|
||||
recurrenceIdTimeZone: null,
|
||||
recurrenceRules: null,
|
||||
recurrenceOverrides: null,
|
||||
excludedRecurrenceRules: null,
|
||||
useDefaultAlerts: true,
|
||||
alerts: null,
|
||||
locations: null,
|
||||
virtualLocations: null,
|
||||
links: null,
|
||||
relatedTo: null,
|
||||
};
|
||||
|
||||
return [
|
||||
// ── Personal calendar ──────────────────────────────────────
|
||||
{
|
||||
...baseEvent,
|
||||
id: 'demo-event-1',
|
||||
calendarIds: { 'demo-calendar-personal': true },
|
||||
uid: 'demo-event-1@example.com',
|
||||
title: 'Dentist Appointment',
|
||||
description: 'Regular checkup at Dr. Smith\'s office',
|
||||
created: demoDate(-7),
|
||||
updated: demoDate(-7),
|
||||
start: demoISODate(2, 10, 0),
|
||||
utcStart: demoDate(2, 10),
|
||||
utcEnd: demoDate(2, 11),
|
||||
duration: 'PT1H',
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
showWithoutTime: false,
|
||||
locations: { loc1: { '@type': 'Location', name: 'Dr. Smith Dental Clinic', description: '123 Medical Plaza', locationTypes: null, coordinates: null, timeZone: null, links: null, relativeTo: null } },
|
||||
},
|
||||
{
|
||||
...baseEvent,
|
||||
id: 'demo-event-2',
|
||||
calendarIds: { 'demo-calendar-personal': true },
|
||||
uid: 'demo-event-2@example.com',
|
||||
title: 'Birthday Party',
|
||||
description: 'Emma\'s birthday celebration',
|
||||
created: demoDate(-10),
|
||||
updated: demoDate(-10),
|
||||
start: demoISODate(5),
|
||||
utcStart: demoDate(5),
|
||||
utcEnd: demoDate(6),
|
||||
duration: 'P1D',
|
||||
timeZone: null,
|
||||
showWithoutTime: true,
|
||||
freeBusyStatus: 'free' as const,
|
||||
},
|
||||
{
|
||||
...baseEvent,
|
||||
id: 'demo-event-3',
|
||||
calendarIds: { 'demo-calendar-personal': true },
|
||||
uid: 'demo-event-3@example.com',
|
||||
title: 'Weekend Trip',
|
||||
description: 'Road trip to the mountains',
|
||||
created: demoDate(-5),
|
||||
updated: demoDate(-5),
|
||||
start: demoISODate(8),
|
||||
utcStart: demoDate(8),
|
||||
utcEnd: demoDate(10),
|
||||
duration: 'P2D',
|
||||
timeZone: null,
|
||||
showWithoutTime: true,
|
||||
freeBusyStatus: 'busy' as const,
|
||||
},
|
||||
|
||||
// ── Work calendar ──────────────────────────────────────────
|
||||
{
|
||||
...baseEvent,
|
||||
id: 'demo-event-4',
|
||||
calendarIds: { 'demo-calendar-work': true },
|
||||
uid: 'demo-event-4@example.com',
|
||||
title: 'Weekly Standup',
|
||||
description: 'Team sync-up meeting',
|
||||
created: demoDate(-30),
|
||||
updated: demoDate(-1),
|
||||
start: demoISODate(1, 9, 30),
|
||||
utcStart: demoDate(1, 9, 30),
|
||||
utcEnd: demoDate(1, 10, 0),
|
||||
duration: 'PT30M',
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
showWithoutTime: false,
|
||||
recurrenceRules: [{
|
||||
'@type': 'RecurrenceRule',
|
||||
frequency: 'weekly',
|
||||
interval: 1,
|
||||
rscale: 'gregorian',
|
||||
skip: 'omit',
|
||||
firstDayOfWeek: 'mo',
|
||||
byDay: [{ day: 'mo' }],
|
||||
byMonthDay: null,
|
||||
byMonth: null,
|
||||
byYearDay: null,
|
||||
byWeekNo: null,
|
||||
byHour: null,
|
||||
byMinute: null,
|
||||
bySecond: null,
|
||||
bySetPosition: null,
|
||||
count: null,
|
||||
until: null,
|
||||
}],
|
||||
virtualLocations: { vl1: { '@type': 'VirtualLocation', name: 'Zoom', uri: 'https://zoom.example/123456', description: 'Weekly standup room', features: null } },
|
||||
},
|
||||
{
|
||||
...baseEvent,
|
||||
id: 'demo-event-5',
|
||||
calendarIds: { 'demo-calendar-work': true },
|
||||
uid: 'demo-event-5@example.com',
|
||||
title: 'Quarterly Review',
|
||||
description: 'Q4 performance review and planning session',
|
||||
created: demoDate(-14),
|
||||
updated: demoDate(-3),
|
||||
start: demoISODate(4, 14, 0),
|
||||
utcStart: demoDate(4, 14),
|
||||
utcEnd: demoDate(4, 16),
|
||||
duration: 'PT2H',
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
showWithoutTime: false,
|
||||
participants: {
|
||||
p1: {
|
||||
'@type': 'Participant', name: 'Demo User', email: 'demo@example.com', calendarAddress: null, description: null, sendTo: null,
|
||||
kind: 'individual', roles: { attendee: true }, participationStatus: 'accepted', participationComment: null,
|
||||
expectReply: false, scheduleAgent: 'server', scheduleForceSend: false, scheduleId: null, scheduleSequence: 0,
|
||||
scheduleStatus: null, scheduleUpdated: null, invitedBy: null, delegatedTo: null, delegatedFrom: null, memberOf: null,
|
||||
locationId: null, language: null, links: null,
|
||||
},
|
||||
p2: {
|
||||
'@type': 'Participant', name: 'Alice Johnson', email: 'alice.johnson@example.com', calendarAddress: null, description: null, sendTo: null,
|
||||
kind: 'individual', roles: { owner: true }, participationStatus: 'accepted', participationComment: null,
|
||||
expectReply: false, scheduleAgent: 'server', scheduleForceSend: false, scheduleId: null, scheduleSequence: 0,
|
||||
scheduleStatus: null, scheduleUpdated: null, invitedBy: null, delegatedTo: null, delegatedFrom: null, memberOf: null,
|
||||
locationId: null, language: null, links: null,
|
||||
},
|
||||
p3: {
|
||||
'@type': 'Participant', name: 'Bob Chen', email: 'bob.chen@example.com', calendarAddress: null, description: null, sendTo: null,
|
||||
kind: 'individual', roles: { attendee: true }, participationStatus: 'tentative', participationComment: null,
|
||||
expectReply: true, scheduleAgent: 'server', scheduleForceSend: false, scheduleId: null, scheduleSequence: 0,
|
||||
scheduleStatus: null, scheduleUpdated: null, invitedBy: null, delegatedTo: null, delegatedFrom: null, memberOf: null,
|
||||
locationId: null, language: null, links: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...baseEvent,
|
||||
id: 'demo-event-6',
|
||||
calendarIds: { 'demo-calendar-work': true },
|
||||
uid: 'demo-event-6@example.com',
|
||||
title: 'Lunch Meeting with Sarah',
|
||||
description: 'Design review over lunch',
|
||||
created: demoDate(-3),
|
||||
updated: demoDate(-3),
|
||||
start: demoISODate(3, 12, 0),
|
||||
utcStart: demoDate(3, 12),
|
||||
utcEnd: demoDate(3, 13),
|
||||
duration: 'PT1H',
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
showWithoutTime: false,
|
||||
locations: { loc1: { '@type': 'Location', name: 'The Garden Bistro', description: '123 Oak Street', locationTypes: null, coordinates: null, timeZone: null, links: null, relativeTo: null } },
|
||||
},
|
||||
|
||||
// ── Birthdays calendar ─────────────────────────────────────
|
||||
{
|
||||
...baseEvent,
|
||||
id: 'demo-event-7',
|
||||
calendarIds: { 'demo-calendar-birthdays': true },
|
||||
uid: 'demo-event-7@example.com',
|
||||
title: 'Alice Johnson\'s Birthday',
|
||||
description: '',
|
||||
created: demoDate(-30),
|
||||
updated: demoDate(-30),
|
||||
start: demoISODate(12),
|
||||
utcStart: demoDate(12),
|
||||
utcEnd: demoDate(13),
|
||||
duration: 'P1D',
|
||||
timeZone: null,
|
||||
showWithoutTime: true,
|
||||
freeBusyStatus: 'free' as const,
|
||||
},
|
||||
{
|
||||
...baseEvent,
|
||||
id: 'demo-event-8',
|
||||
calendarIds: { 'demo-calendar-birthdays': true },
|
||||
uid: 'demo-event-8@example.com',
|
||||
title: 'Carlos Rivera\'s Birthday',
|
||||
description: '',
|
||||
created: demoDate(-30),
|
||||
updated: demoDate(-30),
|
||||
start: demoISODate(-3),
|
||||
utcStart: demoDate(-3),
|
||||
utcEnd: demoDate(-2),
|
||||
duration: 'P1D',
|
||||
timeZone: null,
|
||||
showWithoutTime: true,
|
||||
freeBusyStatus: 'free' as const,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { ContactCard, AddressBook } from '@/lib/jmap/types';
|
||||
|
||||
export function createDemoAddressBooks(): AddressBook[] {
|
||||
return [
|
||||
{
|
||||
id: 'demo-addressbook-personal',
|
||||
name: 'Personal',
|
||||
isDefault: true,
|
||||
isSubscribed: true,
|
||||
sortOrder: 1,
|
||||
myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: false },
|
||||
},
|
||||
{
|
||||
id: 'demo-addressbook-work',
|
||||
name: 'Work',
|
||||
isDefault: false,
|
||||
isSubscribed: true,
|
||||
sortOrder: 2,
|
||||
myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function createDemoContacts(): ContactCard[] {
|
||||
return [
|
||||
// ── Personal address book ──────────────────────────────────
|
||||
{
|
||||
id: 'demo-contact-1',
|
||||
addressBookIds: { 'demo-addressbook-personal': true },
|
||||
kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Johnson' }] },
|
||||
emails: { e1: { address: 'alice.johnson@example.com', contexts: { work: true }, pref: 1 } },
|
||||
phones: { p1: { number: '+1-555-0101', features: { voice: true }, contexts: { work: true } } },
|
||||
organizations: { o1: { name: 'Acme Corp', units: [{ name: 'Engineering' }] } },
|
||||
titles: { t1: { name: 'Senior Engineer', kind: 'title' } },
|
||||
anniversaries: { a1: { kind: 'birth', date: { year: 1990, month: 3, day: 15 } } },
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-2',
|
||||
addressBookIds: { 'demo-addressbook-personal': true },
|
||||
kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Bob' }, { kind: 'surname', value: 'Chen' }] },
|
||||
emails: {
|
||||
e1: { address: 'bob.chen@example.com', contexts: { work: true }, pref: 1 },
|
||||
e2: { address: 'bob.personal@email.example', contexts: { private: true } },
|
||||
},
|
||||
phones: {
|
||||
p1: { number: '+1-555-0102', features: { voice: true }, contexts: { work: true } },
|
||||
p2: { number: '+1-555-0103', features: { cell: true }, contexts: { private: true } },
|
||||
},
|
||||
organizations: { o1: { name: 'Acme Corp', units: [{ name: 'Backend Team' }] } },
|
||||
titles: { t1: { name: 'Staff Engineer', kind: 'title' } },
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-3',
|
||||
addressBookIds: { 'demo-addressbook-personal': true },
|
||||
kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Sarah' }, { kind: 'surname', value: 'Kim' }] },
|
||||
emails: { e1: { address: 'sarah.kim@example.com', pref: 1 } },
|
||||
phones: { p1: { number: '+1-555-0104', features: { voice: true } } },
|
||||
organizations: { o1: { name: 'DesignCo' } },
|
||||
titles: { t1: { name: 'UX Designer', kind: 'title' } },
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-4',
|
||||
addressBookIds: { 'demo-addressbook-personal': true },
|
||||
kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Carlos' }, { kind: 'surname', value: 'Rivera' }] },
|
||||
emails: { e1: { address: 'carlos.rivera@example.com', pref: 1 } },
|
||||
phones: { p1: { number: '+1-555-0105', features: { cell: true } } },
|
||||
notes: { n1: { note: 'Met at the DevConf 2024 conference' } },
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-5',
|
||||
addressBookIds: { 'demo-addressbook-personal': true },
|
||||
kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Emma' }, { kind: 'surname', value: 'Wilson' }] },
|
||||
emails: { e1: { address: 'emma.wilson@example.com', pref: 1 } },
|
||||
addresses: {
|
||||
a1: {
|
||||
components: [
|
||||
{ kind: 'number', value: '456' },
|
||||
{ kind: 'name', value: 'Elm Street' },
|
||||
{ kind: 'locality', value: 'Springfield' },
|
||||
{ kind: 'region', value: 'IL' },
|
||||
{ kind: 'postcode', value: '62701' },
|
||||
],
|
||||
contexts: { private: true },
|
||||
},
|
||||
},
|
||||
anniversaries: { a1: { kind: 'birth', date: { month: 7, day: 22 } } },
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-6',
|
||||
addressBookIds: { 'demo-addressbook-personal': true },
|
||||
kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'David' }, { kind: 'surname', value: 'Park' }] },
|
||||
emails: { e1: { address: 'david.park@example.com', pref: 1 } },
|
||||
phones: { p1: { number: '+82-10-1234-5678', features: { cell: true } } },
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-7',
|
||||
addressBookIds: { 'demo-addressbook-personal': true },
|
||||
kind: 'org',
|
||||
name: { components: [{ kind: 'surname', value: 'Local Coffee Shop' }] },
|
||||
emails: { e1: { address: 'hello@localcoffee.example', pref: 1 } },
|
||||
phones: { p1: { number: '+1-555-0200', features: { voice: true } } },
|
||||
addresses: {
|
||||
a1: {
|
||||
components: [
|
||||
{ kind: 'number', value: '789' },
|
||||
{ kind: 'name', value: 'Main Street' },
|
||||
{ kind: 'locality', value: 'Anytown' },
|
||||
{ kind: 'region', value: 'CA' },
|
||||
{ kind: 'postcode', value: '90210' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-8',
|
||||
addressBookIds: { 'demo-addressbook-personal': true },
|
||||
kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Lisa' }, { kind: 'surname', value: 'Tanaka' }] },
|
||||
emails: { e1: { address: 'lisa.tanaka@example.com', pref: 1 } },
|
||||
},
|
||||
|
||||
// ── Work address book ──────────────────────────────────────
|
||||
{
|
||||
id: 'demo-contact-9',
|
||||
addressBookIds: { 'demo-addressbook-work': true },
|
||||
kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Michael' }, { kind: 'surname', value: 'Torres' }] },
|
||||
emails: { e1: { address: 'michael.torres@company.example', contexts: { work: true }, pref: 1 } },
|
||||
phones: { p1: { number: '+1-555-0301', features: { voice: true }, contexts: { work: true } } },
|
||||
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Product' }] } },
|
||||
titles: { t1: { name: 'Product Manager', kind: 'title' } },
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-10',
|
||||
addressBookIds: { 'demo-addressbook-work': true },
|
||||
kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Rachel' }, { kind: 'surname', value: 'Green' }] },
|
||||
emails: { e1: { address: 'rachel.green@company.example', contexts: { work: true }, pref: 1 } },
|
||||
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Marketing' }] } },
|
||||
titles: { t1: { name: 'Marketing Lead', kind: 'title' } },
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-11',
|
||||
addressBookIds: { 'demo-addressbook-work': true },
|
||||
kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'James' }, { kind: 'surname', value: 'Miller' }] },
|
||||
emails: { e1: { address: 'james.miller@company.example', contexts: { work: true }, pref: 1 } },
|
||||
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Engineering' }] } },
|
||||
titles: { t1: { name: 'CTO', kind: 'title' } },
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-12',
|
||||
addressBookIds: { 'demo-addressbook-work': true },
|
||||
kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Priya' }, { kind: 'surname', value: 'Sharma' }] },
|
||||
emails: { e1: { address: 'priya.sharma@company.example', contexts: { work: true }, pref: 1 } },
|
||||
organizations: { o1: { name: 'Company Inc', units: [{ name: 'QA' }] } },
|
||||
titles: { t1: { name: 'QA Engineer', kind: 'title' } },
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-13',
|
||||
addressBookIds: { 'demo-addressbook-work': true },
|
||||
kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Ahmed' }, { kind: 'surname', value: 'Hassan' }] },
|
||||
emails: { e1: { address: 'ahmed.hassan@company.example', contexts: { work: true }, pref: 1 } },
|
||||
organizations: { o1: { name: 'Company Inc', units: [{ name: 'DevOps' }] } },
|
||||
titles: { t1: { name: 'DevOps Engineer', kind: 'title' } },
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-14',
|
||||
addressBookIds: { 'demo-addressbook-work': true },
|
||||
kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Maria' }, { kind: 'surname', value: 'Lopez' }] },
|
||||
emails: { e1: { address: 'maria.lopez@company.example', contexts: { work: true }, pref: 1 } },
|
||||
organizations: { o1: { name: 'Company Inc', units: [{ name: 'HR' }] } },
|
||||
titles: { t1: { name: 'HR Business Partner', kind: 'title' } },
|
||||
},
|
||||
{
|
||||
id: 'demo-contact-15',
|
||||
addressBookIds: { 'demo-addressbook-work': true },
|
||||
kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Wei' }, { kind: 'surname', value: 'Zhang' }] },
|
||||
emails: { e1: { address: 'wei.zhang@company.example', contexts: { work: true }, pref: 1 } },
|
||||
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Data Science' }] } },
|
||||
titles: { t1: { name: 'Data Scientist', kind: 'title' } },
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
import { demoDate } from '../demo-utils';
|
||||
|
||||
export function createDemoEmails(): Email[] {
|
||||
const now = new Date();
|
||||
|
||||
return [
|
||||
// ── Inbox ───────────────────────────────────────────────────
|
||||
{
|
||||
id: 'demo-email-1',
|
||||
threadId: 'demo-thread-1',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: {},
|
||||
size: 4200,
|
||||
receivedAt: demoDate(0, -2),
|
||||
from: [{ name: 'Bulwark Team', email: 'welcome@bulwark.email' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
subject: 'Welcome to Bulwark Mail!',
|
||||
sentAt: demoDate(0, -2),
|
||||
preview: 'Thanks for trying out Bulwark Mail. This is a demo environment where you can explore all features...',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-1', size: 350, type: 'text/plain' }],
|
||||
htmlBody: [{ partId: '2', blobId: 'blob-2', size: 800, type: 'text/html' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Thanks for trying out Bulwark Mail!\n\nThis is a demo environment where you can explore all features without connecting to a real server. All data stays on your device.\n\nFeel free to:\n- Read, compose, and organize emails\n- Manage contacts and calendars\n- Configure filters and settings\n- Try keyboard shortcuts (press ? to see them)\n\nEnjoy exploring!' },
|
||||
'2': { value: '<div><h2>Welcome to Bulwark Mail!</h2><p>Thanks for trying out Bulwark Mail!</p><p>This is a demo environment where you can explore all features without connecting to a real server. <strong>All data stays on your device.</strong></p><p>Feel free to:</p><ul><li>Read, compose, and organize emails</li><li>Manage contacts and calendars</li><li>Configure filters and settings</li><li>Try keyboard shortcuts (press <kbd>?</kbd> to see them)</li></ul><p>Enjoy exploring!</p></div>' },
|
||||
},
|
||||
messageId: '<welcome@demo.bulwark.email>',
|
||||
},
|
||||
{
|
||||
id: 'demo-email-2',
|
||||
threadId: 'demo-thread-2',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: { $seen: true },
|
||||
size: 18500,
|
||||
receivedAt: demoDate(-1, -5),
|
||||
from: [{ name: 'TechDigest Weekly', email: 'newsletter@techdigest.example' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
subject: 'This Week in Tech: AI Developments & Open Source Updates',
|
||||
sentAt: demoDate(-1, -5),
|
||||
preview: 'Your weekly roundup of the most important technology news and open source developments...',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-3', size: 2400, type: 'text/plain' }],
|
||||
htmlBody: [{ partId: '2', blobId: 'blob-4', size: 5200, type: 'text/html' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'This Week in Tech\n\n1. AI-Powered Code Review Tools\nNew tools are making code reviews faster and more thorough...\n\n2. Open Source Licensing Update\nThe OSI has published new guidelines for AI-generated code...\n\n3. WebAssembly 2.0 Draft\nThe W3C has released the first draft of WebAssembly 2.0...\n\nRead more at techdigest.example' },
|
||||
'2': { value: '<div style="max-width:600px;margin:0 auto;"><h1>This Week in Tech</h1><h3>1. AI-Powered Code Review Tools</h3><p>New tools are making code reviews faster and more thorough, with several open-source options gaining traction.</p><h3>2. Open Source Licensing Update</h3><p>The OSI has published new guidelines for AI-generated code contributions to open source projects.</p><h3>3. WebAssembly 2.0 Draft</h3><p>The W3C has released the first draft of WebAssembly 2.0, promising improved memory management.</p></div>' },
|
||||
},
|
||||
messageId: '<weekly-42@techdigest.example>',
|
||||
},
|
||||
// Thread: Project discussion (3 emails in same thread)
|
||||
{
|
||||
id: 'demo-email-3a',
|
||||
threadId: 'demo-thread-3',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: { $seen: true },
|
||||
size: 3100,
|
||||
receivedAt: demoDate(-3, -10),
|
||||
from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }, { name: 'Bob Chen', email: 'bob.chen@example.com' }],
|
||||
subject: 'Q4 Project Timeline',
|
||||
sentAt: demoDate(-3, -10),
|
||||
preview: 'Hi team, I wanted to share the updated timeline for our Q4 deliverables...',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-5', size: 450, type: 'text/plain' }],
|
||||
htmlBody: [{ partId: '2', blobId: 'blob-6', size: 650, type: 'text/html' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Hi team,\n\nI wanted to share the updated timeline for our Q4 deliverables:\n\n- Phase 1: Design review — Oct 15\n- Phase 2: Development — Nov 1-30\n- Phase 3: Testing — Dec 1-15\n- Phase 4: Launch — Dec 20\n\nPlease review and let me know if you see any conflicts.\n\nBest,\nAlice' },
|
||||
'2': { value: '<p>Hi team,</p><p>I wanted to share the updated timeline for our Q4 deliverables:</p><ul><li>Phase 1: Design review — Oct 15</li><li>Phase 2: Development — Nov 1-30</li><li>Phase 3: Testing — Dec 1-15</li><li>Phase 4: Launch — Dec 20</li></ul><p>Please review and let me know if you see any conflicts.</p><p>Best,<br>Alice</p>' },
|
||||
},
|
||||
messageId: '<q4-timeline-1@example.com>',
|
||||
},
|
||||
{
|
||||
id: 'demo-email-3b',
|
||||
threadId: 'demo-thread-3',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: { $seen: true },
|
||||
size: 3500,
|
||||
receivedAt: demoDate(-2, -8),
|
||||
from: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }],
|
||||
to: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }, { name: 'Demo User', email: 'demo@example.com' }],
|
||||
subject: 'Re: Q4 Project Timeline',
|
||||
sentAt: demoDate(-2, -8),
|
||||
preview: 'Looks good to me! One concern: the testing window might be tight given the holidays...',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-7', size: 520, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Looks good to me! One concern: the testing window might be tight given the holidays. Could we start testing a few days earlier?\n\nAlso, should we set up a shared doc for tracking blockers?\n\n— Bob' },
|
||||
},
|
||||
messageId: '<q4-timeline-2@example.com>',
|
||||
inReplyTo: ['<q4-timeline-1@example.com>'],
|
||||
references: ['<q4-timeline-1@example.com>'],
|
||||
},
|
||||
{
|
||||
id: 'demo-email-3c',
|
||||
threadId: 'demo-thread-3',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: {},
|
||||
size: 3800,
|
||||
receivedAt: demoDate(-1, -3),
|
||||
from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
|
||||
to: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }, { name: 'Demo User', email: 'demo@example.com' }],
|
||||
subject: 'Re: Q4 Project Timeline',
|
||||
sentAt: demoDate(-1, -3),
|
||||
preview: 'Great point Bob. Let\'s move testing to Nov 28. I\'ll create the shared doc today...',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-8', size: 400, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Great point Bob. Let\'s move testing to Nov 28. I\'ll create the shared doc today and share the link.\n\nUpdated timeline:\n- Design review: Oct 15\n- Development: Nov 1-27\n- Testing: Nov 28 - Dec 15\n- Launch: Dec 20\n\n— Alice' },
|
||||
},
|
||||
messageId: '<q4-timeline-3@example.com>',
|
||||
inReplyTo: ['<q4-timeline-2@example.com>'],
|
||||
references: ['<q4-timeline-1@example.com>', '<q4-timeline-2@example.com>'],
|
||||
},
|
||||
// Email with attachments
|
||||
{
|
||||
id: 'demo-email-4',
|
||||
threadId: 'demo-thread-4',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: {},
|
||||
size: 245000,
|
||||
receivedAt: demoDate(0, -6),
|
||||
from: [{ name: 'Sarah Kim', email: 'sarah.kim@example.com' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
subject: 'Invoice #2024-089 & Project Screenshot',
|
||||
sentAt: demoDate(0, -6),
|
||||
preview: 'Hi, please find attached the invoice for October and a screenshot of the latest prototype...',
|
||||
hasAttachment: true,
|
||||
textBody: [{ partId: '1', blobId: 'blob-9', size: 280, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Hi,\n\nPlease find attached the invoice for October and a screenshot of the latest prototype.\n\nLet me know if you have any questions.\n\nBest regards,\nSarah' },
|
||||
},
|
||||
attachments: [
|
||||
{ partId: 'att-1', blobId: 'demo-blob-att-1', size: 145000, name: 'Invoice-2024-089.pdf', type: 'application/pdf' },
|
||||
{ partId: 'att-2', blobId: 'demo-blob-att-2', size: 89000, name: 'prototype-v3.png', type: 'image/png' },
|
||||
],
|
||||
messageId: '<invoice-089@example.com>',
|
||||
},
|
||||
// Starred email
|
||||
{
|
||||
id: 'demo-email-5',
|
||||
threadId: 'demo-thread-5',
|
||||
mailboxIds: { 'demo-mailbox-inbox': true },
|
||||
keywords: { $seen: true, $flagged: true },
|
||||
size: 2800,
|
||||
receivedAt: demoDate(-2, -1),
|
||||
from: [{ name: 'Carlos Rivera', email: 'carlos.rivera@example.com' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
subject: 'Reminder: Team Dinner Friday',
|
||||
sentAt: demoDate(-2, -1),
|
||||
preview: 'Hey! Just a reminder about our team dinner this Friday at 7 PM at The Garden Bistro...',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-10', size: 320, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Hey!\n\nJust a reminder about our team dinner this Friday at 7 PM at The Garden Bistro. I\'ve made a reservation for 8 people.\n\nAddress: 123 Oak Street\n\nLet me know if you can make it!\n\nCheers,\nCarlos' },
|
||||
},
|
||||
messageId: '<dinner-reminder@example.com>',
|
||||
},
|
||||
|
||||
// ── Sent ────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'demo-email-6',
|
||||
threadId: 'demo-thread-6',
|
||||
mailboxIds: { 'demo-mailbox-sent': true },
|
||||
keywords: { $seen: true },
|
||||
size: 2100,
|
||||
receivedAt: demoDate(-1, -4),
|
||||
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
to: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
|
||||
subject: 'Updated Requirements Document',
|
||||
sentAt: demoDate(-1, -4),
|
||||
preview: 'Hi Alice, I\'ve updated the requirements document with the changes we discussed...',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-11', size: 290, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Hi Alice,\n\nI\'ve updated the requirements document with the changes we discussed in yesterday\'s meeting. The main updates are in sections 3 and 5.\n\nLet me know if you have any questions.\n\nBest,\nDemo User' },
|
||||
},
|
||||
messageId: '<sent-1@example.com>',
|
||||
},
|
||||
{
|
||||
id: 'demo-email-7',
|
||||
threadId: 'demo-thread-7',
|
||||
mailboxIds: { 'demo-mailbox-sent': true },
|
||||
keywords: { $seen: true },
|
||||
size: 1800,
|
||||
receivedAt: demoDate(-4, -2),
|
||||
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
to: [{ name: 'Sarah Kim', email: 'sarah.kim@example.com' }],
|
||||
subject: 'Re: Design Feedback',
|
||||
sentAt: demoDate(-4, -2),
|
||||
preview: 'Thanks Sarah! The new color scheme looks great. I especially like the contrast improvements...',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-12', size: 250, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Thanks Sarah! The new color scheme looks great. I especially like the contrast improvements for accessibility.\n\nLet\'s go with Option B for the navigation.\n\nBest,\nDemo User' },
|
||||
},
|
||||
messageId: '<sent-2@example.com>',
|
||||
},
|
||||
|
||||
// ── Drafts ──────────────────────────────────────────────────
|
||||
{
|
||||
id: 'demo-email-8',
|
||||
threadId: 'demo-thread-8',
|
||||
mailboxIds: { 'demo-mailbox-drafts': true },
|
||||
keywords: { $seen: true, $draft: true },
|
||||
size: 900,
|
||||
receivedAt: demoDate(0, -1),
|
||||
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
to: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }],
|
||||
subject: 'Meeting Notes - Draft',
|
||||
sentAt: demoDate(0, -1),
|
||||
preview: 'Here are the notes from today\'s standup...',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-13', size: 180, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Here are the notes from today\'s standup:\n\n- API integration on track\n- Need to resolve the caching issue\n- ' },
|
||||
},
|
||||
messageId: '<draft-1@example.com>',
|
||||
},
|
||||
|
||||
// ── Trash ───────────────────────────────────────────────────
|
||||
{
|
||||
id: 'demo-email-9',
|
||||
threadId: 'demo-thread-9',
|
||||
mailboxIds: { 'demo-mailbox-trash': true },
|
||||
keywords: { $seen: true },
|
||||
size: 15200,
|
||||
receivedAt: demoDate(-5, -3),
|
||||
from: [{ name: 'Promo Store', email: 'deals@promostore.example' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
subject: '🎉 Flash Sale: 50% Off Everything!',
|
||||
sentAt: demoDate(-5, -3),
|
||||
preview: 'Limited time offer! Get 50% off all items in our store...',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-14', size: 400, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Limited time offer! Get 50% off all items in our store. Use code FLASH50 at checkout.' },
|
||||
},
|
||||
messageId: '<promo-1@promostore.example>',
|
||||
},
|
||||
{
|
||||
id: 'demo-email-10',
|
||||
threadId: 'demo-thread-10',
|
||||
mailboxIds: { 'demo-mailbox-trash': true },
|
||||
keywords: { $seen: true },
|
||||
size: 2300,
|
||||
receivedAt: demoDate(-7, 0),
|
||||
from: [{ name: 'System Notification', email: 'noreply@service.example' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
subject: 'Your password was changed',
|
||||
sentAt: demoDate(-7, 0),
|
||||
preview: 'Your account password was successfully changed on...',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-15', size: 200, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Your account password was successfully changed. If you did not make this change, please contact support immediately.' },
|
||||
},
|
||||
messageId: '<notification-1@service.example>',
|
||||
},
|
||||
|
||||
// ── Projects ────────────────────────────────────────────────
|
||||
{
|
||||
id: 'demo-email-11',
|
||||
threadId: 'demo-thread-11',
|
||||
mailboxIds: { 'demo-mailbox-projects': true },
|
||||
keywords: { $seen: true, $flagged: true },
|
||||
size: 4500,
|
||||
receivedAt: demoDate(-2, -7),
|
||||
from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
subject: '[Project] Sprint Planning Agenda',
|
||||
sentAt: demoDate(-2, -7),
|
||||
preview: 'Here\'s the agenda for next week\'s sprint planning session...',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-16', size: 600, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Hi team,\n\nHere\'s the agenda for next week\'s sprint planning:\n\n1. Review previous sprint velocity\n2. Discuss tech debt items\n3. Prioritize backlog\n4. Assign story points\n5. Capacity planning\n\nPlease come prepared with your updates.\n\nThanks,\nAlice' },
|
||||
},
|
||||
messageId: '<project-1@example.com>',
|
||||
},
|
||||
{
|
||||
id: 'demo-email-12',
|
||||
threadId: 'demo-thread-12',
|
||||
mailboxIds: { 'demo-mailbox-projects': true },
|
||||
keywords: {},
|
||||
size: 3200,
|
||||
receivedAt: demoDate(0, -8),
|
||||
from: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
subject: '[Project] API Rate Limiting Discussion',
|
||||
sentAt: demoDate(0, -8),
|
||||
preview: 'I\'ve been thinking about our rate limiting approach and wanted to propose a few changes...',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-17', size: 480, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Hey,\n\nI\'ve been thinking about our rate limiting approach and wanted to propose:\n\n1. Token bucket algorithm instead of fixed window\n2. Per-endpoint limits rather than global\n3. Graduated response (warn → throttle → block)\n\nThoughts? I can put together a more detailed RFC if we agree on the direction.\n\n— Bob' },
|
||||
},
|
||||
messageId: '<project-2@example.com>',
|
||||
},
|
||||
|
||||
// ── Archive ─────────────────────────────────────────────────
|
||||
{
|
||||
id: 'demo-email-13',
|
||||
threadId: 'demo-thread-13',
|
||||
mailboxIds: { 'demo-mailbox-archive': true },
|
||||
keywords: { $seen: true },
|
||||
size: 2600,
|
||||
receivedAt: demoDate(-14, -6),
|
||||
from: [{ name: 'HR Department', email: 'hr@company.example' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
subject: 'Updated PTO Policy - Effective January 1',
|
||||
sentAt: demoDate(-14, -6),
|
||||
preview: 'Please review the updated PTO policy that takes effect January 1st...',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-18', size: 380, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Dear team,\n\nPlease review the updated PTO policy effective January 1st. Key changes include:\n\n- Increased annual allowance from 20 to 25 days\n- Flexible half-day options\n- Rollover limit increased to 10 days\n\nPlease acknowledge receipt.\n\nBest,\nHR Department' },
|
||||
},
|
||||
messageId: '<hr-policy-1@company.example>',
|
||||
},
|
||||
|
||||
// ── Receipts ────────────────────────────────────────────────
|
||||
{
|
||||
id: 'demo-email-14',
|
||||
threadId: 'demo-thread-14',
|
||||
mailboxIds: { 'demo-mailbox-receipts': true },
|
||||
keywords: { $seen: true },
|
||||
size: 5200,
|
||||
receivedAt: demoDate(-3, -12),
|
||||
from: [{ name: 'Cloud Services', email: 'billing@cloudprovider.example' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
subject: 'Payment Receipt - Invoice #INV-2024-1042',
|
||||
sentAt: demoDate(-3, -12),
|
||||
preview: 'Your payment of $49.99 has been processed successfully...',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-19', size: 350, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Payment Confirmation\n\nAmount: $49.99\nDate: Processing date\nInvoice: INV-2024-1042\nService: Cloud Hosting (Standard Plan)\n\nThank you for your payment.' },
|
||||
},
|
||||
messageId: '<receipt-1@cloudprovider.example>',
|
||||
},
|
||||
|
||||
// ── Spam ────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'demo-email-15',
|
||||
threadId: 'demo-thread-15',
|
||||
mailboxIds: { 'demo-mailbox-junk': true },
|
||||
keywords: {},
|
||||
size: 8900,
|
||||
receivedAt: demoDate(-1, -9),
|
||||
from: [{ name: 'Prize Center', email: 'winner@totallylegit.example' }],
|
||||
to: [{ name: 'Demo User', email: 'demo@example.com' }],
|
||||
subject: 'Congratulations! You Won $1,000,000!!!',
|
||||
sentAt: demoDate(-1, -9),
|
||||
preview: 'Dear lucky winner, you have been selected to receive one million dollars...',
|
||||
hasAttachment: false,
|
||||
textBody: [{ partId: '1', blobId: 'blob-20', size: 500, type: 'text/plain' }],
|
||||
bodyValues: {
|
||||
'1': { value: 'Dear lucky winner,\n\nYou have been selected to receive ONE MILLION DOLLARS! Click below to claim your prize immediately.\n\n[This is a demo spam email]' },
|
||||
},
|
||||
messageId: '<spam-1@totallylegit.example>',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { FileNode } from '@/lib/jmap/types';
|
||||
import { demoDate } from '../demo-utils';
|
||||
|
||||
export function createDemoFileNodes(): FileNode[] {
|
||||
return [
|
||||
// Root-level directories
|
||||
{
|
||||
id: 'demo-file-documents',
|
||||
parentId: null,
|
||||
name: 'Documents',
|
||||
type: 'd',
|
||||
blobId: null,
|
||||
size: 0,
|
||||
created: demoDate(-30),
|
||||
updated: demoDate(-2),
|
||||
},
|
||||
{
|
||||
id: 'demo-file-photos',
|
||||
parentId: null,
|
||||
name: 'Photos',
|
||||
type: 'd',
|
||||
blobId: null,
|
||||
size: 0,
|
||||
created: demoDate(-30),
|
||||
updated: demoDate(-5),
|
||||
},
|
||||
|
||||
// Documents contents
|
||||
{
|
||||
id: 'demo-file-meeting-notes',
|
||||
parentId: 'demo-file-documents',
|
||||
name: 'meeting-notes.md',
|
||||
type: 'text/markdown',
|
||||
blobId: 'demo-blob-file-1',
|
||||
size: 2150,
|
||||
created: demoDate(-7),
|
||||
updated: demoDate(-2),
|
||||
},
|
||||
{
|
||||
id: 'demo-file-quarterly-report',
|
||||
parentId: 'demo-file-documents',
|
||||
name: 'quarterly-report.pdf',
|
||||
type: 'application/pdf',
|
||||
blobId: 'demo-blob-file-2',
|
||||
size: 148480,
|
||||
created: demoDate(-14),
|
||||
updated: demoDate(-14),
|
||||
},
|
||||
{
|
||||
id: 'demo-file-todo',
|
||||
parentId: 'demo-file-documents',
|
||||
name: 'todo.txt',
|
||||
type: 'text/plain',
|
||||
blobId: 'demo-blob-file-3',
|
||||
size: 410,
|
||||
created: demoDate(-3),
|
||||
updated: demoDate(-1),
|
||||
},
|
||||
|
||||
// Photos contents
|
||||
{
|
||||
id: 'demo-file-vacation',
|
||||
parentId: 'demo-file-photos',
|
||||
name: 'vacation.jpg',
|
||||
type: 'image/jpeg',
|
||||
blobId: 'demo-blob-file-4',
|
||||
size: 1258291,
|
||||
created: demoDate(-10),
|
||||
updated: demoDate(-10),
|
||||
},
|
||||
{
|
||||
id: 'demo-file-team-photo',
|
||||
parentId: 'demo-file-photos',
|
||||
name: 'team-photo.png',
|
||||
type: 'image/png',
|
||||
blobId: 'demo-blob-file-5',
|
||||
size: 911360,
|
||||
created: demoDate(-21),
|
||||
updated: demoDate(-21),
|
||||
},
|
||||
|
||||
// Root-level file
|
||||
{
|
||||
id: 'demo-file-budget',
|
||||
parentId: null,
|
||||
name: 'budget.xlsx',
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
blobId: 'demo-blob-file-6',
|
||||
size: 68608,
|
||||
created: demoDate(-5),
|
||||
updated: demoDate(-1),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
|
||||
|
||||
export function createDemoSieveCapabilities(): SieveCapabilities {
|
||||
return {
|
||||
implementation: 'Demo Sieve Engine',
|
||||
maxSizeScript: 65536,
|
||||
sieveExtensions: ['fileinto', 'reject', 'vacation', 'imap4flags', 'comparator-i;ascii-casemap', 'body', 'envelope'],
|
||||
notificationMethods: [],
|
||||
externalLists: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function createDemoSieveScripts(): SieveScript[] {
|
||||
return [
|
||||
{
|
||||
id: 'demo-sieve-1',
|
||||
name: 'Default Filters',
|
||||
blobId: 'demo-sieve-blob-1',
|
||||
isActive: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Sieve script content keyed by blobId
|
||||
export function createDemoSieveContent(): Record<string, string> {
|
||||
return {
|
||||
'demo-sieve-blob-1': [
|
||||
'require ["fileinto", "imap4flags"];',
|
||||
'',
|
||||
'# Newsletters to Receipts',
|
||||
'if address :contains "from" "newsletter@" {',
|
||||
' fileinto "Receipts";',
|
||||
' stop;',
|
||||
'}',
|
||||
'',
|
||||
'# Flag emails from boss',
|
||||
'if address :is "from" "alice.johnson@example.com" {',
|
||||
' addflag "\\\\Flagged";',
|
||||
'}',
|
||||
'',
|
||||
'# Move project updates',
|
||||
'if header :contains "subject" "[Project]" {',
|
||||
' fileinto "Projects";',
|
||||
' stop;',
|
||||
'}',
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Identity } from '@/lib/jmap/types';
|
||||
|
||||
export function createDemoIdentities(): Identity[] {
|
||||
return [
|
||||
{
|
||||
id: 'demo-identity-primary',
|
||||
name: 'Demo User',
|
||||
email: 'demo@example.com',
|
||||
textSignature: 'Best regards,\nDemo User\nBulwark Mail Demo',
|
||||
htmlSignature: '<p>Best regards,<br><b>Demo User</b><br>Bulwark Mail Demo</p>',
|
||||
mayDelete: false,
|
||||
},
|
||||
{
|
||||
id: 'demo-identity-alias',
|
||||
name: 'Demo User',
|
||||
email: 'demo+newsletter@example.com',
|
||||
textSignature: '',
|
||||
htmlSignature: '',
|
||||
mayDelete: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
const RIGHTS_SYSTEM = { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: false, mayDelete: false, maySubmit: true };
|
||||
const RIGHTS_CUSTOM = { ...RIGHTS_SYSTEM, mayRename: true, mayDelete: true };
|
||||
|
||||
export function createDemoMailboxes(): Mailbox[] {
|
||||
return [
|
||||
{ id: 'demo-mailbox-inbox', name: 'Inbox', role: 'inbox', sortOrder: 1, totalEmails: 12, unreadEmails: 5, totalThreads: 10, unreadThreads: 4, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||
{ id: 'demo-mailbox-sent', name: 'Sent', role: 'sent', sortOrder: 2, totalEmails: 8, unreadEmails: 0, totalThreads: 8, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||
{ id: 'demo-mailbox-drafts', name: 'Drafts', role: 'drafts', sortOrder: 3, totalEmails: 1, unreadEmails: 0, totalThreads: 1, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||
{ id: 'demo-mailbox-trash', name: 'Trash', role: 'trash', sortOrder: 5, totalEmails: 2, unreadEmails: 0, totalThreads: 2, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||
{ id: 'demo-mailbox-archive', name: 'Archive', role: 'archive', sortOrder: 4, totalEmails: 4, unreadEmails: 0, totalThreads: 4, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||
{ id: 'demo-mailbox-junk', name: 'Spam', role: 'junk', sortOrder: 6, totalEmails: 3, unreadEmails: 1, totalThreads: 3, unreadThreads: 1, myRights: RIGHTS_SYSTEM, isSubscribed: true },
|
||||
{ id: 'demo-mailbox-projects', name: 'Projects', sortOrder: 10, totalEmails: 5, unreadEmails: 2, totalThreads: 5, unreadThreads: 2, myRights: RIGHTS_CUSTOM, isSubscribed: true },
|
||||
{ id: 'demo-mailbox-receipts', name: 'Receipts', sortOrder: 11, totalEmails: 3, unreadEmails: 0, totalThreads: 3, unreadThreads: 0, myRights: RIGHTS_CUSTOM, isSubscribed: true },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { CalendarTask } from '@/lib/jmap/types';
|
||||
import { demoDate } from '../demo-utils';
|
||||
|
||||
export function createDemoCalendarTasks(): CalendarTask[] {
|
||||
return [
|
||||
{
|
||||
id: 'demo-task-1',
|
||||
calendarIds: { 'demo-calendar-personal': true },
|
||||
'@type': 'Task',
|
||||
uid: 'demo-task-uid-1',
|
||||
title: 'Buy groceries',
|
||||
description: 'Milk, bread, eggs, and vegetables',
|
||||
due: demoDate(0, 2),
|
||||
start: null,
|
||||
duration: null,
|
||||
timeZone: null,
|
||||
showWithoutTime: false,
|
||||
progress: 'needs-action',
|
||||
progressUpdated: null,
|
||||
priority: 0,
|
||||
privacy: 'public',
|
||||
keywords: null,
|
||||
categories: null,
|
||||
color: null,
|
||||
created: demoDate(-3),
|
||||
updated: demoDate(-1),
|
||||
recurrenceRules: null,
|
||||
alerts: null,
|
||||
relatedTo: null,
|
||||
},
|
||||
{
|
||||
id: 'demo-task-2',
|
||||
calendarIds: { 'demo-calendar-work': true },
|
||||
'@type': 'Task',
|
||||
uid: 'demo-task-uid-2',
|
||||
title: 'Prepare quarterly report',
|
||||
description: 'Compile Q4 metrics and send to team',
|
||||
due: demoDate(1, 4),
|
||||
start: null,
|
||||
duration: null,
|
||||
timeZone: null,
|
||||
showWithoutTime: false,
|
||||
progress: 'in-process',
|
||||
progressUpdated: demoDate(-1),
|
||||
priority: 1,
|
||||
privacy: 'public',
|
||||
keywords: null,
|
||||
categories: null,
|
||||
color: null,
|
||||
created: demoDate(-5),
|
||||
updated: demoDate(0),
|
||||
recurrenceRules: null,
|
||||
alerts: {
|
||||
'demo-alert-1': {
|
||||
'@type': 'Alert',
|
||||
trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' },
|
||||
action: 'display',
|
||||
acknowledged: null,
|
||||
relatedTo: null,
|
||||
},
|
||||
},
|
||||
relatedTo: null,
|
||||
},
|
||||
{
|
||||
id: 'demo-task-3',
|
||||
calendarIds: { 'demo-calendar-personal': true },
|
||||
'@type': 'Task',
|
||||
uid: 'demo-task-uid-3',
|
||||
title: 'Schedule dentist appointment',
|
||||
description: '',
|
||||
due: demoDate(3),
|
||||
start: null,
|
||||
duration: null,
|
||||
timeZone: null,
|
||||
showWithoutTime: true,
|
||||
progress: 'needs-action',
|
||||
progressUpdated: null,
|
||||
priority: 5,
|
||||
privacy: 'public',
|
||||
keywords: null,
|
||||
categories: null,
|
||||
color: null,
|
||||
created: demoDate(-2),
|
||||
updated: demoDate(-2),
|
||||
recurrenceRules: null,
|
||||
alerts: null,
|
||||
relatedTo: null,
|
||||
},
|
||||
{
|
||||
id: 'demo-task-4',
|
||||
calendarIds: { 'demo-calendar-work': true },
|
||||
'@type': 'Task',
|
||||
uid: 'demo-task-uid-4',
|
||||
title: 'Review pull requests',
|
||||
description: 'Review open PRs from the team',
|
||||
due: demoDate(-1),
|
||||
start: null,
|
||||
duration: null,
|
||||
timeZone: null,
|
||||
showWithoutTime: true,
|
||||
progress: 'completed',
|
||||
progressUpdated: demoDate(0),
|
||||
priority: 0,
|
||||
privacy: 'public',
|
||||
keywords: null,
|
||||
categories: null,
|
||||
color: null,
|
||||
created: demoDate(-4),
|
||||
updated: demoDate(0),
|
||||
recurrenceRules: null,
|
||||
alerts: null,
|
||||
relatedTo: null,
|
||||
},
|
||||
{
|
||||
id: 'demo-task-5',
|
||||
calendarIds: { 'demo-calendar-personal': true },
|
||||
'@type': 'Task',
|
||||
uid: 'demo-task-uid-5',
|
||||
title: 'Pay electricity bill',
|
||||
description: '',
|
||||
due: demoDate(-2),
|
||||
start: null,
|
||||
duration: null,
|
||||
timeZone: null,
|
||||
showWithoutTime: true,
|
||||
progress: 'needs-action',
|
||||
progressUpdated: null,
|
||||
priority: 1,
|
||||
privacy: 'public',
|
||||
keywords: null,
|
||||
categories: null,
|
||||
color: null,
|
||||
created: demoDate(-7),
|
||||
updated: demoDate(-7),
|
||||
recurrenceRules: null,
|
||||
alerts: null,
|
||||
relatedTo: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { VacationResponse } from '@/lib/jmap/types';
|
||||
|
||||
export function createDemoVacationResponse(): VacationResponse {
|
||||
return {
|
||||
id: 'singleton',
|
||||
isEnabled: false,
|
||||
fromDate: null,
|
||||
toDate: null,
|
||||
subject: 'Out of Office',
|
||||
textBody: 'Thank you for your email. I am currently out of the office and will return on Monday. For urgent matters, please contact support@example.com.',
|
||||
htmlBody: '<p>Thank you for your email. I am currently out of the office and will return on Monday.</p><p>For urgent matters, please contact <a href="mailto:support@example.com">support@example.com</a>.</p>',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
const PARENT_ORIGIN = typeof window !== 'undefined'
|
||||
? (document.querySelector('meta[name="parent-origin"]')?.getAttribute('content') || '')
|
||||
: '';
|
||||
|
||||
export function isEmbedded(): boolean {
|
||||
try {
|
||||
return window.self !== window.top;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function notifyParent(type: string, payload: Record<string, unknown> = {}) {
|
||||
if (!isEmbedded()) return;
|
||||
|
||||
const targetOrigin = PARENT_ORIGIN || '*';
|
||||
try {
|
||||
window.parent.postMessage({ source: 'bulwark', type, ...payload }, targetOrigin);
|
||||
} catch {
|
||||
// Cross-origin postMessage may fail in restricted contexts
|
||||
}
|
||||
}
|
||||
|
||||
export function listenFromParent(
|
||||
handler: (msg: { type: string; [k: string]: unknown }) => void,
|
||||
allowedOrigin?: string,
|
||||
): () => void {
|
||||
const listener = (event: MessageEvent) => {
|
||||
// Validate origin if configured
|
||||
if (allowedOrigin && event.origin !== allowedOrigin) return;
|
||||
|
||||
// Only accept messages from the portal
|
||||
if (!event.data || event.data.source !== 'portal') return;
|
||||
|
||||
handler(event.data);
|
||||
};
|
||||
|
||||
window.addEventListener('message', listener);
|
||||
return () => window.removeEventListener('message', listener);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
|
||||
/**
|
||||
* Interface defining the public JMAP client contract.
|
||||
*
|
||||
* Both the real `JMAPClient` (network-backed) and `DemoJMAPClient`
|
||||
* (in-memory/browser-only) implement this interface so that stores
|
||||
* and UI code never need to know which one is active.
|
||||
*/
|
||||
export interface IJMAPClient {
|
||||
// ── Connection lifecycle ──────────────────────────────────────
|
||||
connect(): Promise<void>;
|
||||
disconnect(): void;
|
||||
reconnect(): Promise<void>;
|
||||
ping(): Promise<void>;
|
||||
|
||||
// ── Session / auth accessors ──────────────────────────────────
|
||||
getServerUrl(): string;
|
||||
getAuthHeader(): string;
|
||||
updateAccessToken(token: string): void;
|
||||
getAccountId(): string;
|
||||
getUsername(): string;
|
||||
|
||||
// ── Capabilities ──────────────────────────────────────────────
|
||||
getCapabilities(): Record<string, unknown>;
|
||||
getMaxSizeUpload(): number;
|
||||
getMaxCallsInRequest(): number;
|
||||
getMaxObjectsInGet(): number;
|
||||
getEventSourceUrl(): string | null;
|
||||
supportsEmailSubmission(): boolean;
|
||||
supportsQuota(): boolean;
|
||||
supportsVacationResponse(): boolean;
|
||||
supportsContacts(): boolean;
|
||||
supportsCalendars(): boolean;
|
||||
supportsSieve(): boolean;
|
||||
supportsFiles(): boolean;
|
||||
|
||||
// ── Push / state ──────────────────────────────────────────────
|
||||
setupPushNotifications(): boolean;
|
||||
closePushNotifications(): void;
|
||||
onConnectionChange(callback: (connected: boolean) => void): void;
|
||||
onStateChange(callback: (change: StateChange) => void): void;
|
||||
getLastStates(): AccountStates;
|
||||
setLastStates(states: AccountStates): void;
|
||||
|
||||
// ── Quota ─────────────────────────────────────────────────────
|
||||
getQuota(): Promise<{ used: number; total: number } | null>;
|
||||
|
||||
// ── Mailboxes ─────────────────────────────────────────────────
|
||||
getMailboxes(): Promise<Mailbox[]>;
|
||||
getAllMailboxes(): Promise<Mailbox[]>;
|
||||
createMailbox(name: string, parentId?: string): Promise<Mailbox>;
|
||||
updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }): Promise<void>;
|
||||
deleteMailbox(mailboxId: string): Promise<void>;
|
||||
|
||||
// ── Emails ────────────────────────────────────────────────────
|
||||
getEmails(mailboxId?: string, accountId?: string, limit?: number, position?: number, hasKeyword?: string): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
|
||||
getEmailsInMailbox(mailboxId: string): Promise<Email[]>;
|
||||
getEmail(emailId: string, accountId?: string): Promise<Email | null>;
|
||||
getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>>;
|
||||
searchEmails(query: string, mailboxId?: string, accountId?: string, limit?: number, position?: number): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
|
||||
advancedSearchEmails(
|
||||
filter: Record<string, unknown>,
|
||||
accountId?: string,
|
||||
limit?: number,
|
||||
position?: number,
|
||||
): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
|
||||
|
||||
// ── Email mutations ───────────────────────────────────────────
|
||||
markAsRead(emailId: string, read?: boolean, accountId?: string): Promise<void>;
|
||||
batchMarkAsRead(emailIds: string[], read?: boolean): Promise<void>;
|
||||
toggleStar(emailId: string, starred: boolean): Promise<void>;
|
||||
updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void>;
|
||||
migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>;
|
||||
deleteEmail(emailId: string): Promise<void>;
|
||||
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void>;
|
||||
batchDeleteEmails(emailIds: string[]): Promise<void>;
|
||||
batchMoveEmails(emailIds: string[], toMailboxId: string): Promise<void>;
|
||||
moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void>;
|
||||
emptyMailbox(mailboxId: string): Promise<number>;
|
||||
markAsSpam(emailId: string, accountId?: string): Promise<void>;
|
||||
undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void>;
|
||||
|
||||
// ── Threads ───────────────────────────────────────────────────
|
||||
getThread(threadId: string, accountId?: string): Promise<Thread | null>;
|
||||
getThreadEmails(threadId: string, accountId?: string): Promise<Email[]>;
|
||||
|
||||
// ── Compose / Send ────────────────────────────────────────────
|
||||
createDraft(
|
||||
to: string[],
|
||||
subject: string,
|
||||
body: string,
|
||||
cc?: string[],
|
||||
bcc?: string[],
|
||||
identityId?: string,
|
||||
fromEmail?: string,
|
||||
draftId?: string,
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
|
||||
fromName?: string,
|
||||
): Promise<string>;
|
||||
|
||||
sendEmail(
|
||||
to: string[],
|
||||
subject: string,
|
||||
body: string,
|
||||
cc?: string[],
|
||||
bcc?: string[],
|
||||
identityId?: string,
|
||||
fromEmail?: string,
|
||||
draftId?: string,
|
||||
fromName?: string,
|
||||
htmlBody?: string,
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
|
||||
): Promise<void>;
|
||||
|
||||
sendImipReply(opts: {
|
||||
organizerEmail: string;
|
||||
organizerName?: string;
|
||||
attendeeEmail: string;
|
||||
attendeeName?: string;
|
||||
uid: string;
|
||||
summary?: string;
|
||||
dtStart?: string;
|
||||
dtEnd?: string;
|
||||
timeZone?: string;
|
||||
isAllDay?: boolean;
|
||||
sequence?: number;
|
||||
status: 'ACCEPTED' | 'TENTATIVE' | 'DECLINED';
|
||||
identityId?: string;
|
||||
}): Promise<void>;
|
||||
|
||||
sendImipInvitation(event: CalendarEvent): Promise<void>;
|
||||
sendImipCancellation(event: CalendarEvent): Promise<void>;
|
||||
|
||||
// ── Blobs ─────────────────────────────────────────────────────
|
||||
uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }>;
|
||||
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string;
|
||||
fetchBlob(blobId: string, name?: string, type?: string): Promise<Blob>;
|
||||
fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string>;
|
||||
fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer>;
|
||||
downloadBlob(blobId: string, name?: string, type?: string): Promise<void>;
|
||||
|
||||
// ── Identities ────────────────────────────────────────────────
|
||||
getIdentities(): Promise<Identity[]>;
|
||||
createIdentity(
|
||||
name: string,
|
||||
email: string,
|
||||
replyTo?: EmailAddress[] | null,
|
||||
bcc?: EmailAddress[] | null,
|
||||
htmlSignature?: string,
|
||||
textSignature?: string,
|
||||
): Promise<Identity>;
|
||||
updateIdentity(
|
||||
identityId: string,
|
||||
updates: {
|
||||
name?: string;
|
||||
replyTo?: EmailAddress[] | null;
|
||||
bcc?: EmailAddress[] | null;
|
||||
htmlSignature?: string;
|
||||
textSignature?: string;
|
||||
},
|
||||
): Promise<void>;
|
||||
deleteIdentity(identityId: string): Promise<void>;
|
||||
|
||||
// ── Vacation ──────────────────────────────────────────────────
|
||||
getVacationResponse(): Promise<VacationResponse>;
|
||||
setVacationResponse(updates: Partial<VacationResponse>): Promise<void>;
|
||||
|
||||
// ── Contacts ──────────────────────────────────────────────────
|
||||
getContactsAccountId(): string;
|
||||
getAddressBooks(): Promise<AddressBook[]>;
|
||||
getAllAddressBooks(): Promise<AddressBook[]>;
|
||||
getContacts(addressBookId?: string): Promise<ContactCard[]>;
|
||||
getAllContacts(): Promise<ContactCard[]>;
|
||||
getContact(contactId: string, accountId?: string): Promise<ContactCard | null>;
|
||||
createContact(contact: Partial<ContactCard>, targetAccountId?: string): Promise<ContactCard>;
|
||||
updateContact(contactId: string, updates: Partial<ContactCard>, targetAccountId?: string): Promise<void>;
|
||||
deleteContact(contactId: string, targetAccountId?: string): Promise<void>;
|
||||
searchContacts(query: string): Promise<ContactCard[]>;
|
||||
|
||||
// ── Calendars ─────────────────────────────────────────────────
|
||||
getCalendarsAccountId(): string;
|
||||
getCalendars(): Promise<Calendar[]>;
|
||||
getAllCalendars(): Promise<Calendar[]>;
|
||||
createCalendar(calendar: Partial<Calendar>, targetAccountId?: string): Promise<Calendar>;
|
||||
updateCalendar(calendarId: string, updates: Partial<Calendar>, targetAccountId?: string): Promise<void>;
|
||||
deleteCalendar(calendarId: string, targetAccountId?: string): Promise<void>;
|
||||
getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]>;
|
||||
getCalendarEvent(id: string, targetAccountId?: string): Promise<CalendarEvent | null>;
|
||||
createCalendarEvent(event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<CalendarEvent>;
|
||||
updateCalendarEvent(
|
||||
eventId: string,
|
||||
updates: Partial<CalendarEvent>,
|
||||
sendSchedulingMessages?: boolean,
|
||||
targetAccountId?: string,
|
||||
): Promise<void>;
|
||||
deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<void>;
|
||||
batchDeleteCalendarEvents(eventIds: string[], targetAccountId?: string): Promise<{ destroyed: string[]; notDestroyed: string[] }>;
|
||||
queryCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, targetAccountId?: string): Promise<CalendarEvent[]>;
|
||||
queryAllCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number): Promise<CalendarEvent[]>;
|
||||
parseCalendarEvents(accountId: string, blobId: string): Promise<Partial<CalendarEvent>[]>;
|
||||
|
||||
// ── Calendar Tasks ────────────────────────────────────────────
|
||||
getCalendarTasks(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]>;
|
||||
createCalendarTask(task: Partial<CalendarTask>, targetAccountId?: string): Promise<CalendarTask>;
|
||||
updateCalendarTask(taskId: string, updates: Partial<CalendarTask>, targetAccountId?: string): Promise<void>;
|
||||
deleteCalendarTask(taskId: string, targetAccountId?: string): Promise<void>;
|
||||
|
||||
// ── Sieve / Filters ──────────────────────────────────────────
|
||||
getSieveAccountId(): string;
|
||||
getSieveCapabilities(): SieveCapabilities | null;
|
||||
getSieveScripts(): Promise<SieveScript[]>;
|
||||
getSieveScriptContent(blobId: string): Promise<string>;
|
||||
createSieveScript(name: string, content: string, activate?: boolean): Promise<SieveScript>;
|
||||
updateSieveScript(scriptId: string, content: string, activate?: boolean): Promise<void>;
|
||||
deleteSieveScript(scriptId: string): Promise<void>;
|
||||
validateSieveScript(content: string): Promise<{ isValid: boolean; errors?: string[] }>;
|
||||
|
||||
// ── Files (WebDAV / FileNode) ─────────────────────────────────
|
||||
getFilesAccountId(): string;
|
||||
probeFileNodeSupport(): Promise<boolean>;
|
||||
listFileNodes(parentId: string | null): Promise<FileNode[]>;
|
||||
getFileNodes(ids: string[] | null, properties?: string[]): Promise<FileNode[]>;
|
||||
createFileDirectory(name: string, parentId: string | null): Promise<FileNode>;
|
||||
createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode>;
|
||||
updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void>;
|
||||
destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }>;
|
||||
copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode>;
|
||||
|
||||
// ── S/MIME raw-email helpers ──────────────────────────────────
|
||||
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>): Promise<string>;
|
||||
submitEmail(emailId: string, identityId: string): Promise<void>;
|
||||
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string): Promise<void>;
|
||||
}
|
||||
+99
-19
@@ -1,5 +1,6 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, FileNode, FileNodeFilter } from "./types";
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
import type { IJMAPClient } from "./client-interface";
|
||||
import { toWildcardQuery } from "./search-utils";
|
||||
|
||||
// JMAP protocol types - these are intentionally flexible due to server variations
|
||||
@@ -99,7 +100,7 @@ function computeHasMore(position: number, emailCount: number, total: number, lim
|
||||
return emailCount === limit;
|
||||
}
|
||||
|
||||
export class JMAPClient {
|
||||
export class JMAPClient implements IJMAPClient {
|
||||
private serverUrl: string;
|
||||
private username: string;
|
||||
private password: string;
|
||||
@@ -762,6 +763,56 @@ export class JMAPClient {
|
||||
]);
|
||||
}
|
||||
|
||||
async migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number> {
|
||||
// Query all email IDs that have the old keyword
|
||||
const allIds: string[] = [];
|
||||
let position = 0;
|
||||
const batchSize = 100;
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const response = await this.request([
|
||||
["Email/query", {
|
||||
accountId: this.accountId,
|
||||
filter: { hasKeyword: oldKeyword },
|
||||
limit: batchSize,
|
||||
position,
|
||||
}, "0"],
|
||||
]);
|
||||
|
||||
const queryResult = response.methodResponses?.[0]?.[1];
|
||||
const ids: string[] = queryResult?.ids || [];
|
||||
allIds.push(...ids);
|
||||
|
||||
if (ids.length < batchSize) break;
|
||||
position += ids.length;
|
||||
}
|
||||
|
||||
if (allIds.length === 0) return 0;
|
||||
|
||||
// Batch update: remove old keyword, add new keyword using per-property patches
|
||||
const updateBatchSize = 50;
|
||||
for (let i = 0; i < allIds.length; i += updateBatchSize) {
|
||||
const batch = allIds.slice(i, i + updateBatchSize);
|
||||
const update: Record<string, Record<string, boolean | null>> = {};
|
||||
for (const id of batch) {
|
||||
update[id] = {
|
||||
[`keywords/${oldKeyword}`]: null,
|
||||
[`keywords/${newKeyword}`]: true,
|
||||
};
|
||||
}
|
||||
|
||||
await this.request([
|
||||
["Email/set", {
|
||||
accountId: this.accountId,
|
||||
update,
|
||||
}, "0"],
|
||||
]);
|
||||
}
|
||||
|
||||
return allIds.length;
|
||||
}
|
||||
|
||||
async deleteEmail(emailId: string): Promise<void> {
|
||||
await this.request([
|
||||
["Email/set", {
|
||||
@@ -1358,34 +1409,35 @@ export class JMAPClient {
|
||||
}));
|
||||
}
|
||||
|
||||
// Destroy old draft before creating replacement to avoid duplicates
|
||||
const methodCalls: JMAPMethodCall[] = [];
|
||||
// Use a single Email/set call with both destroy and create for atomicity
|
||||
const setArgs: Record<string, unknown> = {
|
||||
accountId: this.accountId,
|
||||
create: { [emailId]: emailData },
|
||||
};
|
||||
if (draftId) {
|
||||
methodCalls.push(["Email/set", {
|
||||
accountId: this.accountId, destroy: [draftId],
|
||||
}, "0"]);
|
||||
methodCalls.push(["Email/set", {
|
||||
accountId: this.accountId, create: { [emailId]: emailData },
|
||||
}, "1"]);
|
||||
} else {
|
||||
methodCalls.push(["Email/set", {
|
||||
accountId: this.accountId, create: { [emailId]: emailData },
|
||||
}, "0"]);
|
||||
setArgs.destroy = [draftId];
|
||||
}
|
||||
|
||||
const methodCalls: JMAPMethodCall[] = [
|
||||
["Email/set", setArgs, "0"],
|
||||
];
|
||||
|
||||
const response = await this.request(methodCalls);
|
||||
const responseIndex = draftId ? 1 : 0;
|
||||
|
||||
if (response.methodResponses?.[responseIndex]?.[0] === "Email/set") {
|
||||
const result = response.methodResponses[responseIndex][1];
|
||||
if (response.methodResponses?.[0]?.[0] === "Email/set") {
|
||||
const result = response.methodResponses[0][1];
|
||||
|
||||
if (result.notCreated || result.notUpdated) {
|
||||
const errors = result.notCreated || result.notUpdated;
|
||||
if (result.notCreated) {
|
||||
const errors = result.notCreated;
|
||||
const firstError = Object.values(errors)[0] as { description?: string; type?: string };
|
||||
console.error('Draft save error:', firstError);
|
||||
throw new Error(firstError?.description || firstError?.type || 'Failed to save draft');
|
||||
}
|
||||
|
||||
if (draftId && result.notDestroyed) {
|
||||
console.warn('Failed to destroy old draft:', result.notDestroyed);
|
||||
}
|
||||
|
||||
if (result.created?.[emailId]) {
|
||||
return result.created[emailId].id;
|
||||
}
|
||||
@@ -3121,6 +3173,34 @@ export class JMAPClient {
|
||||
return { destroyed, notDestroyed };
|
||||
}
|
||||
|
||||
// ─── Calendar Tasks (JSCalendar Task objects via CalendarEvent endpoints) ───
|
||||
|
||||
async getCalendarTasks(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]> {
|
||||
try {
|
||||
const events = await this.getCalendarEvents(calendarIds, targetAccountId);
|
||||
return events.filter((e): e is CalendarTask & CalendarEvent =>
|
||||
(e as unknown as CalendarTask)['@type'] === 'Task'
|
||||
) as unknown as CalendarTask[];
|
||||
} catch (error) {
|
||||
console.error('Failed to get calendar tasks:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async createCalendarTask(task: Partial<CalendarTask>, targetAccountId?: string): Promise<CalendarTask> {
|
||||
const event = { ...task, '@type': 'Task' } as unknown as Partial<CalendarEvent>;
|
||||
const created = await this.createCalendarEvent(event, false, targetAccountId);
|
||||
return created as unknown as CalendarTask;
|
||||
}
|
||||
|
||||
async updateCalendarTask(taskId: string, updates: Partial<CalendarTask>, targetAccountId?: string): Promise<void> {
|
||||
await this.updateCalendarEvent(taskId, updates as unknown as Partial<CalendarEvent>, false, targetAccountId);
|
||||
}
|
||||
|
||||
async deleteCalendarTask(taskId: string, targetAccountId?: string): Promise<void> {
|
||||
await this.deleteCalendarEvent(taskId, false, targetAccountId);
|
||||
}
|
||||
|
||||
// ─── JMAP FileNode methods (draft-ietf-jmap-filenode) ───
|
||||
|
||||
supportsFiles(): boolean {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
const COOKIE_SAME_SITE = (process.env.COOKIE_SAME_SITE || 'lax') as 'lax' | 'none' | 'strict';
|
||||
|
||||
export function getCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: COOKIE_SAME_SITE === 'none' ? true : process.env.NODE_ENV === 'production',
|
||||
sameSite: COOKIE_SAME_SITE,
|
||||
path: '/',
|
||||
maxAge: 30 * 24 * 60 * 60,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { randomBytes, createHash } from 'node:crypto';
|
||||
|
||||
function base64urlEncode(buffer: Buffer): string {
|
||||
return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function generateCodeVerifierServer(): string {
|
||||
return base64urlEncode(randomBytes(32));
|
||||
}
|
||||
|
||||
export function generateCodeChallengeServer(verifier: string): string {
|
||||
const hash = createHash('sha256').update(verifier).digest();
|
||||
return base64urlEncode(hash);
|
||||
}
|
||||
|
||||
export function generateStateServer(): string {
|
||||
return base64urlEncode(randomBytes(32));
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { logger } from '@/lib/logger';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import type { OAuthMetadata } from '@/lib/oauth/discovery';
|
||||
|
||||
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || '';
|
||||
|
||||
export function getRequiredConfig() {
|
||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
||||
const serverUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
|
||||
const issuerUrl = process.env.OAUTH_ISSUER_URL;
|
||||
if (!clientId || !serverUrl) {
|
||||
throw new Error(`OAuth misconfigured: ${[!clientId && 'OAUTH_CLIENT_ID', !serverUrl && 'JMAP_SERVER_URL'].filter(Boolean).join(', ')} not set`);
|
||||
}
|
||||
const discoveryUrl = issuerUrl?.trim() || serverUrl;
|
||||
if (issuerUrl !== undefined && !issuerUrl.trim()) {
|
||||
logger.warn('OAUTH_ISSUER_URL is set but empty, falling back to JMAP_SERVER_URL for discovery');
|
||||
}
|
||||
return { clientId, serverUrl, discoveryUrl };
|
||||
}
|
||||
|
||||
export async function getTokenEndpoint(): Promise<string> {
|
||||
const { discoveryUrl } = getRequiredConfig();
|
||||
const metadata = await discoverOAuth(discoveryUrl);
|
||||
if (!metadata?.token_endpoint) {
|
||||
throw new Error('OAuth token endpoint not found');
|
||||
}
|
||||
return metadata.token_endpoint;
|
||||
}
|
||||
|
||||
export async function getMetadata(): Promise<OAuthMetadata | null> {
|
||||
const { discoveryUrl } = getRequiredConfig();
|
||||
return discoverOAuth(discoveryUrl);
|
||||
}
|
||||
|
||||
export function buildOAuthParams(base: Record<string, string>): URLSearchParams {
|
||||
const { clientId } = getRequiredConfig();
|
||||
const params = new URLSearchParams({ ...base, client_id: clientId });
|
||||
if (CLIENT_SECRET) {
|
||||
params.set('client_secret', CLIENT_SECRET);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
export interface TokenResult {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
refresh_token?: string;
|
||||
}
|
||||
|
||||
export async function exchangeCodeForTokens(
|
||||
code: string,
|
||||
codeVerifier: string,
|
||||
redirectUri: string,
|
||||
): Promise<TokenResult> {
|
||||
const tokenEndpoint = await getTokenEndpoint();
|
||||
|
||||
const params = buildOAuthParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
});
|
||||
|
||||
const tokenResponse = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
logger.error('Token exchange failed', { status: tokenResponse.status, error: errorText });
|
||||
throw new Error('Token exchange failed');
|
||||
}
|
||||
|
||||
const tokens = await tokenResponse.json();
|
||||
|
||||
if (!tokens.access_token) {
|
||||
logger.error('Token response missing access_token', { response: JSON.stringify(tokens).substring(0, 500) });
|
||||
throw new Error('Invalid token response');
|
||||
}
|
||||
|
||||
return {
|
||||
access_token: tokens.access_token,
|
||||
expires_in: tokens.expires_in || 3600,
|
||||
refresh_token: tokens.refresh_token,
|
||||
};
|
||||
}
|
||||
@@ -171,9 +171,28 @@ function extractEmailAddresses(cert: pkijs.Certificate): string[] {
|
||||
|
||||
// From SubjectAlternativeName
|
||||
const sanExt = cert.extensions?.find((e) => e.extnID === OID_SAN);
|
||||
if (sanExt?.parsedValue) {
|
||||
const san = sanExt.parsedValue as pkijs.GeneralNames;
|
||||
for (const name of san.names) {
|
||||
if (sanExt) {
|
||||
let names: pkijs.GeneralName[] | undefined;
|
||||
|
||||
// parsedValue may be a GeneralNames with .names, or a raw ASN.1 object
|
||||
const pv = sanExt.parsedValue as pkijs.GeneralNames | undefined;
|
||||
if (pv?.names) {
|
||||
names = pv.names;
|
||||
} else if (sanExt.extnValue) {
|
||||
// Manually parse the extension value as a SEQUENCE OF GeneralName
|
||||
try {
|
||||
const sanAsn1 = asn1js.fromBER(sanExt.extnValue.valueBlock.valueHexView);
|
||||
if (sanAsn1.offset !== -1) {
|
||||
const gn = new pkijs.GeneralNames({ schema: sanAsn1.result });
|
||||
names = gn.names;
|
||||
}
|
||||
} catch {
|
||||
// Malformed SAN — skip gracefully
|
||||
}
|
||||
}
|
||||
|
||||
if (names) {
|
||||
for (const name of names) {
|
||||
// type 1 = rfc822Name
|
||||
if (name.type === 1 && typeof name.value === 'string') {
|
||||
if (!emails.includes(name.value)) {
|
||||
@@ -182,6 +201,7 @@ function extractEmailAddresses(cert: pkijs.Certificate): string[] {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return emails;
|
||||
}
|
||||
|
||||
+224
-3
@@ -7,21 +7,242 @@
|
||||
*
|
||||
* Native Web Crypto calls are passed through to the real implementation;
|
||||
* liner only intercepts algorithms that the browser doesn't natively support.
|
||||
*
|
||||
* Additionally, pkijs's CryptoEngine.decryptEncryptedContentInfo only
|
||||
* handles PBES2 (OID 1.2.840.113549.1.5.13). Many PKCS#12 files use
|
||||
* legacy PBE algorithms (e.g. pbeWithSHAAnd3-KeyTripleDES-CBC). We
|
||||
* extend CryptoEngine to handle those via RFC 7292 Appendix B key
|
||||
* derivation + webcrypto-liner's DES-EDE3-CBC support.
|
||||
*/
|
||||
|
||||
import * as asn1js from 'asn1js';
|
||||
import * as pkijs from 'pkijs';
|
||||
|
||||
// webcrypto-liner exports a Crypto constructor at runtime that extends native
|
||||
// Web Crypto with legacy algorithms (3DES, etc.). Its type declarations only
|
||||
// expose the type alias, so we import the module dynamically and cast.
|
||||
// Import the ES module build directly — the package's "browser" field points
|
||||
// to a shim-only build that has no named exports (no setCrypto, Crypto, etc.).
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const liner = require('webcrypto-liner') as {
|
||||
const liner = require('webcrypto-liner/build/index.es.js') as {
|
||||
Crypto: { new (): Crypto };
|
||||
setCrypto: (subtle: SubtleCrypto) => void;
|
||||
nativeCrypto: Crypto | Record<string, never>;
|
||||
};
|
||||
|
||||
let linerEngine: pkijs.CryptoEngine | null = null;
|
||||
// ── PKCS#12 legacy PBE OIDs ──────────────────────────────────────────
|
||||
const PBE_SHA1_3DES_3KEY = '1.2.840.113549.1.12.1.3'; // pbeWithSHAAnd3-KeyTripleDES-CBC
|
||||
const PBE_SHA1_3DES_2KEY = '1.2.840.113549.1.12.1.4'; // pbeWithSHAAnd2-KeyTripleDES-CBC
|
||||
const PBE_SHA1_RC2_128 = '1.2.840.113549.1.12.1.5'; // pbeWithSHAAnd128BitRC2-CBC
|
||||
const PBE_SHA1_RC2_40 = '1.2.840.113549.1.12.1.6'; // pbeWithSHAAnd40BitRC2-CBC
|
||||
|
||||
const LEGACY_PBE_OIDS = new Set([
|
||||
PBE_SHA1_3DES_3KEY,
|
||||
PBE_SHA1_3DES_2KEY,
|
||||
PBE_SHA1_RC2_128,
|
||||
PBE_SHA1_RC2_40,
|
||||
]);
|
||||
|
||||
/** Algorithm config for each legacy PBE OID. */
|
||||
function pbeConfig(oid: string): { keyLen: number; ivLen: number; algName: string } {
|
||||
switch (oid) {
|
||||
case PBE_SHA1_3DES_3KEY: return { keyLen: 24, ivLen: 8, algName: 'DES-EDE3-CBC' };
|
||||
case PBE_SHA1_3DES_2KEY: return { keyLen: 16, ivLen: 8, algName: 'DES-EDE3-CBC' };
|
||||
case PBE_SHA1_RC2_128: return { keyLen: 16, ivLen: 8, algName: 'RC2-CBC' };
|
||||
case PBE_SHA1_RC2_40: return { keyLen: 5, ivLen: 8, algName: 'RC2-CBC' };
|
||||
default: throw new Error(`Unsupported legacy PBE OID: ${oid}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PKCS#12 key derivation — RFC 7292, Appendix B.
|
||||
*
|
||||
* @param password BMP-encoded password (with trailing 0x00 0x00)
|
||||
* @param salt raw salt bytes
|
||||
* @param iterations PBKDF iteration count
|
||||
* @param id 1 = key material, 2 = IV, 3 = MAC key
|
||||
* @param needed number of bytes to derive
|
||||
*/
|
||||
async function pkcs12KDF(
|
||||
password: Uint8Array,
|
||||
salt: Uint8Array,
|
||||
iterations: number,
|
||||
id: number,
|
||||
needed: number,
|
||||
): Promise<Uint8Array> {
|
||||
const v = 64; // SHA-1 block size
|
||||
const u = 20; // SHA-1 output size
|
||||
|
||||
// Step 1: diversifier D = v bytes of 'id'
|
||||
const D = new Uint8Array(v);
|
||||
D.fill(id);
|
||||
|
||||
// Step 2: fill S from salt, padded/repeated to v-byte boundary
|
||||
const sLen = salt.length === 0 ? 0 : v * Math.ceil(salt.length / v);
|
||||
const S = new Uint8Array(sLen);
|
||||
for (let i = 0; i < sLen; i++) S[i] = salt[i % salt.length];
|
||||
|
||||
// Step 3: fill P from password, padded/repeated to v-byte boundary
|
||||
const pLen = password.length === 0 ? 0 : v * Math.ceil(password.length / v);
|
||||
const P = new Uint8Array(pLen);
|
||||
for (let i = 0; i < pLen; i++) P[i] = password[i % password.length];
|
||||
|
||||
// I = S || P
|
||||
const I = new Uint8Array(sLen + pLen);
|
||||
I.set(S, 0);
|
||||
I.set(P, sLen);
|
||||
|
||||
const c = Math.ceil(needed / u);
|
||||
const result = new Uint8Array(c * u);
|
||||
|
||||
for (let i = 0; i < c; i++) {
|
||||
// Aj = Hash^iterations(D || I)
|
||||
const buf = new Uint8Array(v + I.length);
|
||||
buf.set(D, 0);
|
||||
buf.set(I, v);
|
||||
|
||||
let A = new Uint8Array(await crypto.subtle.digest('SHA-1', buf));
|
||||
for (let j = 1; j < iterations; j++) {
|
||||
A = new Uint8Array(await crypto.subtle.digest('SHA-1', A));
|
||||
}
|
||||
|
||||
result.set(A, i * u);
|
||||
|
||||
if (i + 1 < c) {
|
||||
// Build B by repeating A to fill v bytes
|
||||
const B = new Uint8Array(v);
|
||||
for (let j = 0; j < v; j++) B[j] = A[j % u];
|
||||
|
||||
// I[j] = (I[j] + B + 1) mod 2^v for each v-byte block
|
||||
for (let j = 0; j < I.length; j += v) {
|
||||
let carry = 1;
|
||||
for (let k = v - 1; k >= 0; k--) {
|
||||
const sum = I[j + k] + B[k] + carry;
|
||||
I[j + k] = sum & 0xff;
|
||||
carry = sum >> 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.slice(0, needed);
|
||||
}
|
||||
|
||||
/** Encode a password as BMP string with trailing NUL pair (RFC 7292 §B.1). */
|
||||
function passwordToBMP(password: ArrayBuffer): Uint8Array {
|
||||
const passView = new Uint8Array(password);
|
||||
// If already BMP-encoded (even length, every odd byte is 0x00 for ASCII),
|
||||
// or empty, use as-is. Otherwise convert char codes to big-endian UCS-2.
|
||||
// pkijs passes the password as a raw ArrayBuffer of char codes.
|
||||
const bmp = new Uint8Array(passView.length * 2 + 2);
|
||||
for (let i = 0; i < passView.length; i++) {
|
||||
bmp[i * 2] = 0;
|
||||
bmp[i * 2 + 1] = passView[i];
|
||||
}
|
||||
// trailing 0x00 0x00
|
||||
bmp[bmp.length - 2] = 0;
|
||||
bmp[bmp.length - 1] = 0;
|
||||
return bmp;
|
||||
}
|
||||
|
||||
// ── CMS content encryption OIDs (for EnvelopedData decryption) ─────
|
||||
const OID_DES_EDE3_CBC = '1.2.840.113549.3.7'; // des-EDE3-CBC (3DES)
|
||||
const OID_DES_CBC = '1.3.14.3.2.7'; // desCBC
|
||||
const OID_RC2_CBC = '1.2.840.113549.3.2'; // rc2CBC
|
||||
|
||||
/**
|
||||
* Extended CryptoEngine that handles legacy algorithms (3DES, etc.)
|
||||
* not recognized by pkijs's default CryptoEngine.
|
||||
*
|
||||
* - Adds OID→algorithm mappings for DES-EDE3-CBC so that
|
||||
* EnvelopedData.decrypt() can process 3DES-encrypted S/MIME messages.
|
||||
* - Handles legacy PKCS#12 PBE algorithms via custom KDF.
|
||||
*/
|
||||
class Pkcs12CryptoEngine extends pkijs.CryptoEngine {
|
||||
/**
|
||||
* Extend OID→algorithm mapping with legacy algorithms that webcrypto-liner
|
||||
* supports but pkijs does not know about.
|
||||
*/
|
||||
getAlgorithmByOID(oid: string, safety?: boolean, target?: string): object {
|
||||
switch (oid) {
|
||||
case OID_DES_EDE3_CBC:
|
||||
return { name: 'DES-EDE3-CBC', length: 192 };
|
||||
case OID_DES_CBC:
|
||||
return { name: 'DES-CBC', length: 64 };
|
||||
case OID_RC2_CBC:
|
||||
return { name: 'RC2-CBC', length: 128 };
|
||||
default:
|
||||
return super.getAlgorithmByOID(oid, safety, target);
|
||||
}
|
||||
}
|
||||
|
||||
getOIDByAlgorithm(algorithm: { name: string; length?: number }, safety?: boolean, target?: string): string {
|
||||
switch (algorithm.name.toUpperCase()) {
|
||||
case 'DES-EDE3-CBC':
|
||||
return OID_DES_EDE3_CBC;
|
||||
case 'DES-CBC':
|
||||
return OID_DES_CBC;
|
||||
case 'RC2-CBC':
|
||||
return OID_RC2_CBC;
|
||||
default:
|
||||
return super.getOIDByAlgorithm(algorithm, safety, target);
|
||||
}
|
||||
}
|
||||
|
||||
async decryptEncryptedContentInfo(
|
||||
parameters: Parameters<pkijs.CryptoEngine['decryptEncryptedContentInfo']>[0],
|
||||
): Promise<ArrayBuffer> {
|
||||
const oid = parameters.encryptedContentInfo.contentEncryptionAlgorithm.algorithmId;
|
||||
|
||||
if (!LEGACY_PBE_OIDS.has(oid)) {
|
||||
// Delegate to base CryptoEngine (handles PBES2)
|
||||
return super.decryptEncryptedContentInfo(parameters);
|
||||
}
|
||||
|
||||
const algParams = parameters.encryptedContentInfo.contentEncryptionAlgorithm.algorithmParams;
|
||||
if (!algParams) {
|
||||
throw new Error('Missing PBE algorithm parameters');
|
||||
}
|
||||
|
||||
// Parse PBEParameter ::= SEQUENCE { salt OCTET STRING, iterationCount INTEGER }
|
||||
const paramAsn1 = asn1js.fromBER(algParams.toBER(false));
|
||||
if (paramAsn1.offset === -1) {
|
||||
throw new Error('Invalid PBE parameters ASN.1');
|
||||
}
|
||||
const seq = paramAsn1.result as asn1js.Sequence;
|
||||
const salt = new Uint8Array((seq.valueBlock.value[0] as asn1js.OctetString).valueBlock.valueHexView);
|
||||
const iterations = (seq.valueBlock.value[1] as asn1js.Integer).valueBlock.valueDec;
|
||||
|
||||
const { keyLen, ivLen, algName } = pbeConfig(oid);
|
||||
const bmpPassword = passwordToBMP(parameters.password);
|
||||
|
||||
// Derive key (id=1) and IV (id=2) using PKCS#12 KDF
|
||||
const keyBytes = await pkcs12KDF(bmpPassword, salt, iterations, 1, keyLen);
|
||||
const ivBytes = await pkcs12KDF(bmpPassword, salt, iterations, 2, ivLen);
|
||||
|
||||
// Import key via webcrypto-liner (supports DES-EDE3-CBC)
|
||||
const keyData = new Uint8Array(keyBytes.buffer as ArrayBuffer, keyBytes.byteOffset, keyBytes.byteLength);
|
||||
const cryptoKey = await this.importKey(
|
||||
'raw',
|
||||
keyData,
|
||||
// eslint-disable-next-line no-undef
|
||||
{ name: algName, length: keyLen * 8 } as Algorithm,
|
||||
false,
|
||||
['decrypt'],
|
||||
);
|
||||
|
||||
// Decrypt
|
||||
const ciphertext = parameters.encryptedContentInfo.getEncryptedContent();
|
||||
return this.decrypt(
|
||||
// eslint-disable-next-line no-undef
|
||||
{ name: algName, iv: ivBytes } as Algorithm,
|
||||
cryptoKey,
|
||||
ciphertext,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let linerEngine: Pkcs12CryptoEngine | null = null;
|
||||
let linerCryptoInstance: Crypto | null = null;
|
||||
|
||||
function ensureLiner() {
|
||||
@@ -39,7 +260,7 @@ function ensureLiner() {
|
||||
linerCryptoInstance = new liner.Crypto();
|
||||
}
|
||||
if (!linerEngine) {
|
||||
linerEngine = new pkijs.CryptoEngine({
|
||||
linerEngine = new Pkcs12CryptoEngine({
|
||||
crypto: linerCryptoInstance,
|
||||
subtle: linerCryptoInstance.subtle,
|
||||
name: 'webcrypto-liner',
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import * as pkijs from 'pkijs';
|
||||
import * as asn1js from 'asn1js';
|
||||
import type { SmimeKeyRecord } from './types';
|
||||
import { getLinerCryptoEngine } from './crypto-engine';
|
||||
import { getLinerCryptoEngine, withLinerEngine } from './crypto-engine';
|
||||
|
||||
export interface DecryptionInput {
|
||||
/** Raw CMS EnvelopedData bytes (DER) */
|
||||
@@ -360,10 +360,14 @@ async function decryptWithKey(
|
||||
const certAsn1 = asn1js.fromBER(keyRecord.certificate);
|
||||
const cert = new pkijs.Certificate({ schema: certAsn1.result });
|
||||
|
||||
// Use webcrypto-liner engine for legacy algorithm support (e.g. 3DES)
|
||||
// Use withLinerEngine to set the global pkijs engine to webcrypto-liner.
|
||||
// This is required because pkijs internally may use getEngine() for
|
||||
// OID lookups and crypto operations. Without this, 3DES-encrypted
|
||||
// messages fail because the default engine doesn't know about DES-EDE3-CBC.
|
||||
return withLinerEngine(async () => {
|
||||
const cryptoEngine = getLinerCryptoEngine();
|
||||
|
||||
const result = await envelopedData.decrypt(
|
||||
return envelopedData.decrypt(
|
||||
recipientIndex,
|
||||
{
|
||||
recipientCertificate: cert,
|
||||
@@ -371,6 +375,5 @@ async function decryptWithKey(
|
||||
},
|
||||
cryptoEngine,
|
||||
);
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Utilities for handling drag-and-drop of files and folders.
|
||||
* Uses the File and Directory Entries API (webkitGetAsEntry) to
|
||||
* recursively read dropped directory trees, preserving relative paths.
|
||||
*/
|
||||
|
||||
interface FileWithPath extends File {
|
||||
readonly webkitRelativePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all File entries from a FileSystemDirectoryEntry recursively.
|
||||
* Each returned File has its webkitRelativePath set to the relative path
|
||||
* within the dropped folder (e.g. "folder/sub/file.txt").
|
||||
*/
|
||||
function readDirectoryEntries(dirEntry: FileSystemDirectoryEntry): Promise<FileWithPath[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = dirEntry.createReader();
|
||||
const allEntries: FileSystemEntry[] = [];
|
||||
|
||||
// readEntries may return results in batches; keep reading until empty
|
||||
const readBatch = () => {
|
||||
reader.readEntries(
|
||||
(entries) => {
|
||||
if (entries.length === 0) {
|
||||
resolveFiles(allEntries).then(resolve, reject);
|
||||
} else {
|
||||
allEntries.push(...entries);
|
||||
readBatch();
|
||||
}
|
||||
},
|
||||
reject,
|
||||
);
|
||||
};
|
||||
readBatch();
|
||||
});
|
||||
}
|
||||
|
||||
function resolveFiles(entries: FileSystemEntry[]): Promise<FileWithPath[]> {
|
||||
const promises = entries.map((entry) => {
|
||||
if (entry.isFile) {
|
||||
return new Promise<FileWithPath[]>((resolve, reject) => {
|
||||
(entry as FileSystemFileEntry).file(
|
||||
(file) => {
|
||||
// Set webkitRelativePath directly on the original File object.
|
||||
// The property lives on File.prototype as a getter, so defining
|
||||
// an own data property on the instance safely shadows it.
|
||||
try {
|
||||
Object.defineProperty(file, 'webkitRelativePath', {
|
||||
value: entry.fullPath.replace(/^\//, ''),
|
||||
writable: false,
|
||||
configurable: true,
|
||||
});
|
||||
} catch {
|
||||
// Fallback: some environments may prevent overriding.
|
||||
// The store also falls back to file.name, which still works
|
||||
// for flat files (though nested paths would be lost).
|
||||
}
|
||||
resolve([file as unknown as FileWithPath]);
|
||||
},
|
||||
reject,
|
||||
);
|
||||
});
|
||||
} else if (entry.isDirectory) {
|
||||
return readDirectoryEntries(entry as FileSystemDirectoryEntry);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
return Promise.all(promises).then((arrays) => arrays.flat());
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of processing a drop event's DataTransfer.
|
||||
*/
|
||||
export interface DropResult {
|
||||
files: File[];
|
||||
hasDirectories: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a drop event's DataTransfer, detecting folders and recursively
|
||||
* reading their contents. Returns the list of files and whether any
|
||||
* directories were found.
|
||||
*
|
||||
* Falls back to e.dataTransfer.files when webkitGetAsEntry is unavailable.
|
||||
*/
|
||||
export async function getDroppedFilesAndFolders(dataTransfer: DataTransfer): Promise<DropResult> {
|
||||
const items = dataTransfer.items;
|
||||
|
||||
// Check if the browser supports webkitGetAsEntry
|
||||
if (items && items.length > 0 && typeof items[0].webkitGetAsEntry === 'function') {
|
||||
const entries: FileSystemEntry[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const entry = items[i].webkitGetAsEntry();
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
|
||||
let hasDirectories = false;
|
||||
const filePromises: Promise<FileWithPath[]>[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory) {
|
||||
hasDirectories = true;
|
||||
filePromises.push(readDirectoryEntries(entry as FileSystemDirectoryEntry));
|
||||
} else if (entry.isFile) {
|
||||
filePromises.push(
|
||||
new Promise<FileWithPath[]>((resolve, reject) => {
|
||||
(entry as FileSystemFileEntry).file(
|
||||
(file) => resolve([file as FileWithPath]),
|
||||
reject,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const allFiles = (await Promise.all(filePromises)).flat();
|
||||
return { files: allFiles, hasDirectories };
|
||||
}
|
||||
|
||||
// Fallback: no entry API support
|
||||
return {
|
||||
files: Array.from(dataTransfer.files),
|
||||
hasDirectories: false,
|
||||
};
|
||||
}
|
||||
+105
-6
@@ -40,6 +40,12 @@
|
||||
"website": "Webseite",
|
||||
"imprint": "Impressum",
|
||||
"privacy_policy": "Datenschutz",
|
||||
"try_demo": "Demo testen",
|
||||
"demo_description": "Erkunden Sie mit Beispieldaten — kein Konto nötig",
|
||||
"demo_launching": "Demo wird gestartet...",
|
||||
"demo_login_button": "Demo starten",
|
||||
"demo_tagline": "Erleben Sie einen voll ausgestatteten E-Mail-Client. Kein Konto erforderlich.",
|
||||
"demo_no_signup": "Keine Registrierung nötig — erkunden Sie frei mit Beispieldaten",
|
||||
"oauth_completing": "Anmeldung wird abgeschlossen...",
|
||||
"oauth_error": {
|
||||
"title": "Authentifizierung fehlgeschlagen",
|
||||
@@ -104,6 +110,9 @@
|
||||
},
|
||||
"clear_search": "Suche löschen",
|
||||
"vacation_active": "Abwesenheitsnotiz ist aktiv",
|
||||
"demo_banner": "Demo-Modus",
|
||||
"demo_reset": "Zurücksetzen",
|
||||
"demo_tour": "Tour",
|
||||
"tags": "Tags",
|
||||
"mail": "E-Mail",
|
||||
"nav_label": "Navigation",
|
||||
@@ -137,7 +146,8 @@
|
||||
"show_all": "Alle",
|
||||
"no_icons_found": "Keine Symbole gefunden",
|
||||
"inline_badge": "Eingebettet",
|
||||
"tab_badge": "Tab"
|
||||
"tab_badge": "Tab",
|
||||
"show_on_mobile": "Auf Mobilgerät anzeigen"
|
||||
},
|
||||
"email_list": {
|
||||
"no_emails": "Keine Nachrichten gefunden",
|
||||
@@ -682,7 +692,9 @@
|
||||
"delete": "Schlüsselwort löschen",
|
||||
"save": "Speichern",
|
||||
"add": "Hinzufügen",
|
||||
"cancel": "Abbrechen"
|
||||
"cancel": "Abbrechen",
|
||||
"migrating": "Schlüsselwort bei bestehenden E-Mails aktualisieren…",
|
||||
"migration_error": "Schlüsselwort konnte bei bestehenden E-Mails nicht aktualisiert werden"
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Sprache & Region",
|
||||
@@ -797,6 +809,17 @@
|
||||
"close": "Schließen",
|
||||
"invalid_email": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
||||
"already_added": "Dieser Absender ist bereits vertrauenswürdig"
|
||||
},
|
||||
"hover_actions": {
|
||||
"label": "Schnelle Hover-Aktionen",
|
||||
"description": "Wählen Sie, welche Schnellaktionen beim Überfahren einer E-Mail in der Liste angezeigt werden",
|
||||
"delete": "Löschen",
|
||||
"star": "Markieren / Markierung aufheben",
|
||||
"mark_read": "Als gelesen / ungelesen markieren",
|
||||
"archive": "Archivieren",
|
||||
"tag": "Schlagwort",
|
||||
"spam": "Als Spam markieren",
|
||||
"none_selected": "Keine Aktionen ausgewählt"
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
@@ -847,6 +870,9 @@
|
||||
"account": {
|
||||
"title": "Konto",
|
||||
"description": "Zeigen Sie Ihre Kontoinformationen an",
|
||||
"name_label": "Anzeigename",
|
||||
"account_type_label": "Kontotyp",
|
||||
"demo_account": "Demokonto",
|
||||
"email": {
|
||||
"label": "E-Mail-Adresse",
|
||||
"value": "{email}"
|
||||
@@ -1157,6 +1183,12 @@
|
||||
"opaque_warning": "Dieses Skript wurde außerhalb des visuellen Builders bearbeitet. Nur die Sieve-Skriptbearbeitung ist verfügbar.",
|
||||
"open_sieve_editor": "Sieve-Skript-Editor öffnen",
|
||||
"fetch_error": "Filter konnten nicht geladen werden",
|
||||
"expanded_view": "Erweiterte Ansicht",
|
||||
"expanded_view_description": "Filterregeln mit detaillierten Bedingungs- und Aktionsblöcken anzeigen",
|
||||
"if": "Wenn",
|
||||
"then": "Dann",
|
||||
"match_all_conditions": "alle zutreffen",
|
||||
"match_any_condition": "eine zutrifft",
|
||||
"and": "und",
|
||||
"or": "oder",
|
||||
"cancel": "Abbrechen",
|
||||
@@ -1470,6 +1502,9 @@
|
||||
"title": "Kontakte",
|
||||
"search_placeholder": "Kontakte suchen...",
|
||||
"create_new": "Neuer Kontakt",
|
||||
"no_category": "Ohne Kategorie",
|
||||
"category_added": "Kontakt zu {name} hinzugefügt",
|
||||
"category_added_plural": "{count} Kontakte zu {name} hinzugefügt",
|
||||
"empty_state": "Keine Kontakte",
|
||||
"empty_state_title": "Keine Kontakte",
|
||||
"empty_state_subtitle": "Erstellen Sie Ihren ersten Kontakt oder importieren Sie aus einer vCard-Datei",
|
||||
@@ -1613,7 +1648,8 @@
|
||||
"level_low": "Niedrig",
|
||||
"categories": "Kategorien",
|
||||
"categories_placeholder": "z. B. Familie, Freunde, Kollegen",
|
||||
"categories_hint": "Mit Kommas trennen",
|
||||
"categories_hint": "Tippen zum Suchen oder Hinzufügen",
|
||||
"category_add": "Hinzufügen",
|
||||
"note": "Notizen",
|
||||
"note_placeholder": "Notiz hinzufügen...",
|
||||
"gender": "Geschlecht",
|
||||
@@ -1826,7 +1862,11 @@
|
||||
"notification_sound": "Benachrichtigungston",
|
||||
"notification_sound_desc": "Ton für Kalenderbenachrichtigungen abspielen",
|
||||
"invitation_parsing": "E-Mail-Einladungen verarbeiten",
|
||||
"invitation_parsing_desc": "Kalendereinladungen in E-Mail-Anhängen erkennen und Kalenderaktionen anzeigen"
|
||||
"invitation_parsing_desc": "Kalendereinladungen in E-Mail-Anhängen erkennen und Kalenderaktionen anzeigen",
|
||||
"show_time_in_month_view": "Zeit in Monatsansicht anzeigen",
|
||||
"show_time_in_month_view_desc": "Ereigniszeiten in der Monatskalenderansicht anzeigen",
|
||||
"show_week_numbers": "Kalenderwochen anzeigen",
|
||||
"show_week_numbers_desc": "Kalenderwochen im Minikalender anzeigen"
|
||||
},
|
||||
"days": {
|
||||
"monday": "Montag",
|
||||
@@ -2001,7 +2041,17 @@
|
||||
"tip_settings": "Passen Sie Ihre Erfahrung in den Einstellungen an",
|
||||
"got_it": "Verstanden",
|
||||
"settings": "Einstellungen",
|
||||
"dismiss": "Schließen"
|
||||
"dismiss": "Schließen",
|
||||
"start_tour": "Tour starten"
|
||||
},
|
||||
"demo_welcome": {
|
||||
"title": "Willkommen bei Bulwark Mail",
|
||||
"description": "Entdecken Sie einen voll ausgestatteten Webmail-Client — direkt in Ihrem Browser. Alle Daten bleiben auf Ihrem Gerät, also testen Sie alles.",
|
||||
"feature_email": "E-Mails lesen & verfassen",
|
||||
"feature_organize": "Tags, Sterne & Ordner",
|
||||
"feature_shortcuts": "Tastenkürzel",
|
||||
"feature_privacy": "100 % private Demo",
|
||||
"hint": "Klicken Sie links auf eine E-Mail, um loszulegen, oder starten Sie die Tour."
|
||||
},
|
||||
"files": {
|
||||
"title": "Dateien",
|
||||
@@ -2027,7 +2077,7 @@
|
||||
"file": "Datei",
|
||||
"parent_directory": "Übergeordnetes Verzeichnis",
|
||||
"breadcrumb_root": "Startseite",
|
||||
"drop_files_here": "Dateien hier ablegen zum Hochladen",
|
||||
"drop_files_here": "Dateien oder Ordner hier ablegen zum Hochladen",
|
||||
"uploading": "Wird hochgeladen...",
|
||||
"upload_success": "{count, plural, one {1 Datei hochgeladen} other {# Dateien hochgeladen}}",
|
||||
"upload_error": "Datei konnte nicht hochgeladen werden",
|
||||
@@ -2181,5 +2231,54 @@
|
||||
"export_passphrase_desc": "Wählen Sie eine Passphrase zum Schutz der exportierten PKCS#12-Datei",
|
||||
"export_storage_desc": "Geben Sie die Speicher-Passphrase ein, um den Schlüssel für den Export zu entschlüsseln",
|
||||
"incorrect_passphrase": "Falsche Passphrase"
|
||||
},
|
||||
"tour": {
|
||||
"step_counter": "Schritt {current} von {total}",
|
||||
"skip": "Tour überspringen",
|
||||
"back": "Zurück",
|
||||
"next": "Weiter",
|
||||
"finish": "Fertig",
|
||||
"take_a_tour": "Machen Sie eine Tour durch die Oberfläche",
|
||||
"restart_title": "Einführungstour",
|
||||
"restart_desc": "Geführte Tour durch die Oberfläche erneut abspielen",
|
||||
"restart_button": "Tour neu starten",
|
||||
"sidebar_title": "Ihre Postfächer",
|
||||
"sidebar_desc": "Dies ist Ihre Ordner-Seitenleiste. Klicken Sie auf ein Postfach, um seine E-Mails anzuzeigen. Sie können Ordner erstellen, E-Mails zwischen ihnen verschieben und ungelesene Zähler auf einen Blick sehen.",
|
||||
"compose_title": "E-Mail verfassen",
|
||||
"compose_desc": "Klicken Sie hier, um eine neue E-Mail zu schreiben. Sie können Empfänger, Anhänge und Textformatierung hinzufügen.",
|
||||
"search_title": "E-Mails durchsuchen",
|
||||
"search_desc": "Suchen Sie nach Absender, Betreff oder Inhalt. Klicken Sie auf das Filtersymbol für erweiterte Optionen wie Datumsbereich, Anhänge und markierte Nachrichten.",
|
||||
"email_list_title": "Ihre E-Mail-Liste",
|
||||
"email_list_desc": "E-Mails erscheinen hier. Klicken Sie auf eine, um sie rechts zu lesen. Verwenden Sie die Checkbox, um mehrere auszuwählen und sie dann zu verschieben, löschen oder taggen.",
|
||||
"email_viewer_title": "Lesebereich",
|
||||
"email_viewer_desc": "Die ausgewählte E-Mail wird hier geöffnet. Antworten, weiterleiten, archivieren oder löschen Sie mit den Schaltflächen. Sie können auch E-Mails markieren oder Farbtags hinzufügen.",
|
||||
"keywords_title": "Farbtags",
|
||||
"keywords_desc": "Organisieren Sie Ihre E-Mails mit farbcodierten Tags. Ziehen Sie eine E-Mail auf ein Tag oder klicken Sie mit der rechten Maustaste.",
|
||||
"calendar_title": "Kalender",
|
||||
"calendar_desc": "Wechseln Sie zum Kalender, um Ihre Termine zu verwalten. Erstellen Sie Ereignisse, setzen Sie Erinnerungen und wählen Sie verschiedene Ansichten.",
|
||||
"contacts_title": "Kontakte",
|
||||
"contacts_desc": "Hier finden Sie Ihr Adressbuch. Importieren Sie Kontakte, erstellen Sie Gruppen und sehen Sie Details.",
|
||||
"settings_title": "Einstellungen",
|
||||
"settings_desc": "Passen Sie alles an: Design, Dichte, Signaturen, Filter, Tastaturkürzel, Kalender-Standards und mehr.",
|
||||
"shortcuts_title": "Tastaturkürzel",
|
||||
"shortcuts_desc": "Für Power-User. Drücken Sie jederzeit ?, um alle verfügbaren Kürzel anzuzeigen.",
|
||||
"compose_open_title": "Der Editor",
|
||||
"compose_open_desc": "Dies ist der E-Mail-Editor. Fügen Sie Empfänger hinzu, schreiben Sie Ihre Nachricht, hängen Sie Dateien an und verwenden Sie Rich-Text-Formatierung. Sie können auch Entwürfe speichern und Vorlagen verwenden.",
|
||||
"calendar_view_title": "Ihr Kalender",
|
||||
"calendar_view_desc": "Hier ist Ihr Kalender mit Beispielterminen. Wechseln Sie zwischen Tag-, Wochen-, Monats- und Agendaansicht.",
|
||||
"create_event_title": "Ereignis erstellen",
|
||||
"create_event_desc": "Klicken Sie auf diese Schaltfläche, um ein neues Kalenderereignis zu erstellen. Sie können Titel, Datum, Uhrzeit und Teilnehmer festlegen.",
|
||||
"event_modal_title": "Ereignisdetails",
|
||||
"event_modal_desc": "Hier ist das Ereignisformular. Geben Sie den Titel ein, wählen Sie Datum und Uhrzeit, fügen Sie einen Ort oder Teilnehmer hinzu. Klicken Sie auf Speichern, wenn Sie fertig sind — oder schließen Sie es und fahren Sie fort.",
|
||||
"contacts_list_title": "Ihre Kontakte",
|
||||
"contacts_list_desc": "Hier sind Ihre Kontakte. Klicken Sie auf einen Kontakt, um Details zu sehen. Sie können neue Kontakte erstellen oder vCards importieren.",
|
||||
"files_title": "Dateispeicher",
|
||||
"settings_tabs_title": "Einstellungsmenü",
|
||||
"settings_tabs_desc": "Hier finden Sie alle Einstellungskategorien. Passen Sie das Erscheinungsbild an, verwalten Sie Identitäten, richten Sie E-Mail-Filter ein, konfigurieren Sie Ihren Kalender und vieles mehr.",
|
||||
"files_desc": "Ihr Dateibrowser zum Hochladen, Organisieren und Teilen von Dateien.",
|
||||
"demo_banner_title": "Demo-Steuerung",
|
||||
"demo_banner_desc": "Sie sind im Demo-Modus — alles bleibt in Ihrem Browser. Klicken Sie jederzeit auf 'Demo zurücksetzen'.",
|
||||
"quota_title": "Speichernutzung",
|
||||
"quota_desc": "Verfolgen Sie Ihre Postfachgröße hier. Der Kreis füllt sich mit zunehmendem Verbrauch."
|
||||
}
|
||||
}
|
||||
|
||||
+153
-9
@@ -40,6 +40,12 @@
|
||||
"website": "Website",
|
||||
"imprint": "Imprint",
|
||||
"privacy_policy": "Privacy Policy",
|
||||
"try_demo": "Try Demo",
|
||||
"demo_description": "Explore with sample data — no account needed",
|
||||
"demo_launching": "Launching demo...",
|
||||
"demo_login_button": "Launch Demo",
|
||||
"demo_tagline": "Experience a full-featured email client. No account required.",
|
||||
"demo_no_signup": "No signup needed — explore freely with sample data",
|
||||
"oauth_completing": "Completing sign in...",
|
||||
"oauth_error": {
|
||||
"title": "Authentication Failed",
|
||||
@@ -104,6 +110,9 @@
|
||||
},
|
||||
"clear_search": "Clear search",
|
||||
"vacation_active": "Vacation responder is active",
|
||||
"demo_banner": "Demo Mode",
|
||||
"demo_reset": "Reset",
|
||||
"demo_tour": "Tour",
|
||||
"tags": "Tags",
|
||||
"mail": "Mail",
|
||||
"nav_label": "Navigation",
|
||||
@@ -137,7 +146,8 @@
|
||||
"show_all": "All",
|
||||
"no_icons_found": "No icons found",
|
||||
"inline_badge": "Inline",
|
||||
"tab_badge": "Tab"
|
||||
"tab_badge": "Tab",
|
||||
"show_on_mobile": "Show on Mobile"
|
||||
},
|
||||
"email_list": {
|
||||
"no_emails": "No messages found",
|
||||
@@ -682,7 +692,9 @@
|
||||
"delete": "Delete keyword",
|
||||
"save": "Save",
|
||||
"add": "Add",
|
||||
"cancel": "Cancel"
|
||||
"cancel": "Cancel",
|
||||
"migrating": "Updating keyword on existing emails…",
|
||||
"migration_error": "Failed to update keyword on existing emails"
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Language & Region",
|
||||
@@ -797,6 +809,17 @@
|
||||
"close": "Close",
|
||||
"invalid_email": "Please enter a valid email address",
|
||||
"already_added": "This sender is already trusted"
|
||||
},
|
||||
"hover_actions": {
|
||||
"label": "Quick Hover Actions",
|
||||
"description": "Choose which quick actions appear when hovering over an email in the list",
|
||||
"delete": "Delete",
|
||||
"star": "Star / Unstar",
|
||||
"mark_read": "Mark Read / Unread",
|
||||
"archive": "Archive",
|
||||
"tag": "Tag",
|
||||
"spam": "Mark as Spam",
|
||||
"none_selected": "No actions selected"
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
@@ -847,6 +870,9 @@
|
||||
"account": {
|
||||
"title": "Account",
|
||||
"description": "View your account information",
|
||||
"name_label": "Display Name",
|
||||
"account_type_label": "Account Type",
|
||||
"demo_account": "Demo Account",
|
||||
"email": {
|
||||
"label": "Email Address",
|
||||
"value": "{email}"
|
||||
@@ -1157,6 +1183,12 @@
|
||||
"opaque_warning": "This script was edited outside the visual builder. Only raw Sieve editing is available.",
|
||||
"open_sieve_editor": "Open raw Sieve editor",
|
||||
"fetch_error": "Failed to load filters",
|
||||
"expanded_view": "Expanded view",
|
||||
"expanded_view_description": "Show filter rules with detailed condition and action blocks",
|
||||
"if": "If",
|
||||
"then": "Then",
|
||||
"match_all_conditions": "all match",
|
||||
"match_any_condition": "any matches",
|
||||
"and": "and",
|
||||
"or": "or",
|
||||
"cancel": "Cancel",
|
||||
@@ -1470,6 +1502,9 @@
|
||||
"title": "Contacts",
|
||||
"search_placeholder": "Search contacts...",
|
||||
"create_new": "New Contact",
|
||||
"no_category": "No Category",
|
||||
"category_added": "Contact added to {name}",
|
||||
"category_added_plural": "{count} contacts added to {name}",
|
||||
"empty_state": "No contacts yet",
|
||||
"empty_state_title": "No contacts yet",
|
||||
"empty_state_subtitle": "Create your first contact or import from a vCard file",
|
||||
@@ -1613,7 +1648,8 @@
|
||||
"level_low": "Low",
|
||||
"categories": "Categories",
|
||||
"categories_placeholder": "e.g., Family, Friends, Colleagues",
|
||||
"categories_hint": "Separate with commas",
|
||||
"categories_hint": "Type to search or add categories",
|
||||
"category_add": "Add",
|
||||
"note": "Notes",
|
||||
"note_placeholder": "Add a note...",
|
||||
"gender": "Gender",
|
||||
@@ -1717,7 +1753,9 @@
|
||||
"month_hint": "Month (m)",
|
||||
"week_hint": "Week (w)",
|
||||
"day_hint": "Day (d)",
|
||||
"agenda_hint": "Agenda (a)"
|
||||
"agenda_hint": "Agenda (a)",
|
||||
"tasks": "Tasks",
|
||||
"tasks_hint": "Tasks (k)"
|
||||
},
|
||||
"events": {
|
||||
"create": "Create event",
|
||||
@@ -1826,7 +1864,15 @@
|
||||
"notification_sound": "Notification sound",
|
||||
"notification_sound_desc": "Play a sound for calendar alerts",
|
||||
"invitation_parsing": "Parse email invitations",
|
||||
"invitation_parsing_desc": "Detect calendar invitations in email attachments and show calendar actions"
|
||||
"invitation_parsing_desc": "Detect calendar invitations in email attachments and show calendar actions",
|
||||
"show_time_in_month_view": "Show time in month view",
|
||||
"show_time_in_month_view_desc": "Display event times in the month calendar view",
|
||||
"show_week_numbers": "Show week numbers",
|
||||
"show_week_numbers_desc": "Display week numbers in the mini-calendar",
|
||||
"enable_tasks": "Enable tasks",
|
||||
"enable_tasks_desc": "Show a tasks view in the calendar for managing to-dos",
|
||||
"show_tasks_on_calendar": "Show tasks on calendar",
|
||||
"show_tasks_on_calendar_desc": "Display task chips on the day and week calendar views"
|
||||
},
|
||||
"days": {
|
||||
"monday": "Monday",
|
||||
@@ -1859,7 +1905,8 @@
|
||||
"rsvp_updated": "Response updated",
|
||||
"rsvp_error": "Failed to update response",
|
||||
"event_duplicated": "Event duplicated",
|
||||
"event_error": "Failed to save event"
|
||||
"event_error": "Failed to save event",
|
||||
"task_due": "Task due"
|
||||
},
|
||||
"status": {
|
||||
"loading_calendars": "Loading calendars...",
|
||||
@@ -1957,10 +2004,48 @@
|
||||
"last_refreshed": "Last updated: {time}"
|
||||
},
|
||||
"tasks": {
|
||||
"label": "Tasks",
|
||||
"no_tasks": "No tasks",
|
||||
"no_title": "(No title)",
|
||||
"mark_complete": "Mark as complete",
|
||||
"mark_incomplete": "Mark as incomplete"
|
||||
"mark_incomplete": "Mark as incomplete",
|
||||
"filter_all": "All",
|
||||
"filter_pending": "Pending",
|
||||
"filter_completed": "Completed",
|
||||
"filter_overdue": "Overdue",
|
||||
"show_completed": "Show completed",
|
||||
"create": "New Task",
|
||||
"edit": "Edit Task",
|
||||
"title_placeholder": "Task title",
|
||||
"description_placeholder": "Add a description...",
|
||||
"due_date": "Due date",
|
||||
"include_time": "Include time",
|
||||
"priority": "Priority",
|
||||
"priority_none": "None",
|
||||
"priority_high": "High",
|
||||
"priority_medium": "Medium",
|
||||
"priority_low": "Low",
|
||||
"progress": "Status",
|
||||
"progress_needs_action": "Needs action",
|
||||
"progress_in_process": "In process",
|
||||
"progress_completed": "Completed",
|
||||
"progress_cancelled": "Cancelled",
|
||||
"calendar": "Calendar",
|
||||
"alert": "Reminder",
|
||||
"alert_none": "None",
|
||||
"alert_at_time": "At time of due date",
|
||||
"alert_5min": "5 minutes before",
|
||||
"alert_15min": "15 minutes before",
|
||||
"alert_30min": "30 minutes before",
|
||||
"alert_1hr": "1 hour before",
|
||||
"alert_1day": "1 day before",
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"quick_add_placeholder": "Add a task...",
|
||||
"due_today": "Today",
|
||||
"due_tomorrow": "Tomorrow",
|
||||
"overdue": "Overdue"
|
||||
}
|
||||
},
|
||||
"advanced_search": {
|
||||
@@ -2001,7 +2086,17 @@
|
||||
"tip_settings": "Customize your experience in Settings",
|
||||
"got_it": "Got it",
|
||||
"settings": "Settings",
|
||||
"dismiss": "Dismiss"
|
||||
"dismiss": "Dismiss",
|
||||
"start_tour": "Start Tour"
|
||||
},
|
||||
"demo_welcome": {
|
||||
"title": "Welcome to Bulwark Mail",
|
||||
"description": "Explore a fully-featured webmail client — right in your browser. All data stays on your device, so feel free to test everything.",
|
||||
"feature_email": "Read & compose email",
|
||||
"feature_organize": "Tags, stars & folders",
|
||||
"feature_shortcuts": "Keyboard shortcuts",
|
||||
"feature_privacy": "100% private demo",
|
||||
"hint": "Click any email on the left to get started, or take the tour below."
|
||||
},
|
||||
"files": {
|
||||
"title": "Files",
|
||||
@@ -2027,7 +2122,7 @@
|
||||
"file": "File",
|
||||
"parent_directory": "Parent directory",
|
||||
"breadcrumb_root": "Home",
|
||||
"drop_files_here": "Drop files here to upload",
|
||||
"drop_files_here": "Drop files or folders here to upload",
|
||||
"uploading": "Uploading...",
|
||||
"upload_success": "{count, plural, one {1 file uploaded} other {# files uploaded}}",
|
||||
"upload_error": "Failed to upload file",
|
||||
@@ -2181,5 +2276,54 @@
|
||||
"export_passphrase_desc": "Choose a passphrase to protect the exported PKCS#12 file",
|
||||
"export_storage_desc": "Enter the storage passphrase to decrypt the key for export",
|
||||
"incorrect_passphrase": "Incorrect passphrase"
|
||||
},
|
||||
"tour": {
|
||||
"step_counter": "Step {current} of {total}",
|
||||
"skip": "Skip tour",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"finish": "Finish",
|
||||
"take_a_tour": "Take a tour of the interface",
|
||||
"restart_title": "Introductory tour",
|
||||
"restart_desc": "Replay the guided walkthrough of the interface",
|
||||
"restart_button": "Restart tour",
|
||||
"sidebar_title": "Your mailboxes",
|
||||
"sidebar_desc": "This is your folder sidebar. Click any mailbox to view its emails. You can create folders, drag emails between them, and see unread counts at a glance.",
|
||||
"compose_title": "Compose an email",
|
||||
"compose_desc": "Click here to write a new email. You can add recipients, attachments, and use rich text formatting.",
|
||||
"search_title": "Search your mail",
|
||||
"search_desc": "Search by sender, subject, or content. Click the filter icon for advanced options like date range, attachments, and starred messages.",
|
||||
"email_list_title": "Your email list",
|
||||
"email_list_desc": "Emails appear here. Click one to read it on the right. Use the checkbox to select multiple, then bulk-move, delete, or tag them.",
|
||||
"email_viewer_title": "Reading pane",
|
||||
"email_viewer_desc": "The selected email opens here. Reply, forward, archive, or delete with the toolbar buttons. You can also star emails or add color tags.",
|
||||
"keywords_title": "Color tags",
|
||||
"keywords_desc": "Organize your email with color-coded tags. Drag an email onto a tag to label it, or right-click an email to assign tags.",
|
||||
"calendar_title": "Calendar",
|
||||
"calendar_desc": "Switch to the calendar to manage your events. Create events, set reminders, and view day, week, or month layouts.",
|
||||
"contacts_title": "Contacts",
|
||||
"contacts_desc": "Your address book lives here. Import contacts, create groups, and click any contact to see their full details.",
|
||||
"settings_title": "Settings",
|
||||
"settings_desc": "Customize everything: theme, density, signatures, filters, keyboard shortcuts, calendar defaults, and more.",
|
||||
"shortcuts_title": "Keyboard shortcuts",
|
||||
"shortcuts_desc": "Power users love this. Press ? anytime to see all available shortcuts. You can navigate, compose, and manage emails without touching a mouse.",
|
||||
"compose_open_title": "The composer",
|
||||
"compose_open_desc": "This is the email composer. Add recipients, write your message, attach files, and use rich text formatting. You can also save drafts and use templates.",
|
||||
"calendar_view_title": "Your calendar",
|
||||
"calendar_view_desc": "Here's your calendar with sample events. You can switch between day, week, month, and agenda views using the toolbar.",
|
||||
"create_event_title": "Create an event",
|
||||
"create_event_desc": "Click this button to create a new calendar event. You can set a title, date, time, and add participants.",
|
||||
"event_modal_title": "Event details",
|
||||
"event_modal_desc": "Here's the event form. Fill in the title, pick a date and time, add a location or participants. Hit save when you're done — or close it and move on.",
|
||||
"contacts_list_title": "Your contacts",
|
||||
"contacts_list_desc": "Here are your contacts. Click any contact to see their full details on the right. You can also create new contacts, import vCards, or organize contacts into groups.",
|
||||
"settings_tabs_title": "Settings menu",
|
||||
"settings_tabs_desc": "Here are all the settings categories. Customize your appearance, manage identities, set up email filters, configure your calendar, and much more.",
|
||||
"files_title": "File storage",
|
||||
"files_desc": "Your file browser lets you upload, organize, and share files — like a personal cloud drive built into your mail.",
|
||||
"demo_banner_title": "Demo controls",
|
||||
"demo_banner_desc": "You're in demo mode — everything stays in your browser. Hit 'Reset Demo' anytime to start fresh with clean sample data.",
|
||||
"quota_title": "Storage usage",
|
||||
"quota_desc": "Track your mailbox size here. The circle fills up as you use more space."
|
||||
}
|
||||
}
|
||||
|
||||
+105
-6
@@ -40,6 +40,12 @@
|
||||
"website": "Sitio web",
|
||||
"imprint": "Aviso legal",
|
||||
"privacy_policy": "Política de privacidad",
|
||||
"try_demo": "Probar demo",
|
||||
"demo_description": "Explora con datos de ejemplo — sin cuenta necesaria",
|
||||
"demo_launching": "Iniciando demo...",
|
||||
"demo_login_button": "Iniciar demo",
|
||||
"demo_tagline": "Experimenta un cliente de correo completo. Sin necesidad de cuenta.",
|
||||
"demo_no_signup": "Sin registro — explora libremente con datos de ejemplo",
|
||||
"oauth_completing": "Completando inicio de sesión...",
|
||||
"oauth_error": {
|
||||
"title": "Error de autenticación",
|
||||
@@ -104,6 +110,9 @@
|
||||
},
|
||||
"clear_search": "Limpiar búsqueda",
|
||||
"vacation_active": "Respuesta automática activa",
|
||||
"demo_banner": "Modo demo",
|
||||
"demo_reset": "Restablecer",
|
||||
"demo_tour": "Tour",
|
||||
"tags": "Etiquetas",
|
||||
"mail": "Correo",
|
||||
"nav_label": "Navegación",
|
||||
@@ -137,7 +146,8 @@
|
||||
"show_all": "Todos",
|
||||
"no_icons_found": "No se encontraron iconos",
|
||||
"inline_badge": "Integrado",
|
||||
"tab_badge": "Pestaña"
|
||||
"tab_badge": "Pestaña",
|
||||
"show_on_mobile": "Mostrar en móvil"
|
||||
},
|
||||
"email_list": {
|
||||
"no_emails": "No se encontraron mensajes",
|
||||
@@ -682,7 +692,9 @@
|
||||
"delete": "Eliminar palabra clave",
|
||||
"save": "Guardar",
|
||||
"add": "Añadir",
|
||||
"cancel": "Cancelar"
|
||||
"cancel": "Cancelar",
|
||||
"migrating": "Actualizando etiqueta en correos existentes…",
|
||||
"migration_error": "Error al actualizar la etiqueta en correos existentes"
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Idioma y Región",
|
||||
@@ -797,6 +809,17 @@
|
||||
"close": "Cerrar",
|
||||
"invalid_email": "Por favor ingrese una dirección de correo válida",
|
||||
"already_added": "Este remitente ya es de confianza"
|
||||
},
|
||||
"hover_actions": {
|
||||
"label": "Acciones rápidas al pasar el ratón",
|
||||
"description": "Elige qué acciones rápidas aparecen al pasar el ratón sobre un correo en la lista",
|
||||
"delete": "Eliminar",
|
||||
"star": "Marcar / Desmarcar estrella",
|
||||
"mark_read": "Marcar como leído / no leído",
|
||||
"archive": "Archivar",
|
||||
"tag": "Etiqueta",
|
||||
"spam": "Marcar como spam",
|
||||
"none_selected": "No hay acciones seleccionadas"
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
@@ -847,6 +870,9 @@
|
||||
"account": {
|
||||
"title": "Cuenta",
|
||||
"description": "Vea la información de su cuenta",
|
||||
"name_label": "Nombre para mostrar",
|
||||
"account_type_label": "Tipo de cuenta",
|
||||
"demo_account": "Cuenta de demostración",
|
||||
"email": {
|
||||
"label": "Dirección de Correo",
|
||||
"value": "{email}"
|
||||
@@ -1157,6 +1183,12 @@
|
||||
"opaque_warning": "Este script fue editado fuera del constructor visual. Solo está disponible la edición Sieve sin formato.",
|
||||
"open_sieve_editor": "Abrir editor Sieve",
|
||||
"fetch_error": "Error al cargar los filtros",
|
||||
"expanded_view": "Vista expandida",
|
||||
"expanded_view_description": "Mostrar reglas de filtro con bloques detallados de condiciones y acciones",
|
||||
"if": "Si",
|
||||
"then": "Entonces",
|
||||
"match_all_conditions": "todas coinciden",
|
||||
"match_any_condition": "alguna coincide",
|
||||
"and": "y",
|
||||
"or": "o",
|
||||
"cancel": "Cancelar",
|
||||
@@ -1470,6 +1502,9 @@
|
||||
"title": "Contactos",
|
||||
"search_placeholder": "Buscar contactos...",
|
||||
"create_new": "Nuevo contacto",
|
||||
"no_category": "Sin categoría",
|
||||
"category_added": "Contacto añadido a {name}",
|
||||
"category_added_plural": "{count} contactos añadidos a {name}",
|
||||
"empty_state": "No hay contactos",
|
||||
"empty_state_title": "Sin contactos",
|
||||
"empty_state_subtitle": "Crea tu primer contacto o importa desde un archivo vCard",
|
||||
@@ -1613,7 +1648,8 @@
|
||||
"level_low": "Bajo",
|
||||
"categories": "Categorías",
|
||||
"categories_placeholder": "p. ej., Familia, Amigos, Colegas",
|
||||
"categories_hint": "Separar con comas",
|
||||
"categories_hint": "Escriba para buscar o añadir categorías",
|
||||
"category_add": "Añadir",
|
||||
"note": "Notas",
|
||||
"note_placeholder": "Agregar una nota...",
|
||||
"gender": "Género",
|
||||
@@ -1826,7 +1862,11 @@
|
||||
"notification_sound": "Sonido de notificación",
|
||||
"notification_sound_desc": "Reproducir un sonido para las alertas del calendario",
|
||||
"invitation_parsing": "Analizar invitaciones por correo",
|
||||
"invitation_parsing_desc": "Detectar invitaciones de calendario en archivos adjuntos del correo y mostrar acciones del calendario"
|
||||
"invitation_parsing_desc": "Detectar invitaciones de calendario en archivos adjuntos del correo y mostrar acciones del calendario",
|
||||
"show_time_in_month_view": "Mostrar hora en vista mensual",
|
||||
"show_time_in_month_view_desc": "Mostrar las horas de los eventos en la vista mensual del calendario",
|
||||
"show_week_numbers": "Mostrar números de semana",
|
||||
"show_week_numbers_desc": "Mostrar los números de semana en el minicalendario"
|
||||
},
|
||||
"days": {
|
||||
"monday": "Lunes",
|
||||
@@ -2001,7 +2041,17 @@
|
||||
"tip_settings": "Personaliza tu experiencia en Ajustes",
|
||||
"got_it": "Entendido",
|
||||
"settings": "Ajustes",
|
||||
"dismiss": "Cerrar"
|
||||
"dismiss": "Cerrar",
|
||||
"start_tour": "Iniciar tour"
|
||||
},
|
||||
"demo_welcome": {
|
||||
"title": "Bienvenido a Bulwark Mail",
|
||||
"description": "Explora un cliente de correo web completo — directamente en tu navegador. Todos los datos quedan en tu dispositivo, así que prueba todo.",
|
||||
"feature_email": "Leer y redactar correos",
|
||||
"feature_organize": "Etiquetas, estrellas y carpetas",
|
||||
"feature_shortcuts": "Atajos de teclado",
|
||||
"feature_privacy": "Demo 100 % privada",
|
||||
"hint": "Haz clic en un correo a la izquierda para empezar, o inicia el tour."
|
||||
},
|
||||
"files": {
|
||||
"title": "Archivos",
|
||||
@@ -2027,7 +2077,7 @@
|
||||
"file": "Archivo",
|
||||
"parent_directory": "Directorio superior",
|
||||
"breadcrumb_root": "Inicio",
|
||||
"drop_files_here": "Suelte los archivos aquí para subirlos",
|
||||
"drop_files_here": "Suelte archivos o carpetas aquí para subirlos",
|
||||
"uploading": "Subiendo...",
|
||||
"upload_success": "{count, plural, one {1 archivo subido} other {# archivos subidos}}",
|
||||
"upload_error": "Error al subir el archivo",
|
||||
@@ -2181,5 +2231,54 @@
|
||||
"export_passphrase_desc": "Elige una contraseña para proteger el archivo PKCS#12 exportado",
|
||||
"export_storage_desc": "Introduce la contraseña de almacenamiento para descifrar la clave antes de exportarla",
|
||||
"incorrect_passphrase": "Contraseña incorrecta"
|
||||
},
|
||||
"tour": {
|
||||
"step_counter": "Paso {current} de {total}",
|
||||
"skip": "Saltar tour",
|
||||
"back": "Atrás",
|
||||
"next": "Siguiente",
|
||||
"finish": "Finalizar",
|
||||
"take_a_tour": "Haz un recorrido por la interfaz",
|
||||
"restart_title": "Tour introductorio",
|
||||
"restart_desc": "Repetir el recorrido guiado por la interfaz",
|
||||
"restart_button": "Reiniciar tour",
|
||||
"sidebar_title": "Tus buzones",
|
||||
"sidebar_desc": "Esta es tu barra lateral de carpetas. Haz clic en cualquier buzón para ver sus correos. Puedes crear carpetas, arrastrar correos entre ellas y ver los contadores de no leídos.",
|
||||
"compose_title": "Redactar un correo",
|
||||
"compose_desc": "Haz clic aquí para escribir un nuevo correo. Puedes añadir destinatarios, archivos adjuntos y formato de texto enriquecido.",
|
||||
"search_title": "Buscar en tu correo",
|
||||
"search_desc": "Busca por remitente, asunto o contenido. Haz clic en el icono de filtro para opciones avanzadas como rango de fechas, adjuntos y mensajes destacados.",
|
||||
"email_list_title": "Tu lista de correos",
|
||||
"email_list_desc": "Los correos aparecen aquí. Haz clic en uno para leerlo a la derecha. Usa la casilla para seleccionar varios y moverlos, eliminarlos o etiquetarlos.",
|
||||
"email_viewer_title": "Panel de lectura",
|
||||
"email_viewer_desc": "El correo seleccionado se abre aquí. Responde, reenvía, archiva o elimina con los botones de la barra. También puedes destacar correos o añadir etiquetas de color.",
|
||||
"keywords_title": "Etiquetas de color",
|
||||
"keywords_desc": "Organiza tu correo con etiquetas de colores. Arrastra un correo sobre una etiqueta o haz clic derecho para asignarlas.",
|
||||
"calendar_title": "Calendario",
|
||||
"calendar_desc": "Cambia al calendario para gestionar tus eventos. Crea eventos, configura recordatorios y elige diferentes vistas.",
|
||||
"contacts_title": "Contactos",
|
||||
"contacts_desc": "Tu libreta de direcciones está aquí. Importa contactos, crea grupos y consulta los detalles.",
|
||||
"settings_title": "Ajustes",
|
||||
"settings_desc": "Personaliza todo: tema, densidad, firmas, filtros, atajos de teclado, valores predeterminados del calendario y más.",
|
||||
"shortcuts_title": "Atajos de teclado",
|
||||
"shortcuts_desc": "Para usuarios avanzados. Pulsa ? en cualquier momento para ver todos los atajos disponibles.",
|
||||
"compose_open_title": "El compositor",
|
||||
"compose_open_desc": "Este es el compositor de correo. Añade destinatarios, escribe tu mensaje, adjunta archivos y usa formato de texto enriquecido. También puedes guardar borradores y usar plantillas.",
|
||||
"calendar_view_title": "Tu calendario",
|
||||
"calendar_view_desc": "Aquí está tu calendario con eventos de ejemplo. Cambia entre vistas de día, semana, mes y agenda.",
|
||||
"create_event_title": "Crear un evento",
|
||||
"create_event_desc": "Haz clic en este botón para crear un nuevo evento de calendario. Puedes establecer un título, fecha, hora y añadir participantes.",
|
||||
"event_modal_title": "Detalles del evento",
|
||||
"event_modal_desc": "Aquí está el formulario del evento. Rellena el título, elige una fecha y hora, añade una ubicación o participantes. Pulsa guardar cuando hayas terminado — o ciérralo y continúa.",
|
||||
"contacts_list_title": "Tus contactos",
|
||||
"contacts_list_desc": "Aquí están tus contactos. Haz clic en cualquier contacto para ver sus detalles. Puedes crear contactos nuevos o importar vCards.",
|
||||
"files_title": "Almacenamiento de archivos",
|
||||
"settings_tabs_title": "Menú de ajustes",
|
||||
"settings_tabs_desc": "Aquí están todas las categorías de ajustes. Personaliza la apariencia, gestiona identidades, configura filtros de correo, ajusta tu calendario y mucho más.",
|
||||
"files_desc": "Tu explorador de archivos para subir, organizar y compartir archivos.",
|
||||
"demo_banner_title": "Controles de demo",
|
||||
"demo_banner_desc": "Estás en modo demo — todo permanece en tu navegador. Haz clic en 'Restablecer demo' en cualquier momento.",
|
||||
"quota_title": "Uso de almacenamiento",
|
||||
"quota_desc": "Controla el tamaño de tu buzón aquí. El círculo se llena a medida que usas más espacio."
|
||||
}
|
||||
}
|
||||
|
||||
+105
-6
@@ -40,6 +40,12 @@
|
||||
"website": "Site web",
|
||||
"imprint": "Mentions légales",
|
||||
"privacy_policy": "Politique de confidentialité",
|
||||
"try_demo": "Essayer la démo",
|
||||
"demo_description": "Explorez avec des données d'exemple — aucun compte nécessaire",
|
||||
"demo_launching": "Lancement de la démo...",
|
||||
"demo_login_button": "Lancer la démo",
|
||||
"demo_tagline": "Découvrez un client de messagerie complet. Aucun compte requis.",
|
||||
"demo_no_signup": "Aucune inscription — explorez librement avec des données d'exemple",
|
||||
"oauth_completing": "Connexion en cours...",
|
||||
"oauth_error": {
|
||||
"title": "Échec de l'authentification",
|
||||
@@ -104,6 +110,9 @@
|
||||
},
|
||||
"clear_search": "Effacer la recherche",
|
||||
"vacation_active": "Répondeur d'absence activé",
|
||||
"demo_banner": "Mode démo",
|
||||
"demo_reset": "Réinitialiser",
|
||||
"demo_tour": "Visite",
|
||||
"tags": "Étiquettes",
|
||||
"mail": "Messagerie",
|
||||
"nav_label": "Navigation",
|
||||
@@ -137,7 +146,8 @@
|
||||
"show_all": "Toutes",
|
||||
"no_icons_found": "Aucune icône trouvée",
|
||||
"inline_badge": "Intégré",
|
||||
"tab_badge": "Onglet"
|
||||
"tab_badge": "Onglet",
|
||||
"show_on_mobile": "Afficher sur mobile"
|
||||
},
|
||||
"email_list": {
|
||||
"no_emails": "Aucun message trouvé",
|
||||
@@ -682,7 +692,9 @@
|
||||
"delete": "Supprimer le mot-clé",
|
||||
"save": "Enregistrer",
|
||||
"add": "Ajouter",
|
||||
"cancel": "Annuler"
|
||||
"cancel": "Annuler",
|
||||
"migrating": "Mise à jour du mot-clé sur les e-mails existants…",
|
||||
"migration_error": "Échec de la mise à jour du mot-clé sur les e-mails existants"
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Langue et région",
|
||||
@@ -797,6 +809,17 @@
|
||||
"close": "Fermer",
|
||||
"invalid_email": "Veuillez entrer une adresse email valide",
|
||||
"already_added": "Cet expéditeur est déjà de confiance"
|
||||
},
|
||||
"hover_actions": {
|
||||
"label": "Actions rapides au survol",
|
||||
"description": "Choisissez les actions rapides qui apparaissent au survol d'un e-mail dans la liste",
|
||||
"delete": "Supprimer",
|
||||
"star": "Étoile / Retirer l'étoile",
|
||||
"mark_read": "Marquer lu / non lu",
|
||||
"archive": "Archiver",
|
||||
"tag": "Étiquette",
|
||||
"spam": "Marquer comme spam",
|
||||
"none_selected": "Aucune action sélectionnée"
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
@@ -847,6 +870,9 @@
|
||||
"account": {
|
||||
"title": "Compte",
|
||||
"description": "Consultez les informations de votre compte",
|
||||
"name_label": "Nom d'affichage",
|
||||
"account_type_label": "Type de compte",
|
||||
"demo_account": "Compte de démonstration",
|
||||
"email": {
|
||||
"label": "Adresse email",
|
||||
"value": "{email}"
|
||||
@@ -1157,6 +1183,12 @@
|
||||
"opaque_warning": "Ce script a été modifié en dehors du constructeur visuel. Seule l'édition Sieve brute est disponible.",
|
||||
"open_sieve_editor": "Ouvrir l'éditeur Sieve brut",
|
||||
"fetch_error": "Échec du chargement des filtres",
|
||||
"expanded_view": "Vue étendue",
|
||||
"expanded_view_description": "Afficher les règles de filtre avec des blocs de conditions et d'actions détaillés",
|
||||
"if": "Si",
|
||||
"then": "Alors",
|
||||
"match_all_conditions": "toutes correspondent",
|
||||
"match_any_condition": "une correspond",
|
||||
"and": "et",
|
||||
"or": "ou",
|
||||
"cancel": "Annuler",
|
||||
@@ -1470,6 +1502,9 @@
|
||||
"title": "Contacts",
|
||||
"search_placeholder": "Rechercher des contacts...",
|
||||
"create_new": "Nouveau contact",
|
||||
"no_category": "Sans catégorie",
|
||||
"category_added": "Contact ajouté à {name}",
|
||||
"category_added_plural": "{count} contacts ajoutés à {name}",
|
||||
"empty_state": "Aucun contact",
|
||||
"empty_state_title": "Aucun contact",
|
||||
"empty_state_subtitle": "Créez votre premier contact ou importez depuis un fichier vCard",
|
||||
@@ -1613,7 +1648,8 @@
|
||||
"level_low": "Faible",
|
||||
"categories": "Catégories",
|
||||
"categories_placeholder": "p. ex., Famille, Amis, Collègues",
|
||||
"categories_hint": "Séparer par des virgules",
|
||||
"categories_hint": "Tapez pour rechercher ou ajouter",
|
||||
"category_add": "Ajouter",
|
||||
"note": "Notes",
|
||||
"note_placeholder": "Ajouter une note...",
|
||||
"gender": "Genre",
|
||||
@@ -1826,7 +1862,11 @@
|
||||
"notification_sound": "Son de notification",
|
||||
"notification_sound_desc": "Jouer un son pour les alertes de calendrier",
|
||||
"invitation_parsing": "Analyser les invitations par e-mail",
|
||||
"invitation_parsing_desc": "Détecter les invitations de calendrier dans les pièces jointes des e-mails et afficher les actions du calendrier"
|
||||
"invitation_parsing_desc": "Détecter les invitations de calendrier dans les pièces jointes des e-mails et afficher les actions du calendrier",
|
||||
"show_time_in_month_view": "Afficher l'heure dans la vue mensuelle",
|
||||
"show_time_in_month_view_desc": "Afficher les heures des événements dans la vue mensuelle du calendrier",
|
||||
"show_week_numbers": "Afficher les numéros de semaine",
|
||||
"show_week_numbers_desc": "Afficher les numéros de semaine dans le mini-calendrier"
|
||||
},
|
||||
"days": {
|
||||
"monday": "Lundi",
|
||||
@@ -2001,7 +2041,17 @@
|
||||
"tip_settings": "Personnalisez votre experience dans les Parametres",
|
||||
"got_it": "Compris",
|
||||
"settings": "Paramètres",
|
||||
"dismiss": "Fermer"
|
||||
"dismiss": "Fermer",
|
||||
"start_tour": "Démarrer la visite"
|
||||
},
|
||||
"demo_welcome": {
|
||||
"title": "Bienvenue sur Bulwark Mail",
|
||||
"description": "Explorez un client webmail complet — directement dans votre navigateur. Toutes les données restent sur votre appareil, alors testez tout.",
|
||||
"feature_email": "Lire et rédiger des e-mails",
|
||||
"feature_organize": "Tags, étoiles et dossiers",
|
||||
"feature_shortcuts": "Raccourcis clavier",
|
||||
"feature_privacy": "Démo 100 % privée",
|
||||
"hint": "Cliquez sur un e-mail à gauche pour commencer, ou lancez la visite."
|
||||
},
|
||||
"files": {
|
||||
"title": "Fichiers",
|
||||
@@ -2027,7 +2077,7 @@
|
||||
"file": "Fichier",
|
||||
"parent_directory": "Répertoire parent",
|
||||
"breadcrumb_root": "Accueil",
|
||||
"drop_files_here": "Déposez les fichiers ici pour les téléverser",
|
||||
"drop_files_here": "Déposez des fichiers ou dossiers ici pour les téléverser",
|
||||
"uploading": "Téléversement en cours...",
|
||||
"upload_success": "{count, plural, one {1 fichier téléversé} other {# fichiers téléversés}}",
|
||||
"upload_error": "Échec du téléversement du fichier",
|
||||
@@ -2181,5 +2231,54 @@
|
||||
"export_passphrase_desc": "Choisissez une phrase secrète pour protéger le fichier PKCS#12 exporté",
|
||||
"export_storage_desc": "Entrez la phrase secrète de stockage pour déchiffrer la clé avant l'export",
|
||||
"incorrect_passphrase": "Phrase secrète incorrecte"
|
||||
},
|
||||
"tour": {
|
||||
"step_counter": "Étape {current} sur {total}",
|
||||
"skip": "Passer la visite",
|
||||
"back": "Retour",
|
||||
"next": "Suivant",
|
||||
"finish": "Terminer",
|
||||
"take_a_tour": "Faire une visite de l'interface",
|
||||
"restart_title": "Visite d'introduction",
|
||||
"restart_desc": "Rejouer la visite guidée de l'interface",
|
||||
"restart_button": "Relancer la visite",
|
||||
"sidebar_title": "Vos boîtes mail",
|
||||
"sidebar_desc": "Voici votre barre latérale de dossiers. Cliquez sur une boîte pour voir ses emails. Vous pouvez créer des dossiers, glisser des emails entre eux et voir les compteurs de non lus.",
|
||||
"compose_title": "Rédiger un email",
|
||||
"compose_desc": "Cliquez ici pour écrire un nouvel email. Ajoutez des destinataires, des pièces jointes et utilisez la mise en forme enrichie.",
|
||||
"search_title": "Rechercher vos emails",
|
||||
"search_desc": "Recherchez par expéditeur, objet ou contenu. Cliquez sur l'icône de filtre pour les options avancées comme la plage de dates, les pièces jointes et les messages suivis.",
|
||||
"email_list_title": "Votre liste d'emails",
|
||||
"email_list_desc": "Les emails apparaissent ici. Cliquez sur un email pour le lire à droite. Utilisez la case à cocher pour en sélectionner plusieurs, puis déplacez, supprimez ou étiquetez-les.",
|
||||
"email_viewer_title": "Panneau de lecture",
|
||||
"email_viewer_desc": "L'email sélectionné s'ouvre ici. Répondez, transférez, archivez ou supprimez avec les boutons de la barre d'outils. Vous pouvez aussi marquer les emails ou ajouter des étiquettes de couleur.",
|
||||
"keywords_title": "Étiquettes de couleur",
|
||||
"keywords_desc": "Organisez vos emails avec des étiquettes colorées. Glissez un email sur une étiquette pour le marquer, ou faites un clic droit pour assigner des étiquettes.",
|
||||
"calendar_title": "Calendrier",
|
||||
"calendar_desc": "Accédez au calendrier pour gérer vos événements. Créez des événements, définissez des rappels et consultez les vues jour, semaine ou mois.",
|
||||
"contacts_title": "Contacts",
|
||||
"contacts_desc": "Votre carnet d'adresses se trouve ici. Importez des contacts, créez des groupes et cliquez sur un contact pour voir ses détails.",
|
||||
"settings_title": "Paramètres",
|
||||
"settings_desc": "Personnalisez tout : thème, densité, signatures, filtres, raccourcis clavier, paramètres du calendrier et plus encore.",
|
||||
"shortcuts_title": "Raccourcis clavier",
|
||||
"shortcuts_desc": "Les utilisateurs avancés adorent ça. Appuyez sur ? à tout moment pour voir tous les raccourcis disponibles. Naviguez, rédigez et gérez vos emails sans toucher à la souris.",
|
||||
"compose_open_title": "Le compositeur",
|
||||
"compose_open_desc": "Voici le compositeur d'e-mail. Ajoutez des destinataires, rédigez votre message, joignez des fichiers et utilisez la mise en forme enrichie. Vous pouvez aussi sauvegarder des brouillons et utiliser des modèles.",
|
||||
"calendar_view_title": "Votre calendrier",
|
||||
"calendar_view_desc": "Voici votre calendrier avec des événements exemples. Basculez entre les vues jour, semaine, mois et agenda avec la barre d'outils.",
|
||||
"create_event_title": "Créer un événement",
|
||||
"create_event_desc": "Cliquez sur ce bouton pour créer un nouvel événement. Vous pouvez définir un titre, une date, une heure et ajouter des participants.",
|
||||
"event_modal_title": "Détails de l'événement",
|
||||
"event_modal_desc": "Voici le formulaire de l'événement. Remplissez le titre, choisissez une date et une heure, ajoutez un lieu ou des participants. Cliquez sur enregistrer quand vous avez terminé — ou fermez-le et passez à autre chose.",
|
||||
"contacts_list_title": "Vos contacts",
|
||||
"contacts_list_desc": "Voici vos contacts. Cliquez sur un contact pour voir ses détails à droite. Vous pouvez aussi créer de nouveaux contacts, importer des vCards ou organiser les contacts en groupes.",
|
||||
"files_title": "Stockage de fichiers",
|
||||
"settings_tabs_title": "Menu des paramètres",
|
||||
"settings_tabs_desc": "Voici toutes les catégories de paramètres. Personnalisez l'apparence, gérez les identités, configurez les filtres de messagerie, paramétrez votre calendrier et bien plus encore.",
|
||||
"files_desc": "Votre gestionnaire de fichiers vous permet de téléverser, organiser et partager des fichiers — comme un cloud personnel intégré à votre messagerie.",
|
||||
"demo_banner_title": "Contrôles de démo",
|
||||
"demo_banner_desc": "Vous êtes en mode démo — tout reste dans votre navigateur. Cliquez sur 'Réinitialiser la démo' à tout moment pour repartir avec des données fraîches.",
|
||||
"quota_title": "Utilisation du stockage",
|
||||
"quota_desc": "Suivez la taille de votre boîte mail ici. Le cercle se remplit au fur et à mesure que vous utilisez plus d'espace."
|
||||
}
|
||||
}
|
||||
|
||||
+105
-6
@@ -40,6 +40,12 @@
|
||||
"website": "Sito web",
|
||||
"imprint": "Note legali",
|
||||
"privacy_policy": "Informativa sulla privacy",
|
||||
"try_demo": "Prova la demo",
|
||||
"demo_description": "Esplora con dati di esempio — nessun account necessario",
|
||||
"demo_launching": "Avvio demo...",
|
||||
"demo_login_button": "Avvia demo",
|
||||
"demo_tagline": "Scopri un client di posta completo. Nessun account richiesto.",
|
||||
"demo_no_signup": "Nessuna registrazione — esplora liberamente con dati di esempio",
|
||||
"oauth_completing": "Completamento dell'accesso...",
|
||||
"oauth_error": {
|
||||
"title": "Autenticazione non riuscita",
|
||||
@@ -104,6 +110,9 @@
|
||||
},
|
||||
"clear_search": "Cancella ricerca",
|
||||
"vacation_active": "Risponditore automatico attivo",
|
||||
"demo_banner": "Modalità demo",
|
||||
"demo_reset": "Reimposta",
|
||||
"demo_tour": "Tour",
|
||||
"tags": "Etichette",
|
||||
"mail": "Posta",
|
||||
"nav_label": "Navigazione",
|
||||
@@ -137,7 +146,8 @@
|
||||
"show_all": "Tutte",
|
||||
"no_icons_found": "Nessuna icona trovata",
|
||||
"inline_badge": "Integrato",
|
||||
"tab_badge": "Scheda"
|
||||
"tab_badge": "Scheda",
|
||||
"show_on_mobile": "Mostra su mobile"
|
||||
},
|
||||
"email_list": {
|
||||
"no_emails": "Nessun messaggio trovato",
|
||||
@@ -682,7 +692,9 @@
|
||||
"delete": "Elimina parola chiave",
|
||||
"save": "Salva",
|
||||
"add": "Aggiungi",
|
||||
"cancel": "Annulla"
|
||||
"cancel": "Annulla",
|
||||
"migrating": "Aggiornamento parola chiave sulle email esistenti…",
|
||||
"migration_error": "Impossibile aggiornare la parola chiave sulle email esistenti"
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Lingua e regione",
|
||||
@@ -797,6 +809,17 @@
|
||||
"close": "Chiudi",
|
||||
"invalid_email": "Inserisci un indirizzo email valido",
|
||||
"already_added": "Questo mittente è già attendibile"
|
||||
},
|
||||
"hover_actions": {
|
||||
"label": "Azioni rapide al passaggio del mouse",
|
||||
"description": "Scegli quali azioni rapide appaiono al passaggio del mouse su un'email nella lista",
|
||||
"delete": "Elimina",
|
||||
"star": "Segna / Rimuovi stella",
|
||||
"mark_read": "Segna come letto / non letto",
|
||||
"archive": "Archivia",
|
||||
"tag": "Etichetta",
|
||||
"spam": "Segna come spam",
|
||||
"none_selected": "Nessuna azione selezionata"
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
@@ -847,6 +870,9 @@
|
||||
"account": {
|
||||
"title": "Account",
|
||||
"description": "Visualizza le informazioni del tuo account",
|
||||
"name_label": "Nome visualizzato",
|
||||
"account_type_label": "Tipo di account",
|
||||
"demo_account": "Account dimostrativo",
|
||||
"email": {
|
||||
"label": "Indirizzo email",
|
||||
"value": "{email}"
|
||||
@@ -1157,6 +1183,12 @@
|
||||
"opaque_warning": "Questo script è stato modificato al di fuori del costruttore visuale. È disponibile solo la modifica Sieve grezza.",
|
||||
"open_sieve_editor": "Apri editor Sieve",
|
||||
"fetch_error": "Impossibile caricare i filtri",
|
||||
"expanded_view": "Vista espansa",
|
||||
"expanded_view_description": "Mostra le regole dei filtri con blocchi dettagliati di condizioni e azioni",
|
||||
"if": "Se",
|
||||
"then": "Allora",
|
||||
"match_all_conditions": "tutte corrispondono",
|
||||
"match_any_condition": "una corrisponde",
|
||||
"and": "e",
|
||||
"or": "o",
|
||||
"cancel": "Annulla",
|
||||
@@ -1470,6 +1502,9 @@
|
||||
"title": "Contatti",
|
||||
"search_placeholder": "Cerca contatti...",
|
||||
"create_new": "Nuovo contatto",
|
||||
"no_category": "Senza categoria",
|
||||
"category_added": "Contatto aggiunto a {name}",
|
||||
"category_added_plural": "{count} contatti aggiunti a {name}",
|
||||
"empty_state": "Nessun contatto",
|
||||
"empty_state_title": "Nessun contatto",
|
||||
"empty_state_subtitle": "Crea il tuo primo contatto o importa da un file vCard",
|
||||
@@ -1613,7 +1648,8 @@
|
||||
"level_low": "Basso",
|
||||
"categories": "Categorie",
|
||||
"categories_placeholder": "es., Famiglia, Amici, Colleghi",
|
||||
"categories_hint": "Separare con virgole",
|
||||
"categories_hint": "Digita per cercare o aggiungere",
|
||||
"category_add": "Aggiungi",
|
||||
"note": "Note",
|
||||
"note_placeholder": "Aggiungi una nota...",
|
||||
"gender": "Genere",
|
||||
@@ -1826,7 +1862,11 @@
|
||||
"notification_sound": "Suono di notifica",
|
||||
"notification_sound_desc": "Riproduci un suono per gli avvisi del calendario",
|
||||
"invitation_parsing": "Analizza gli inviti email",
|
||||
"invitation_parsing_desc": "Rileva gli inviti del calendario negli allegati email e mostra le azioni del calendario"
|
||||
"invitation_parsing_desc": "Rileva gli inviti del calendario negli allegati email e mostra le azioni del calendario",
|
||||
"show_time_in_month_view": "Mostra orario nella vista mensile",
|
||||
"show_time_in_month_view_desc": "Visualizza gli orari degli eventi nella vista mensile del calendario",
|
||||
"show_week_numbers": "Mostra numeri di settimana",
|
||||
"show_week_numbers_desc": "Mostra i numeri di settimana nel mini-calendario"
|
||||
},
|
||||
"days": {
|
||||
"monday": "Lunedì",
|
||||
@@ -2001,7 +2041,17 @@
|
||||
"tip_settings": "Personalizza la tua esperienza nelle Impostazioni",
|
||||
"got_it": "Ho capito",
|
||||
"settings": "Impostazioni",
|
||||
"dismiss": "Chiudi"
|
||||
"dismiss": "Chiudi",
|
||||
"start_tour": "Inizia il tour"
|
||||
},
|
||||
"demo_welcome": {
|
||||
"title": "Benvenuto su Bulwark Mail",
|
||||
"description": "Esplora un client webmail completo — direttamente nel tuo browser. Tutti i dati restano sul tuo dispositivo, quindi prova tutto.",
|
||||
"feature_email": "Leggere e scrivere email",
|
||||
"feature_organize": "Tag, stelle e cartelle",
|
||||
"feature_shortcuts": "Scorciatoie da tastiera",
|
||||
"feature_privacy": "Demo 100% privata",
|
||||
"hint": "Clicca su un'email a sinistra per iniziare, oppure fai il tour."
|
||||
},
|
||||
"files": {
|
||||
"title": "File",
|
||||
@@ -2027,7 +2077,7 @@
|
||||
"file": "File",
|
||||
"parent_directory": "Directory superiore",
|
||||
"breadcrumb_root": "Home",
|
||||
"drop_files_here": "Trascina i file qui per caricarli",
|
||||
"drop_files_here": "Trascina file o cartelle qui per caricarli",
|
||||
"uploading": "Caricamento in corso...",
|
||||
"upload_success": "{count, plural, one {1 file caricato} other {# file caricati}}",
|
||||
"upload_error": "Caricamento del file non riuscito",
|
||||
@@ -2181,5 +2231,54 @@
|
||||
"export_passphrase_desc": "Scegli una passphrase per proteggere il file PKCS#12 esportato",
|
||||
"export_storage_desc": "Inserisci la passphrase di archiviazione per decrittare la chiave prima dell'esportazione",
|
||||
"incorrect_passphrase": "Passphrase errata"
|
||||
},
|
||||
"tour": {
|
||||
"step_counter": "Passo {current} di {total}",
|
||||
"skip": "Salta il tour",
|
||||
"back": "Indietro",
|
||||
"next": "Avanti",
|
||||
"finish": "Fine",
|
||||
"take_a_tour": "Fai un tour dell'interfaccia",
|
||||
"restart_title": "Tour introduttivo",
|
||||
"restart_desc": "Rivedi la guida dell'interfaccia",
|
||||
"restart_button": "Riavvia il tour",
|
||||
"sidebar_title": "Le tue caselle di posta",
|
||||
"sidebar_desc": "Questa è la barra laterale delle cartelle. Clicca su una casella per vedere le email. Puoi creare cartelle, trascinare email tra loro e vedere i conteggi dei non letti.",
|
||||
"compose_title": "Scrivi un'email",
|
||||
"compose_desc": "Clicca qui per scrivere una nuova email. Puoi aggiungere destinatari, allegati e usare la formattazione RTF.",
|
||||
"search_title": "Cerca nella posta",
|
||||
"search_desc": "Cerca per mittente, oggetto o contenuto. Clicca sull'icona del filtro per opzioni avanzate come intervallo di date, allegati e messaggi speciali.",
|
||||
"email_list_title": "La tua lista email",
|
||||
"email_list_desc": "Le email appaiono qui. Clicca su una per leggerla a destra. Usa la casella di controllo per selezionarne più di una, poi sposta, elimina o etichetta in blocco.",
|
||||
"email_viewer_title": "Pannello di lettura",
|
||||
"email_viewer_desc": "L'email selezionata si apre qui. Rispondi, inoltra, archivia o elimina con i pulsanti della barra degli strumenti. Puoi anche contrassegnare le email o aggiungere etichette colorate.",
|
||||
"keywords_title": "Etichette colorate",
|
||||
"keywords_desc": "Organizza le tue email con etichette colorate. Trascina un'email su un'etichetta per contrassegnarla, o fai clic destro per assegnare etichette.",
|
||||
"calendar_title": "Calendario",
|
||||
"calendar_desc": "Passa al calendario per gestire i tuoi eventi. Crea eventi, imposta promemoria e visualizza le viste giorno, settimana o mese.",
|
||||
"contacts_title": "Contatti",
|
||||
"contacts_desc": "La tua rubrica si trova qui. Importa contatti, crea gruppi e clicca su un contatto per vedere i suoi dettagli completi.",
|
||||
"settings_title": "Impostazioni",
|
||||
"settings_desc": "Personalizza tutto: tema, densità, firme, filtri, scorciatoie da tastiera, impostazioni del calendario e altro ancora.",
|
||||
"shortcuts_title": "Scorciatoie da tastiera",
|
||||
"shortcuts_desc": "Gli utenti esperti adorano questo. Premi ? in qualsiasi momento per vedere tutte le scorciatoie disponibili. Puoi navigare, comporre e gestire le email senza toccare il mouse.",
|
||||
"compose_open_title": "Il compositore",
|
||||
"compose_open_desc": "Questo è il compositore di email. Aggiungi destinatari, scrivi il tuo messaggio, allega file e usa la formattazione del testo. Puoi anche salvare bozze e usare modelli.",
|
||||
"calendar_view_title": "Il tuo calendario",
|
||||
"calendar_view_desc": "Ecco il tuo calendario con eventi di esempio. Puoi passare tra le viste giorno, settimana, mese e agenda usando la barra degli strumenti.",
|
||||
"create_event_title": "Crea un evento",
|
||||
"create_event_desc": "Fai clic su questo pulsante per creare un nuovo evento del calendario. Puoi impostare un titolo, data, ora e aggiungere partecipanti.",
|
||||
"event_modal_title": "Dettagli evento",
|
||||
"event_modal_desc": "Ecco il modulo dell'evento. Inserisci il titolo, scegli data e ora, aggiungi un luogo o partecipanti. Premi salva quando hai finito — o chiudilo e vai avanti.",
|
||||
"contacts_list_title": "I tuoi contatti",
|
||||
"contacts_list_desc": "Ecco i tuoi contatti. Clicca su un contatto per vedere i suoi dettagli a destra. Puoi anche creare nuovi contatti, importare vCard o organizzare i contatti in gruppi.",
|
||||
"files_title": "Archiviazione file",
|
||||
"settings_tabs_title": "Menu impostazioni",
|
||||
"settings_tabs_desc": "Ecco tutte le categorie di impostazioni. Personalizza l'aspetto, gestisci le identità, configura i filtri email, imposta il calendario e molto altro.",
|
||||
"files_desc": "Il tuo file browser ti permette di caricare, organizzare e condividere file — come un cloud personale integrato nella tua posta.",
|
||||
"demo_banner_title": "Controlli demo",
|
||||
"demo_banner_desc": "Sei in modalità demo — tutto rimane nel tuo browser. Premi 'Reimposta Demo' in qualsiasi momento per ricominciare con dati puliti.",
|
||||
"quota_title": "Utilizzo dello spazio",
|
||||
"quota_desc": "Monitora le dimensioni della tua casella qui. Il cerchio si riempie man mano che utilizzi più spazio."
|
||||
}
|
||||
}
|
||||
|
||||
+105
-6
@@ -40,6 +40,12 @@
|
||||
"website": "ウェブサイト",
|
||||
"imprint": "サイト運営者情報",
|
||||
"privacy_policy": "プライバシーポリシー",
|
||||
"try_demo": "デモを試す",
|
||||
"demo_description": "サンプルデータで探索 — アカウント不要",
|
||||
"demo_launching": "デモを起動中...",
|
||||
"demo_login_button": "デモを開始",
|
||||
"demo_tagline": "フル機能のメールクライアントを体験。アカウント不要。",
|
||||
"demo_no_signup": "登録不要 — サンプルデータで自由に探索",
|
||||
"oauth_completing": "サインイン処理中...",
|
||||
"oauth_error": {
|
||||
"title": "認証に失敗しました",
|
||||
@@ -104,6 +110,9 @@
|
||||
},
|
||||
"clear_search": "検索をクリア",
|
||||
"vacation_active": "不在応答が有効です",
|
||||
"demo_banner": "デモモード",
|
||||
"demo_reset": "リセット",
|
||||
"demo_tour": "ツアー",
|
||||
"tags": "タグ",
|
||||
"mail": "メール",
|
||||
"nav_label": "ナビゲーション",
|
||||
@@ -137,7 +146,8 @@
|
||||
"show_all": "すべて",
|
||||
"no_icons_found": "アイコンが見つかりません",
|
||||
"inline_badge": "埋め込み",
|
||||
"tab_badge": "タブ"
|
||||
"tab_badge": "タブ",
|
||||
"show_on_mobile": "モバイルで表示"
|
||||
},
|
||||
"email_list": {
|
||||
"no_emails": "メッセージが見つかりません",
|
||||
@@ -682,7 +692,9 @@
|
||||
"delete": "キーワードを削除",
|
||||
"save": "保存",
|
||||
"add": "追加",
|
||||
"cancel": "キャンセル"
|
||||
"cancel": "キャンセル",
|
||||
"migrating": "既存のメールでキーワードを更新中…",
|
||||
"migration_error": "既存のメールでのキーワード更新に失敗しました"
|
||||
},
|
||||
"language_region": {
|
||||
"title": "言語と地域",
|
||||
@@ -797,6 +809,17 @@
|
||||
"close": "閉じる",
|
||||
"invalid_email": "有効なメールアドレスを入力してください",
|
||||
"already_added": "この送信者はすでに信頼されています"
|
||||
},
|
||||
"hover_actions": {
|
||||
"label": "ホバークイックアクション",
|
||||
"description": "リスト内のメールにカーソルを合わせたときに表示するクイックアクションを選択",
|
||||
"delete": "削除",
|
||||
"star": "スター付け / 解除",
|
||||
"mark_read": "既読 / 未読にする",
|
||||
"archive": "アーカイブ",
|
||||
"tag": "タグ",
|
||||
"spam": "スパムとしてマーク",
|
||||
"none_selected": "アクションが選択されていません"
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
@@ -847,6 +870,9 @@
|
||||
"account": {
|
||||
"title": "アカウント",
|
||||
"description": "アカウント情報を表示",
|
||||
"name_label": "表示名",
|
||||
"account_type_label": "アカウントタイプ",
|
||||
"demo_account": "デモアカウント",
|
||||
"email": {
|
||||
"label": "メールアドレス",
|
||||
"value": "{email}"
|
||||
@@ -1157,6 +1183,12 @@
|
||||
"opaque_warning": "このスクリプトはビジュアルビルダーの外部で編集されました。Sieveスクリプトの直接編集のみ可能です。",
|
||||
"open_sieve_editor": "Sieveスクリプトエディタを開く",
|
||||
"fetch_error": "フィルターの読み込みに失敗しました",
|
||||
"expanded_view": "詳細表示",
|
||||
"expanded_view_description": "フィルタールールを条件とアクションのブロックで表示",
|
||||
"if": "条件",
|
||||
"then": "実行",
|
||||
"match_all_conditions": "すべて一致",
|
||||
"match_any_condition": "いずれか一致",
|
||||
"and": "かつ",
|
||||
"or": "または",
|
||||
"cancel": "キャンセル",
|
||||
@@ -1470,6 +1502,9 @@
|
||||
"title": "連絡先",
|
||||
"search_placeholder": "連絡先を検索...",
|
||||
"create_new": "新しい連絡先",
|
||||
"no_category": "カテゴリなし",
|
||||
"category_added": "{name} に連絡先を追加しました",
|
||||
"category_added_plural": "{count} 件の連絡先を {name} に追加しました",
|
||||
"empty_state": "連絡先がありません",
|
||||
"empty_state_title": "連絡先がありません",
|
||||
"empty_state_subtitle": "最初の連絡先を作成するか、vCardファイルからインポートしてください",
|
||||
@@ -1613,7 +1648,8 @@
|
||||
"level_low": "低",
|
||||
"categories": "カテゴリー",
|
||||
"categories_placeholder": "例:家族、友人、同僚",
|
||||
"categories_hint": "カンマで区切ってください",
|
||||
"categories_hint": "検索または追加するには入力",
|
||||
"category_add": "追加",
|
||||
"note": "メモ",
|
||||
"note_placeholder": "メモを追加...",
|
||||
"gender": "性別",
|
||||
@@ -1826,7 +1862,11 @@
|
||||
"notification_sound": "通知音",
|
||||
"notification_sound_desc": "カレンダーアラートの音を鳴らす",
|
||||
"invitation_parsing": "メール招待を解析する",
|
||||
"invitation_parsing_desc": "メール添付のカレンダー招待を検出してカレンダー操作を表示する"
|
||||
"invitation_parsing_desc": "メール添付のカレンダー招待を検出してカレンダー操作を表示する",
|
||||
"show_time_in_month_view": "月表示で時刻を表示",
|
||||
"show_time_in_month_view_desc": "月カレンダー表示でイベントの時刻を表示する",
|
||||
"show_week_numbers": "週番号を表示",
|
||||
"show_week_numbers_desc": "ミニカレンダーに週番号を表示する"
|
||||
},
|
||||
"days": {
|
||||
"monday": "月曜日",
|
||||
@@ -2001,7 +2041,17 @@
|
||||
"tip_settings": "設定でカスタマイズできます",
|
||||
"got_it": "了解",
|
||||
"settings": "設定",
|
||||
"dismiss": "閉じる"
|
||||
"dismiss": "閉じる",
|
||||
"start_tour": "ツアーを開始"
|
||||
},
|
||||
"demo_welcome": {
|
||||
"title": "Bulwark Mail へようこそ",
|
||||
"description": "フル機能のウェブメールクライアントをブラウザで体験できます。すべてのデータはお使いのデバイスに保存されるので、自由にお試しください。",
|
||||
"feature_email": "メールの読み書き",
|
||||
"feature_organize": "タグ・スター・フォルダ",
|
||||
"feature_shortcuts": "キーボードショートカット",
|
||||
"feature_privacy": "100% プライベートなデモ",
|
||||
"hint": "左のメールをクリックして始めるか、ツアーを開始してください。"
|
||||
},
|
||||
"files": {
|
||||
"title": "ファイル",
|
||||
@@ -2027,7 +2077,7 @@
|
||||
"file": "ファイル",
|
||||
"parent_directory": "親ディレクトリ",
|
||||
"breadcrumb_root": "ホーム",
|
||||
"drop_files_here": "ここにファイルをドロップしてアップロード",
|
||||
"drop_files_here": "ここにファイルまたはフォルダをドロップしてアップロード",
|
||||
"uploading": "アップロード中...",
|
||||
"upload_success": "{count, plural, other {#件のファイルをアップロードしました}}",
|
||||
"upload_error": "ファイルのアップロードに失敗しました",
|
||||
@@ -2181,5 +2231,54 @@
|
||||
"export_passphrase_desc": "エクスポートする PKCS#12 ファイルを保護するパスフレーズを選択してください",
|
||||
"export_storage_desc": "エクスポートのために鍵を復号する保存用パスフレーズを入力してください",
|
||||
"incorrect_passphrase": "パスフレーズが正しくありません"
|
||||
},
|
||||
"tour": {
|
||||
"step_counter": "ステップ {current} / {total}",
|
||||
"skip": "ツアーをスキップ",
|
||||
"back": "戻る",
|
||||
"next": "次へ",
|
||||
"finish": "完了",
|
||||
"take_a_tour": "インターフェースのツアーを見る",
|
||||
"restart_title": "紹介ツアー",
|
||||
"restart_desc": "インターフェースのガイドツアーを再生する",
|
||||
"restart_button": "ツアーを再開",
|
||||
"sidebar_title": "メールボックス",
|
||||
"sidebar_desc": "フォルダーサイドバーです。メールボックスをクリックしてメールを表示できます。フォルダーの作成、メールのドラッグ移動、未読数の確認ができます。",
|
||||
"compose_title": "メールを作成",
|
||||
"compose_desc": "ここをクリックして新しいメールを作成します。宛先、添付ファイルの追加やリッチテキスト書式が使えます。",
|
||||
"search_title": "メールを検索",
|
||||
"search_desc": "送信者、件名、内容で検索できます。フィルターアイコンをクリックすると、日付範囲、添付ファイル、スター付きメッセージなどの詳細オプションが使えます。",
|
||||
"email_list_title": "メール一覧",
|
||||
"email_list_desc": "メールがここに表示されます。クリックすると右側で読めます。チェックボックスで複数選択し、一括で移動、削除、タグ付けができます。",
|
||||
"email_viewer_title": "閲覧パネル",
|
||||
"email_viewer_desc": "選択したメールがここに開きます。ツールバーのボタンで返信、転送、アーカイブ、削除ができます。メールにスターやカラータグも付けられます。",
|
||||
"keywords_title": "カラータグ",
|
||||
"keywords_desc": "色分けされたタグでメールを整理できます。メールをタグにドラッグしてラベル付けするか、右クリックでタグを割り当てられます。",
|
||||
"calendar_title": "カレンダー",
|
||||
"calendar_desc": "カレンダーに切り替えてイベントを管理できます。イベントの作成、リマインダーの設定、日・週・月表示の切り替えができます。",
|
||||
"contacts_title": "連絡先",
|
||||
"contacts_desc": "アドレス帳がここにあります。連絡先のインポート、グループの作成、連絡先をクリックして詳細を確認できます。",
|
||||
"settings_title": "設定",
|
||||
"settings_desc": "すべてをカスタマイズできます:テーマ、表示密度、署名、フィルター、キーボードショートカット、カレンダー設定など。",
|
||||
"shortcuts_title": "キーボードショートカット",
|
||||
"shortcuts_desc": "パワーユーザー向けの機能です。いつでも ? を押すと利用可能なすべてのショートカットが表示されます。マウスを使わずにナビゲーション、作成、メール管理ができます。",
|
||||
"compose_open_title": "メール作成画面",
|
||||
"compose_open_desc": "これはメール作成画面です。宛先を追加し、メッセージを書き、ファイルを添付し、リッチテキスト書式を使用できます。下書きの保存やテンプレートの使用も可能です。",
|
||||
"calendar_view_title": "カレンダー表示",
|
||||
"calendar_view_desc": "サンプルイベント付きのカレンダーです。ツールバーで日、週、月、アジェンダビューを切り替えられます。",
|
||||
"create_event_title": "イベントを作成",
|
||||
"create_event_desc": "このボタンをクリックして新しいカレンダーイベントを作成します。タイトル、日付、時間を設定し、参加者を追加できます。",
|
||||
"event_modal_title": "イベント詳細",
|
||||
"event_modal_desc": "イベントフォームです。タイトルを入力し、日時を選択し、場所や参加者を追加してください。完了したら保存をクリック — または閉じて次に進みましょう。",
|
||||
"contacts_list_title": "連絡先一覧",
|
||||
"contacts_list_desc": "連絡先の一覧です。連絡先をクリックすると右側に詳細が表示されます。新しい連絡先の作成、vCardのインポート、グループへの整理もできます。",
|
||||
"files_title": "ファイルストレージ",
|
||||
"settings_tabs_title": "設定メニュー",
|
||||
"settings_tabs_desc": "すべての設定カテゴリがここにあります。外観のカスタマイズ、IDの管理、メールフィルターの設定、カレンダーの構成など、多数の項目を調整できます。",
|
||||
"files_desc": "ファイルブラウザでファイルのアップロード、整理、共有ができます。メールに統合されたパーソナルクラウドのようなものです。",
|
||||
"demo_banner_title": "デモコントロール",
|
||||
"demo_banner_desc": "デモモードです。すべてブラウザ内に保存されます。「デモをリセット」をクリックすると、いつでもクリーンなサンプルデータで再開できます。",
|
||||
"quota_title": "ストレージ使用量",
|
||||
"quota_desc": "メールボックスのサイズをここで確認できます。使用量が増えるとサークルが満たされます。"
|
||||
}
|
||||
}
|
||||
|
||||
+105
-6
@@ -40,6 +40,12 @@
|
||||
"website": "Website",
|
||||
"imprint": "Colofon",
|
||||
"privacy_policy": "Privacybeleid",
|
||||
"try_demo": "Demo proberen",
|
||||
"demo_description": "Verken met voorbeeldgegevens — geen account nodig",
|
||||
"demo_launching": "Demo starten...",
|
||||
"demo_login_button": "Demo starten",
|
||||
"demo_tagline": "Ervaar een complete e-mailclient. Geen account nodig.",
|
||||
"demo_no_signup": "Geen registratie nodig — verken vrij met voorbeeldgegevens",
|
||||
"oauth_completing": "Aanmelding voltooien...",
|
||||
"oauth_error": {
|
||||
"title": "Authenticatie mislukt",
|
||||
@@ -104,6 +110,9 @@
|
||||
},
|
||||
"clear_search": "Zoekopdracht wissen",
|
||||
"vacation_active": "Afwezigheidsmelder is actief",
|
||||
"demo_banner": "Demomodus",
|
||||
"demo_reset": "Resetten",
|
||||
"demo_tour": "Rondleiding",
|
||||
"tags": "Labels",
|
||||
"mail": "E-mail",
|
||||
"nav_label": "Navigatie",
|
||||
@@ -137,7 +146,8 @@
|
||||
"show_all": "Alle",
|
||||
"no_icons_found": "Geen pictogrammen gevonden",
|
||||
"inline_badge": "Ingesloten",
|
||||
"tab_badge": "Tabblad"
|
||||
"tab_badge": "Tabblad",
|
||||
"show_on_mobile": "Weergeven op mobiel"
|
||||
},
|
||||
"email_list": {
|
||||
"no_emails": "Geen berichten gevonden",
|
||||
@@ -682,7 +692,9 @@
|
||||
"delete": "Trefwoord verwijderen",
|
||||
"save": "Opslaan",
|
||||
"add": "Toevoegen",
|
||||
"cancel": "Annuleren"
|
||||
"cancel": "Annuleren",
|
||||
"migrating": "Trefwoord bijwerken op bestaande e-mails…",
|
||||
"migration_error": "Kan trefwoord niet bijwerken op bestaande e-mails"
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Taal & Regio",
|
||||
@@ -797,6 +809,17 @@
|
||||
"close": "Sluiten",
|
||||
"invalid_email": "Voer een geldig e-mailadres in",
|
||||
"already_added": "Deze afzender wordt al vertrouwd"
|
||||
},
|
||||
"hover_actions": {
|
||||
"label": "Snelle hover-acties",
|
||||
"description": "Kies welke snelle acties verschijnen wanneer u over een e-mail in de lijst beweegt",
|
||||
"delete": "Verwijderen",
|
||||
"star": "Ster aan / uit",
|
||||
"mark_read": "Markeer gelezen / ongelezen",
|
||||
"archive": "Archiveren",
|
||||
"tag": "Label",
|
||||
"spam": "Markeer als spam",
|
||||
"none_selected": "Geen acties geselecteerd"
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
@@ -847,6 +870,9 @@
|
||||
"account": {
|
||||
"title": "Account",
|
||||
"description": "Bekijk je accountinformatie",
|
||||
"name_label": "Weergavenaam",
|
||||
"account_type_label": "Accounttype",
|
||||
"demo_account": "Demoaccount",
|
||||
"email": {
|
||||
"label": "E-mailadres",
|
||||
"value": "{email}"
|
||||
@@ -1157,6 +1183,12 @@
|
||||
"opaque_warning": "Dit script is buiten de visuele builder bewerkt. Alleen Sieve-scriptbewerking is beschikbaar.",
|
||||
"open_sieve_editor": "Sieve-scripteditor openen",
|
||||
"fetch_error": "Filters konden niet worden geladen",
|
||||
"expanded_view": "Uitgebreide weergave",
|
||||
"expanded_view_description": "Filterregels weergeven met gedetailleerde voorwaarde- en actieblokken",
|
||||
"if": "Als",
|
||||
"then": "Dan",
|
||||
"match_all_conditions": "alle overeenkomen",
|
||||
"match_any_condition": "een overeenkomt",
|
||||
"and": "en",
|
||||
"or": "of",
|
||||
"cancel": "Annuleren",
|
||||
@@ -1470,6 +1502,9 @@
|
||||
"title": "Contacten",
|
||||
"search_placeholder": "Contacten zoeken...",
|
||||
"create_new": "Nieuw contact",
|
||||
"no_category": "Geen categorie",
|
||||
"category_added": "Contact toegevoegd aan {name}",
|
||||
"category_added_plural": "{count} contacten toegevoegd aan {name}",
|
||||
"empty_state": "Geen contacten",
|
||||
"empty_state_title": "Geen contacten",
|
||||
"empty_state_subtitle": "Maak uw eerste contact aan of importeer vanuit een vCard-bestand",
|
||||
@@ -1613,7 +1648,8 @@
|
||||
"level_low": "Laag",
|
||||
"categories": "Categorieën",
|
||||
"categories_placeholder": "bijv. Familie, Vrienden, Collega's",
|
||||
"categories_hint": "Scheiden met komma's",
|
||||
"categories_hint": "Typ om te zoeken of toe te voegen",
|
||||
"category_add": "Toevoegen",
|
||||
"note": "Notities",
|
||||
"note_placeholder": "Notitie toevoegen...",
|
||||
"gender": "Geslacht",
|
||||
@@ -1826,7 +1862,11 @@
|
||||
"notification_sound": "Meldingsgeluid",
|
||||
"notification_sound_desc": "Geluid afspelen voor agendameldingen",
|
||||
"invitation_parsing": "E-mailuitnodigingen verwerken",
|
||||
"invitation_parsing_desc": "Kalenderuitnodigingen in e-mailbijlagen detecteren en kalenderacties tonen"
|
||||
"invitation_parsing_desc": "Kalenderuitnodigingen in e-mailbijlagen detecteren en kalenderacties tonen",
|
||||
"show_time_in_month_view": "Tijd weergeven in maandweergave",
|
||||
"show_time_in_month_view_desc": "Evenementtijden weergeven in de maandkalenderweergave",
|
||||
"show_week_numbers": "Weeknummers weergeven",
|
||||
"show_week_numbers_desc": "Weeknummers weergeven in de minikalender"
|
||||
},
|
||||
"days": {
|
||||
"monday": "Maandag",
|
||||
@@ -2001,7 +2041,17 @@
|
||||
"tip_settings": "Pas uw ervaring aan in Instellingen",
|
||||
"got_it": "Begrepen",
|
||||
"settings": "Instellingen",
|
||||
"dismiss": "Sluiten"
|
||||
"dismiss": "Sluiten",
|
||||
"start_tour": "Tour starten"
|
||||
},
|
||||
"demo_welcome": {
|
||||
"title": "Welkom bij Bulwark Mail",
|
||||
"description": "Ontdek een volwaardige webmail-client — rechtstreeks in je browser. Alle gegevens blijven op je apparaat, dus test gerust alles.",
|
||||
"feature_email": "E-mails lezen en schrijven",
|
||||
"feature_organize": "Tags, sterren en mappen",
|
||||
"feature_shortcuts": "Sneltoetsen",
|
||||
"feature_privacy": "100% privédemo",
|
||||
"hint": "Klik links op een e-mail om te beginnen, of start de tour."
|
||||
},
|
||||
"files": {
|
||||
"title": "Bestanden",
|
||||
@@ -2027,7 +2077,7 @@
|
||||
"file": "Bestand",
|
||||
"parent_directory": "Bovenliggende map",
|
||||
"breadcrumb_root": "Start",
|
||||
"drop_files_here": "Sleep bestanden hierheen om te uploaden",
|
||||
"drop_files_here": "Sleep bestanden of mappen hierheen om te uploaden",
|
||||
"uploading": "Uploaden...",
|
||||
"upload_success": "{count, plural, one {1 bestand geüpload} other {# bestanden geüpload}}",
|
||||
"upload_error": "Bestand uploaden mislukt",
|
||||
@@ -2181,5 +2231,54 @@
|
||||
"export_passphrase_desc": "Kies een wachtwoordzin om het geëxporteerde PKCS#12-bestand te beschermen",
|
||||
"export_storage_desc": "Voer de opslagwachtwoordzin in om de sleutel voor export te ontsleutelen",
|
||||
"incorrect_passphrase": "Onjuiste wachtwoordzin"
|
||||
},
|
||||
"tour": {
|
||||
"step_counter": "Stap {current} van {total}",
|
||||
"skip": "Tour overslaan",
|
||||
"back": "Terug",
|
||||
"next": "Volgende",
|
||||
"finish": "Voltooien",
|
||||
"take_a_tour": "Maak een rondleiding door de interface",
|
||||
"restart_title": "Introductietour",
|
||||
"restart_desc": "Bekijk de rondleiding door de interface opnieuw",
|
||||
"restart_button": "Tour herstarten",
|
||||
"sidebar_title": "Uw mailboxen",
|
||||
"sidebar_desc": "Dit is uw mappenbalk. Klik op een mailbox om de e-mails te bekijken. U kunt mappen maken, e-mails tussen mappen slepen en ongelezen aantallen zien.",
|
||||
"compose_title": "E-mail schrijven",
|
||||
"compose_desc": "Klik hier om een nieuwe e-mail te schrijven. U kunt ontvangers, bijlagen toevoegen en rijke tekstopmaak gebruiken.",
|
||||
"search_title": "Zoek in uw mail",
|
||||
"search_desc": "Zoek op afzender, onderwerp of inhoud. Klik op het filtericoon voor geavanceerde opties zoals datumbereik, bijlagen en favoriete berichten.",
|
||||
"email_list_title": "Uw e-maillijst",
|
||||
"email_list_desc": "E-mails verschijnen hier. Klik op een e-mail om deze rechts te lezen. Gebruik het selectievakje om meerdere te selecteren, verplaats, verwijder of label ze vervolgens.",
|
||||
"email_viewer_title": "Leesvenster",
|
||||
"email_viewer_desc": "De geselecteerde e-mail opent hier. Beantwoord, doorstuur, archiveer of verwijder met de werkbalkknopen. U kunt ook e-mails markeren of kleurlabels toevoegen.",
|
||||
"keywords_title": "Kleurlabels",
|
||||
"keywords_desc": "Organiseer uw e-mail met kleurgecodeerde labels. Sleep een e-mail naar een label om het te markeren, of klik met de rechtermuisknop om labels toe te wijzen.",
|
||||
"calendar_title": "Agenda",
|
||||
"calendar_desc": "Schakel naar de agenda om uw evenementen te beheren. Maak evenementen aan, stel herinneringen in en bekijk dag-, week- of maandweergaven.",
|
||||
"contacts_title": "Contacten",
|
||||
"contacts_desc": "Uw adresboek bevindt zich hier. Importeer contacten, maak groepen aan en klik op een contact om de volledige details te bekijken.",
|
||||
"settings_title": "Instellingen",
|
||||
"settings_desc": "Pas alles aan: thema, dichtheid, handtekeningen, filters, sneltoetsen, agendainstellingen en meer.",
|
||||
"shortcuts_title": "Sneltoetsen",
|
||||
"shortcuts_desc": "Ervaren gebruikers zijn hier dol op. Druk op ? om alle beschikbare sneltoetsen te bekijken. Navigeer, schrijf en beheer e-mails zonder de muis aan te raken.",
|
||||
"compose_open_title": "De e-maileditor",
|
||||
"compose_open_desc": "Dit is de e-maileditor. Voeg ontvangers toe, schrijf uw bericht, voeg bestanden bij en gebruik rijke tekstopmaak. U kunt ook concepten opslaan en sjablonen gebruiken.",
|
||||
"calendar_view_title": "Uw agenda",
|
||||
"calendar_view_desc": "Hier is uw agenda met voorbeeldevenementen. Schakel tussen dag-, week-, maand- en agendaweergave via de werkbalk.",
|
||||
"create_event_title": "Evenement aanmaken",
|
||||
"create_event_desc": "Klik op deze knop om een nieuw agenda-evenement aan te maken. U kunt een titel, datum, tijd en deelnemers instellen.",
|
||||
"event_modal_title": "Evenementdetails",
|
||||
"event_modal_desc": "Hier is het evenementformulier. Vul de titel in, kies een datum en tijd, voeg een locatie of deelnemers toe. Klik op opslaan als u klaar bent — of sluit het en ga verder.",
|
||||
"contacts_list_title": "Uw contacten",
|
||||
"contacts_list_desc": "Hier zijn uw contacten. Klik op een contact om de details rechts te bekijken. U kunt ook nieuwe contacten aanmaken, vCards importeren of contacten in groepen organiseren.",
|
||||
"files_title": "Bestandsopslag",
|
||||
"settings_tabs_title": "Instellingenmenu",
|
||||
"settings_tabs_desc": "Hier vindt u alle instellingscategorieën. Pas het uiterlijk aan, beheer identiteiten, stel e-mailfilters in, configureer uw agenda en nog veel meer.",
|
||||
"files_desc": "Uw bestandsbrowser laat u bestanden uploaden, organiseren en delen — als een persoonlijke cloud geïntegreerd in uw mail.",
|
||||
"demo_banner_title": "Demo-bediening",
|
||||
"demo_banner_desc": "U bent in demomodus — alles blijft in uw browser. Klik op 'Demo resetten' om opnieuw te beginnen met schone voorbeeldgegevens.",
|
||||
"quota_title": "Opslaggebruik",
|
||||
"quota_desc": "Volg de grootte van uw mailbox hier. De cirkel vult zich naarmate u meer ruimte gebruikt."
|
||||
}
|
||||
}
|
||||
|
||||
+105
-6
@@ -40,6 +40,12 @@
|
||||
"website": "Site",
|
||||
"imprint": "Informações legais",
|
||||
"privacy_policy": "Política de privacidade",
|
||||
"try_demo": "Experimentar demo",
|
||||
"demo_description": "Explore com dados de exemplo — sem conta necessária",
|
||||
"demo_launching": "Iniciando demo...",
|
||||
"demo_login_button": "Iniciar demo",
|
||||
"demo_tagline": "Experimente um cliente de e-mail completo. Sem necessidade de conta.",
|
||||
"demo_no_signup": "Sem registo — explore livremente com dados de exemplo",
|
||||
"oauth_completing": "Concluindo login...",
|
||||
"oauth_error": {
|
||||
"title": "Falha na autenticação",
|
||||
@@ -104,6 +110,9 @@
|
||||
},
|
||||
"clear_search": "Limpar busca",
|
||||
"vacation_active": "Resposta automática ativa",
|
||||
"demo_banner": "Modo de demonstração",
|
||||
"demo_reset": "Repor",
|
||||
"demo_tour": "Tour",
|
||||
"tags": "Etiquetas",
|
||||
"mail": "E-mail",
|
||||
"nav_label": "Navegação",
|
||||
@@ -137,7 +146,8 @@
|
||||
"show_all": "Todos",
|
||||
"no_icons_found": "Nenhum ícone encontrado",
|
||||
"inline_badge": "Integrado",
|
||||
"tab_badge": "Aba"
|
||||
"tab_badge": "Aba",
|
||||
"show_on_mobile": "Mostrar no celular"
|
||||
},
|
||||
"email_list": {
|
||||
"no_emails": "Nenhuma mensagem encontrada",
|
||||
@@ -682,7 +692,9 @@
|
||||
"delete": "Excluir palavra-chave",
|
||||
"save": "Salvar",
|
||||
"add": "Adicionar",
|
||||
"cancel": "Cancelar"
|
||||
"cancel": "Cancelar",
|
||||
"migrating": "Atualizando etiqueta nos e-mails existentes…",
|
||||
"migration_error": "Falha ao atualizar etiqueta nos e-mails existentes"
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Idioma e Região",
|
||||
@@ -797,6 +809,17 @@
|
||||
"close": "Fechar",
|
||||
"invalid_email": "Por favor, digite um endereço de e-mail válido",
|
||||
"already_added": "Este remetente já é confiável"
|
||||
},
|
||||
"hover_actions": {
|
||||
"label": "Ações rápidas ao passar o mouse",
|
||||
"description": "Escolha quais ações rápidas aparecem ao passar o mouse sobre um e-mail na lista",
|
||||
"delete": "Excluir",
|
||||
"star": "Favoritar / Desfavoritar",
|
||||
"mark_read": "Marcar como lido / não lido",
|
||||
"archive": "Arquivar",
|
||||
"tag": "Etiqueta",
|
||||
"spam": "Marcar como spam",
|
||||
"none_selected": "Nenhuma ação selecionada"
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
@@ -847,6 +870,9 @@
|
||||
"account": {
|
||||
"title": "Conta",
|
||||
"description": "Visualize as informações da sua conta",
|
||||
"name_label": "Nome de exibição",
|
||||
"account_type_label": "Tipo de conta",
|
||||
"demo_account": "Conta de demonstração",
|
||||
"email": {
|
||||
"label": "Endereço de E-mail",
|
||||
"value": "{email}"
|
||||
@@ -1157,6 +1183,12 @@
|
||||
"opaque_warning": "Este script foi editado fora do construtor visual. Apenas a edição Sieve bruta está disponível.",
|
||||
"open_sieve_editor": "Abrir editor Sieve",
|
||||
"fetch_error": "Falha ao carregar filtros",
|
||||
"expanded_view": "Vista expandida",
|
||||
"expanded_view_description": "Mostrar regras de filtro com blocos detalhados de condições e ações",
|
||||
"if": "Se",
|
||||
"then": "Então",
|
||||
"match_all_conditions": "todas correspondem",
|
||||
"match_any_condition": "uma corresponde",
|
||||
"and": "e",
|
||||
"or": "ou",
|
||||
"cancel": "Cancelar",
|
||||
@@ -1470,6 +1502,9 @@
|
||||
"title": "Contatos",
|
||||
"search_placeholder": "Pesquisar contatos...",
|
||||
"create_new": "Novo contato",
|
||||
"no_category": "Sem categoria",
|
||||
"category_added": "Contato adicionado a {name}",
|
||||
"category_added_plural": "{count} contatos adicionados a {name}",
|
||||
"empty_state": "Nenhum contato",
|
||||
"empty_state_title": "Sem contatos",
|
||||
"empty_state_subtitle": "Crie seu primeiro contato ou importe de um arquivo vCard",
|
||||
@@ -1613,7 +1648,8 @@
|
||||
"level_low": "Baixo",
|
||||
"categories": "Categorias",
|
||||
"categories_placeholder": "ex., Família, Amigos, Colegas",
|
||||
"categories_hint": "Separar com vírgulas",
|
||||
"categories_hint": "Digite para pesquisar ou adicionar",
|
||||
"category_add": "Adicionar",
|
||||
"note": "Notas",
|
||||
"note_placeholder": "Adicionar uma nota...",
|
||||
"gender": "Gênero",
|
||||
@@ -1826,7 +1862,11 @@
|
||||
"notification_sound": "Som de notificação",
|
||||
"notification_sound_desc": "Reproduzir um som para alertas do calendário",
|
||||
"invitation_parsing": "Analisar convites por e-mail",
|
||||
"invitation_parsing_desc": "Detectar convites de calendário em anexos de e-mail e mostrar ações do calendário"
|
||||
"invitation_parsing_desc": "Detectar convites de calendário em anexos de e-mail e mostrar ações do calendário",
|
||||
"show_time_in_month_view": "Mostrar horário na visualização mensal",
|
||||
"show_time_in_month_view_desc": "Exibir horários dos eventos na visualização mensal do calendário",
|
||||
"show_week_numbers": "Mostrar números da semana",
|
||||
"show_week_numbers_desc": "Exibir números da semana no minicalendário"
|
||||
},
|
||||
"days": {
|
||||
"monday": "Segunda-feira",
|
||||
@@ -2001,7 +2041,17 @@
|
||||
"tip_settings": "Personalize sua experiência nas Configurações",
|
||||
"got_it": "Entendi",
|
||||
"settings": "Configurações",
|
||||
"dismiss": "Fechar"
|
||||
"dismiss": "Fechar",
|
||||
"start_tour": "Iniciar tour"
|
||||
},
|
||||
"demo_welcome": {
|
||||
"title": "Bem-vindo ao Bulwark Mail",
|
||||
"description": "Explore um cliente de webmail completo — diretamente no seu navegador. Todos os dados ficam no seu dispositivo, então teste tudo.",
|
||||
"feature_email": "Ler e escrever e-mails",
|
||||
"feature_organize": "Tags, estrelas e pastas",
|
||||
"feature_shortcuts": "Atalhos de teclado",
|
||||
"feature_privacy": "Demo 100% privada",
|
||||
"hint": "Clique num e-mail à esquerda para começar, ou inicie o tour."
|
||||
},
|
||||
"files": {
|
||||
"title": "Ficheiros",
|
||||
@@ -2027,7 +2077,7 @@
|
||||
"file": "Ficheiro",
|
||||
"parent_directory": "Diretório superior",
|
||||
"breadcrumb_root": "Início",
|
||||
"drop_files_here": "Largue os ficheiros aqui para carregar",
|
||||
"drop_files_here": "Largue ficheiros ou pastas aqui para carregar",
|
||||
"uploading": "A carregar...",
|
||||
"upload_success": "{count, plural, one {1 ficheiro carregado} other {# ficheiros carregados}}",
|
||||
"upload_error": "Falha ao carregar o ficheiro",
|
||||
@@ -2181,5 +2231,54 @@
|
||||
"export_passphrase_desc": "Escolha uma frase secreta para proteger o arquivo PKCS#12 exportado",
|
||||
"export_storage_desc": "Insira a frase secreta de armazenamento para descriptografar a chave para exportação",
|
||||
"incorrect_passphrase": "Frase secreta incorreta"
|
||||
},
|
||||
"tour": {
|
||||
"step_counter": "Passo {current} de {total}",
|
||||
"skip": "Pular tour",
|
||||
"back": "Voltar",
|
||||
"next": "Próximo",
|
||||
"finish": "Finalizar",
|
||||
"take_a_tour": "Faça um tour pela interface",
|
||||
"restart_title": "Tour introdutório",
|
||||
"restart_desc": "Rever o tour guiado da interface",
|
||||
"restart_button": "Reiniciar tour",
|
||||
"sidebar_title": "Suas caixas de correio",
|
||||
"sidebar_desc": "Esta é a barra lateral de pastas. Clique em qualquer caixa para ver seus e-mails. Você pode criar pastas, arrastar e-mails entre elas e ver contadores de não lidos.",
|
||||
"compose_title": "Escrever um e-mail",
|
||||
"compose_desc": "Clique aqui para escrever um novo e-mail. Você pode adicionar destinatários, anexos e usar formatação de texto enriquecido.",
|
||||
"search_title": "Pesquisar sua caixa",
|
||||
"search_desc": "Pesquise por remetente, assunto ou conteúdo. Clique no ícone de filtro para opções avançadas como intervalo de datas, anexos e mensagens com estrela.",
|
||||
"email_list_title": "Sua lista de e-mails",
|
||||
"email_list_desc": "Os e-mails aparecem aqui. Clique em um para lê-lo à direita. Use a caixa de seleção para selecionar vários, depois mova, exclua ou etiquete em massa.",
|
||||
"email_viewer_title": "Painel de leitura",
|
||||
"email_viewer_desc": "O e-mail selecionado abre aqui. Responda, encaminhe, arquive ou exclua com os botões da barra de ferramentas. Você também pode marcar e-mails com estrela ou adicionar etiquetas coloridas.",
|
||||
"keywords_title": "Etiquetas coloridas",
|
||||
"keywords_desc": "Organize seus e-mails com etiquetas coloridas. Arraste um e-mail para uma etiqueta para marcá-lo, ou clique com o botão direito para atribuir etiquetas.",
|
||||
"calendar_title": "Calendário",
|
||||
"calendar_desc": "Mude para o calendário para gerenciar seus eventos. Crie eventos, defina lembretes e visualize os layouts de dia, semana ou mês.",
|
||||
"contacts_title": "Contatos",
|
||||
"contacts_desc": "Seu livro de endereços fica aqui. Importe contatos, crie grupos e clique em qualquer contato para ver seus detalhes completos.",
|
||||
"settings_title": "Configurações",
|
||||
"settings_desc": "Personalize tudo: tema, densidade, assinaturas, filtros, atalhos de teclado, padrões do calendário e mais.",
|
||||
"shortcuts_title": "Atalhos de teclado",
|
||||
"shortcuts_desc": "Usuários avançados adoram isso. Pressione ? a qualquer momento para ver todos os atalhos disponíveis. Navegue, escreva e gerencie e-mails sem tocar no mouse.",
|
||||
"compose_open_title": "O compositor",
|
||||
"compose_open_desc": "Este é o compositor de e-mail. Adicione destinatários, escreva sua mensagem, anexe arquivos e use formatação de texto rico. Você também pode salvar rascunhos e usar modelos.",
|
||||
"calendar_view_title": "Seu calendário",
|
||||
"calendar_view_desc": "Aqui está seu calendário com eventos de exemplo. Você pode alternar entre as visualizações de dia, semana, mês e agenda usando a barra de ferramentas.",
|
||||
"create_event_title": "Criar um evento",
|
||||
"create_event_desc": "Clique neste botão para criar um novo evento no calendário. Você pode definir um título, data, hora e adicionar participantes.",
|
||||
"event_modal_title": "Detalhes do evento",
|
||||
"event_modal_desc": "Aqui está o formulário do evento. Preencha o título, escolha uma data e hora, adicione um local ou participantes. Clique em salvar quando terminar — ou feche e siga em frente.",
|
||||
"contacts_list_title": "Seus contatos",
|
||||
"contacts_list_desc": "Aqui estão seus contatos. Clique em qualquer contato para ver seus detalhes à direita. Você também pode criar novos contatos, importar vCards ou organizar contatos em grupos.",
|
||||
"files_title": "Armazenamento de ficheiros",
|
||||
"settings_tabs_title": "Menu de configurações",
|
||||
"settings_tabs_desc": "Aqui estão todas as categorias de configurações. Personalize a aparência, gerencie identidades, configure filtros de e-mail, ajuste o calendário e muito mais.",
|
||||
"files_desc": "O navegador de ficheiros permite carregar, organizar e partilhar ficheiros — como uma nuvem pessoal integrada no seu e-mail.",
|
||||
"demo_banner_title": "Controlos de demonstração",
|
||||
"demo_banner_desc": "Está no modo de demonstração — tudo permanece no seu navegador. Clique em 'Repor Demonstração' a qualquer momento para recomeçar com dados limpos.",
|
||||
"quota_title": "Utilização do armazenamento",
|
||||
"quota_desc": "Acompanhe o tamanho da sua caixa de correio aqui. O círculo preenche-se à medida que utiliza mais espaço."
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+875
-7
File diff suppressed because it is too large
Load Diff
+11
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.4.5",
|
||||
"version": "1.4.7",
|
||||
"description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server",
|
||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||
"license": "AGPL-3.0-only",
|
||||
@@ -33,6 +33,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"@tiptap/extension-color": "^3.20.4",
|
||||
"@tiptap/extension-image": "^3.20.4",
|
||||
"@tiptap/extension-link": "^3.20.4",
|
||||
"@tiptap/extension-placeholder": "^3.20.4",
|
||||
"@tiptap/extension-text-align": "^3.20.4",
|
||||
"@tiptap/extension-text-style": "^3.20.4",
|
||||
"@tiptap/extension-underline": "^3.20.4",
|
||||
"@tiptap/pm": "^3.20.4",
|
||||
"@tiptap/react": "^3.20.4",
|
||||
"@tiptap/starter-kit": "^3.20.4",
|
||||
"asn1js": "^3.0.7",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user