diff --git a/.gitignore b/.gitignore index ef0b4f23..8a20df4d 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,7 @@ next-env.d.ts *.test.yml .claude + +# serwist (auto-generated service worker) +public/sw* +public/swe-worker* diff --git a/Dockerfile b/Dockerfile index 5b4b7a38..0ffc01e0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,7 @@ WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 -RUN apk add --no-cache su-exec git +RUN apk add --no-cache su-exec git grep sed RUN if ! getent group 1000 > /dev/null 2>&1; then \ addgroup --system --gid 1000 appgroup; \ diff --git a/README.md b/README.md index 8a9a5fcc..8e4caf5d 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ A self-hosted app for your checklists and notes. - **Customisable:** 14 built-in themes plus custom theme support with custom emojis and icons. - **Encryption:** Full on PGP encryption, read more about it in [howto/ENCRYPTION.md](howto/ENCRYPTION.md) - **API Access:** Programmatic access to your checklists and notes via REST API with authentication. +- **PWA** Jotty doesn't have a native app, but it's built mobile first. Once installed the PWA on your device it will feel like you installed it from the app store. There's also partial offline caching, as long as you visited a page while online Jotty will allow you to re-visit it while offline. At the moment there's no current support for offline CRUD operation. diff --git a/app/(loggedInRoutes)/admin/checklist/[uuid]/page.tsx b/app/(loggedInRoutes)/admin/checklist/[uuid]/page.tsx index 64ca40c9..fcbfc361 100644 --- a/app/(loggedInRoutes)/admin/checklist/[uuid]/page.tsx +++ b/app/(loggedInRoutes)/admin/checklist/[uuid]/page.tsx @@ -14,21 +14,21 @@ import { MetadataProvider } from "@/app/_providers/MetadataProvider"; import { sanitizeUserForClient } from "@/app/_utils/user-sanitize-utils"; interface AdminChecklistPageProps { - params: { + params: Promise<{ uuid: string; - }; + }>; } export const dynamic = "force-dynamic"; -export async function generateMetadata({ - params, -}: AdminChecklistPageProps): Promise { +export async function generateMetadata(props: AdminChecklistPageProps): Promise { + const params = await props.params; const { uuid } = params; return getMedatadaTitle(Modes.CHECKLISTS, uuid, "Admin"); } -export default async function AdminChecklistPage({ params }: AdminChecklistPageProps) { +export default async function AdminChecklistPage(props: AdminChecklistPageProps) { + const params = await props.params; const { uuid } = params; const userRecord = await getCurrentUser(); const hasContentAccess = await canAccessAllContent(); diff --git a/app/(loggedInRoutes)/admin/note/[uuid]/page.tsx b/app/(loggedInRoutes)/admin/note/[uuid]/page.tsx index 9a41a087..666eaf92 100644 --- a/app/(loggedInRoutes)/admin/note/[uuid]/page.tsx +++ b/app/(loggedInRoutes)/admin/note/[uuid]/page.tsx @@ -14,21 +14,21 @@ import { PermissionsProvider } from "@/app/_providers/PermissionsProvider"; import { MetadataProvider } from "@/app/_providers/MetadataProvider"; interface AdminNotePageProps { - params: { + params: Promise<{ uuid: string; - }; + }>; } export const dynamic = "force-dynamic"; -export async function generateMetadata({ - params, -}: AdminNotePageProps): Promise { +export async function generateMetadata(props: AdminNotePageProps): Promise { + const params = await props.params; const { uuid } = params; return getMedatadaTitle(Modes.NOTES, uuid, "Admin"); } -export default async function AdminNotePage({ params }: AdminNotePageProps) { +export default async function AdminNotePage(props: AdminNotePageProps) { + const params = await props.params; const { uuid } = params; const hasContentAccess = await canAccessAllContent(); diff --git a/app/(loggedInRoutes)/checklist/[...categoryPath]/page.tsx b/app/(loggedInRoutes)/checklist/[...categoryPath]/page.tsx index 33a1a937..e2d0847a 100644 --- a/app/(loggedInRoutes)/checklist/[...categoryPath]/page.tsx +++ b/app/(loggedInRoutes)/checklist/[...categoryPath]/page.tsx @@ -16,16 +16,15 @@ import { MetadataProvider } from "@/app/_providers/MetadataProvider"; import { sanitizeUserForClient } from "@/app/_utils/user-sanitize-utils"; interface ChecklistPageProps { - params: { + params: Promise<{ categoryPath: string[]; - }; + }>; } export const dynamic = "force-dynamic"; -export async function generateMetadata({ - params, -}: ChecklistPageProps): Promise { +export async function generateMetadata(props: ChecklistPageProps): Promise { + const params = await props.params; const { categoryPath } = params; const id = decodeId(categoryPath[categoryPath.length - 1]); const encodedCategoryPath = categoryPath.slice(0, -1).join("/"); @@ -37,7 +36,8 @@ export async function generateMetadata({ return getMedatadaTitle(Modes.CHECKLISTS, id, category); } -export default async function ChecklistPage({ params }: ChecklistPageProps) { +export default async function ChecklistPage(props: ChecklistPageProps) { + const params = await props.params; const { categoryPath } = params; const id = decodeId(categoryPath[categoryPath.length - 1]); const encodedCategoryPath = categoryPath.slice(0, -1).join("/"); diff --git a/app/(loggedInRoutes)/howto/[path]/page.tsx b/app/(loggedInRoutes)/howto/[path]/page.tsx index d197c7c5..683e6844 100644 --- a/app/(loggedInRoutes)/howto/[path]/page.tsx +++ b/app/(loggedInRoutes)/howto/[path]/page.tsx @@ -7,16 +7,15 @@ import { getTranslations } from "next-intl/server"; import { getHowtoGuideById, getHowtoFilePath, isValidHowtoGuide } from "@/app/_utils/howto-utils"; interface HowtoPageProps { - params: { + params: Promise<{ path: string; - }; + }>; } export const dynamic = "force-dynamic"; -export async function generateMetadata({ - params, -}: HowtoPageProps): Promise { +export async function generateMetadata(props: HowtoPageProps): Promise { + const params = await props.params; const { path } = params; const t = await getTranslations(); const guide = getHowtoGuideById(path, t); @@ -33,7 +32,8 @@ export async function generateMetadata({ }; } -export default async function HowtoPage({ params }: HowtoPageProps) { +export default async function HowtoPage(props: HowtoPageProps) { + const params = await props.params; const { path } = params; const t = await getTranslations(); diff --git a/app/(loggedInRoutes)/note/[...categoryPath]/page.tsx b/app/(loggedInRoutes)/note/[...categoryPath]/page.tsx index 76b7af42..ff546b52 100644 --- a/app/(loggedInRoutes)/note/[...categoryPath]/page.tsx +++ b/app/(loggedInRoutes)/note/[...categoryPath]/page.tsx @@ -19,16 +19,15 @@ import { PermissionsProvider } from "@/app/_providers/PermissionsProvider"; import { MetadataProvider } from "@/app/_providers/MetadataProvider"; interface NotePageProps { - params: { + params: Promise<{ categoryPath: string[]; - }; + }>; } export const dynamic = "force-dynamic"; -export async function generateMetadata({ - params, -}: NotePageProps): Promise { +export async function generateMetadata(props: NotePageProps): Promise { + const params = await props.params; const { categoryPath } = params; const id = decodeId(categoryPath[categoryPath.length - 1]); const encodedCategoryPath = categoryPath.slice(0, -1).join("/"); @@ -40,7 +39,8 @@ export async function generateMetadata({ return getMedatadaTitle(Modes.NOTES, id, category); } -export default async function NotePage({ params }: NotePageProps) { +export default async function NotePage(props: NotePageProps) { + const params = await props.params; const { categoryPath } = params; const id = decodeId(categoryPath[categoryPath.length - 1]); const encodedCategoryPath = categoryPath.slice(0, -1).join("/"); @@ -55,7 +55,7 @@ export default async function NotePage({ params }: NotePageProps) { await CheckForNeedsMigration(); const [docsResult, categoriesResult] = await Promise.all([ - getUserNotes({ isRaw: true }), + getUserNotes({ isRaw: true, metadataOnly: true }), getCategories(Modes.NOTES), ]); @@ -69,7 +69,7 @@ export default async function NotePage({ params }: NotePageProps) { const allDocsResult = await getAllNotes(); if (allDocsResult.success && allDocsResult.data) { note = allDocsResult.data.find( - (doc) => doc.id === id && doc.category === category + (doc) => doc.id === id && doc.category === category, ); } } diff --git a/app/(loggedInRoutes)/page.tsx b/app/(loggedInRoutes)/page.tsx index 64f37417..c5aa2e5f 100644 --- a/app/(loggedInRoutes)/page.tsx +++ b/app/(loggedInRoutes)/page.tsx @@ -1,11 +1,15 @@ import { getCategories } from "@/app/_server/actions/category"; import { getUserChecklists } from "@/app/_server/actions/checklist"; -import { getUserNotes, CheckForNeedsMigration } from "@/app/_server/actions/note"; +import { + getUserNotes, + CheckForNeedsMigration, +} from "@/app/_server/actions/note"; import { HomeClient } from "@/app/_components/FeatureComponents/Home/HomeClient"; import { getCurrentUser } from "@/app/_server/actions/users"; import { Modes } from "@/app/_types/enums"; import { Checklist, Note } from "../_types"; import { sanitizeUserForClient } from "@/app/_utils/user-sanitize-utils"; +import { HOMEPAGE_ITEMS_LIMIT } from "@/app/_consts/files"; export const dynamic = "force-dynamic"; @@ -14,22 +18,25 @@ export default async function HomePage() { const [listsResult, notesResult, categoriesResult, notesCategoriesResult] = await Promise.all([ - getUserChecklists({ isRaw: true }), - getUserNotes({ isRaw: true }), + getUserChecklists({ limit: HOMEPAGE_ITEMS_LIMIT }), + getUserNotes({ limit: HOMEPAGE_ITEMS_LIMIT }), getCategories(Modes.CHECKLISTS), getCategories(Modes.NOTES), ]); const lists = listsResult.success && listsResult.data ? listsResult.data : []; + const notes = notesResult.success && notesResult.data ? notesResult.data : []; + const categories = categoriesResult.success && categoriesResult.data ? categoriesResult.data : []; - const notes = notesResult.success && notesResult.data ? notesResult.data : []; + const notesCategories = notesCategoriesResult.success && notesCategoriesResult.data ? notesCategoriesResult.data : []; + const user = sanitizeUserForClient(await getCurrentUser()); return ( diff --git a/app/(loggedInRoutes)/settings/connections/page.tsx b/app/(loggedInRoutes)/settings/connections/page.tsx index f81675ac..ce5e490c 100644 --- a/app/(loggedInRoutes)/settings/connections/page.tsx +++ b/app/(loggedInRoutes)/settings/connections/page.tsx @@ -1,13 +1,18 @@ import { LinksTab } from "@/app/_components/FeatureComponents/Profile/Parts/LinksTab"; -import { readLinkIndex, LinkIndex } from "@/app/_server/actions/link"; +import { readLinkIndex } from "@/app/_server/actions/link"; +import { LinkIndex } from "@/app/_types"; import { getUsername } from "@/app/_server/actions/users"; import { getArchivedItems } from "@/app/_server/actions/archived"; +import { getUserNotes } from "@/app/_server/actions/note"; +import { getUserChecklists } from "@/app/_server/actions/checklist"; export default async function ConnectionsPage() { const username = await getUsername(); - const [linkIndex, archivedResult] = await Promise.all([ + const [linkIndex, archivedResult, notesResult, checklistsResult] = await Promise.all([ readLinkIndex(username), getArchivedItems(), + getUserNotes({ username }), + getUserChecklists({ username }), ]); const archivedItems = archivedResult.success ? archivedResult.data : []; @@ -57,6 +62,8 @@ export default async function ConnectionsPage() { }; const filteredLinkIndex = filterArchivedItems(linkIndex, archivedItems || []); + const notes = notesResult.success ? notesResult.data || [] : []; + const checklists = checklistsResult.success ? checklistsResult.data || [] : []; - return ; + return ; } diff --git a/app/(loggedOutRoutes)/auth/login/page.tsx b/app/(loggedOutRoutes)/auth/login/page.tsx index 7886f15b..1e898f19 100644 --- a/app/(loggedOutRoutes)/auth/login/page.tsx +++ b/app/(loggedOutRoutes)/auth/login/page.tsx @@ -4,19 +4,17 @@ import LoginForm from "@/app/_components/GlobalComponents/Auth/LoginForm"; import { AuthShell } from "@/app/_components/GlobalComponents/Auth/AuthShell"; import { getTranslations } from "next-intl/server"; import { SsoOnlyLogin } from "@/app/_components/GlobalComponents/Auth/SsoOnlyLogin"; +import { isEnvEnabled } from "@/app/_utils/env-utils"; export const dynamic = "force-dynamic"; export default async function LoginPage() { const t = await getTranslations("auth"); const ssoEnabled = process.env.SSO_MODE === "oidc"; - const allowLocal = - process.env.SSO_FALLBACK_LOCAL && - process.env.SSO_FALLBACK_LOCAL !== "no" && - process.env.SSO_FALLBACK_LOCAL !== "false"; + const allowLocal = isEnvEnabled(process.env.SSO_FALLBACK_LOCAL); const hasExistingUsers = await hasUsers(); - if (!hasExistingUsers && !ssoEnabled || !hasExistingUsers && allowLocal) { + if ((!hasExistingUsers && !ssoEnabled) || (!hasExistingUsers && allowLocal)) { redirect("/auth/setup"); } diff --git a/app/(loggedOutRoutes)/auth/setup/page.tsx b/app/(loggedOutRoutes)/auth/setup/page.tsx index 28a60a7d..7399b7e2 100644 --- a/app/(loggedOutRoutes)/auth/setup/page.tsx +++ b/app/(loggedOutRoutes)/auth/setup/page.tsx @@ -2,15 +2,13 @@ import { redirect } from "next/navigation"; import { hasUsers } from "@/app/_server/actions/users"; import SetupForm from "@/app/(loggedOutRoutes)/auth/setup/setup-form"; import { AuthShell } from "@/app/_components/GlobalComponents/Auth/AuthShell"; +import { isEnvEnabled } from "@/app/_utils/env-utils"; export const dynamic = "force-dynamic"; export default async function SetupPage() { const ssoEnabled = process.env.SSO_MODE === "oidc"; - const allowLocal = - process.env.SSO_FALLBACK_LOCAL && - process.env.SSO_FALLBACK_LOCAL !== "no" && - process.env.SSO_FALLBACK_LOCAL !== "false"; + const allowLocal = isEnvEnabled(process.env.SSO_FALLBACK_LOCAL); if (ssoEnabled && !allowLocal) { redirect("/auth/login"); diff --git a/app/_components/FeatureComponents/Admin/Parts/Sharing/AdminSharing.tsx b/app/_components/FeatureComponents/Admin/Parts/Sharing/AdminSharing.tsx index 0ea613f3..fc35ce0c 100644 --- a/app/_components/FeatureComponents/Admin/Parts/Sharing/AdminSharing.tsx +++ b/app/_components/FeatureComponents/Admin/Parts/Sharing/AdminSharing.tsx @@ -24,12 +24,17 @@ import { useToast } from "@/app/_providers/ToastProvider"; export const AdminSharing = () => { const t = useTranslations(); const { showToast } = useToast(); - const { allSharedItems, globalSharing: rawGlobalSharing, user, appSettings } = useAppMode(); + const { + allSharedItems, + globalSharing: rawGlobalSharing, + user, + appSettings, + } = useAppMode(); const colors = useThemeColors(); const handleUnsharePublicItem = async ( item: { id: string; category: string }, - itemType: ItemType + itemType: ItemType, ) => { try { const currentUser = await getCurrentUser(); @@ -40,7 +45,7 @@ export const AdminSharing = () => { item.category, currentUser.username, "public", - itemType + itemType, ); window.location.reload(); @@ -68,17 +73,17 @@ export const AdminSharing = () => { Object.values(rawGlobalSharing?.checklists || {}).reduce( (sum: number, entries) => sum + (Array.isArray(entries) ? entries.length : 0), - 0 + 0, ) + Object.values(rawGlobalSharing?.notes || {}).reduce( (sum: number, entries) => sum + (Array.isArray(entries) ? entries.length : 0), - 0 + 0, ); const mostActiveSharers = useMemo( () => calculateMostActiveSharers(rawGlobalSharing), - [rawGlobalSharing] + [rawGlobalSharing], ); return ( @@ -86,9 +91,11 @@ export const AdminSharing = () => {
-

{t('admin.sharingOverview')}

+

+ {t("admin.sharingOverview")} +

- {t('admin.detailedSharingBreakdown')} + {t("admin.detailedSharingBreakdown")}

@@ -98,7 +105,7 @@ export const AdminSharing = () => { {Object.keys(rawGlobalSharing?.checklists || {}).length}
- {t('admin.activeChecklistSharers')} + {t("admin.activeChecklistSharers")}
@@ -106,7 +113,7 @@ export const AdminSharing = () => { {Object.keys(rawGlobalSharing?.notes || {}).length}
- {t('admin.activeNotesSharers')} + {t("admin.activeNotesSharers")}
@@ -114,23 +121,22 @@ export const AdminSharing = () => { {totalSharingRelationships}
- {t('admin.totalSharing')} + {t("admin.totalSharing")}
-

{t('admin.topContributors')}

+

{t("admin.topContributors")}

- @@ -150,32 +156,6 @@ export const AdminSharing = () => { {sharer.sharedCount} - ))} @@ -185,91 +165,6 @@ export const AdminSharing = () => { - -
-

{t('admin.sharedCMS')}

- {!hasContentAccess && ( -
- -
-

- {t('admin.contentHidden')} -

-

- {t('admin.noSharingPermissionsLabel')} -

-
-
- )} - {hasContentAccess && ( -
-
-
-
-
- -
-
-

{t('checklists.sharedChecklists')}

-

- {totalSharedChecklists} {t('checklists.title')} -

-
-
- -
-
- -
-
-
-
- -
-
-

{t('notes.sharedNotes')}

-

- {totalSharedNotes} {t('notes.title')} -

-
-
- -
-
- -
-
-
-
- -
-
-

{t('sharing.publicShares')}

-

- {t('admin.totalPublicItems', { count: totalPublicShares })} -

-
-
- { - const isChecklist = allSharedItems?.public.checklists.some( - (c) => c.id === item.id && c.category === item.category - ); - handleUnsharePublicItem( - item, - isChecklist ? ItemTypes.CHECKLIST : ItemTypes.NOTE - ); - }} - /> -
-
-
- )} -
); }; diff --git a/app/_components/FeatureComponents/Checklists/Checklist.tsx b/app/_components/FeatureComponents/Checklists/Checklist.tsx index 38e4acc3..a523ddf0 100644 --- a/app/_components/FeatureComponents/Checklists/Checklist.tsx +++ b/app/_components/FeatureComponents/Checklists/Checklist.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect } from "react"; import { Checklist } from "@/app/_types"; import { KanbanBoard } from "@/app/_components/FeatureComponents/Checklists/Parts/Kanban/KanbanBoard"; import { useChecklist } from "@/app/_hooks/useChecklist"; @@ -22,8 +22,6 @@ interface ChecklistViewProps { onEdit?: (checklist: Checklist) => void; onDelete?: (deletedId: string) => void; onClone?: () => void; - currentUsername?: string; - isAdmin?: boolean; sensors: any; } @@ -34,8 +32,6 @@ export const ChecklistView = ({ onEdit, onDelete, onClone, - currentUsername, - isAdmin = false, sensors, }: ChecklistViewProps) => { const t = useTranslations(); @@ -66,10 +62,7 @@ export const ChecklistView = ({ const { permissions } = usePermissions(); - const canDelete = true - ? isAdmin || currentUsername === localList.owner - : true; - const deleteHandler = canDelete ? handleDeleteList : undefined; + const deleteHandler = permissions?.canDelete ? handleDeleteList : undefined; const archiveHandler = async () => { const result = await toggleArchive(localList, Modes.CHECKLISTS); diff --git a/app/_components/FeatureComponents/Checklists/Parts/ChecklistClient.tsx b/app/_components/FeatureComponents/Checklists/Parts/ChecklistClient.tsx index 36691c7d..d8f4c837 100644 --- a/app/_components/FeatureComponents/Checklists/Parts/ChecklistClient.tsx +++ b/app/_components/FeatureComponents/Checklists/Parts/ChecklistClient.tsx @@ -48,11 +48,16 @@ export const ChecklistClient = ({ const { openCreateChecklistModal, openCreateCategoryModal, openSettings } = useShortcut(); const prevChecklistId = useRef(checklist.id); + const prevUpdatedAt = useRef(checklist.updatedAt); useEffect(() => { - if (checklist.id !== prevChecklistId.current) { + if ( + checklist.id !== prevChecklistId.current || + checklist.updatedAt !== prevUpdatedAt.current + ) { setLocalChecklist(checklist); prevChecklistId.current = checklist.id; + prevUpdatedAt.current = checklist.updatedAt; } }, [checklist]); @@ -62,7 +67,7 @@ export const ChecklistClient = ({ const handleBack = () => { checkNavigation(() => { - router.push("/"); + router.push("/?mode=checklists"); }); }; @@ -80,7 +85,10 @@ export const ChecklistClient = ({ const handleCloneConfirm = async (targetCategory: string) => { const formData = new FormData(); formData.append("id", localChecklist.id); - formData.append("originalCategory", localChecklist.category || "Uncategorized"); + formData.append( + "originalCategory", + localChecklist.category || "Uncategorized", + ); formData.append("category", targetCategory || "Uncategorized"); if (localChecklist.owner) { formData.append("user", localChecklist.owner); @@ -93,8 +101,8 @@ export const ChecklistClient = ({ router.push( `/checklist/${buildCategoryPath( result.data.category || "Uncategorized", - result.data.id - )}` + result.data.id, + )}`, ); router.refresh(); } @@ -106,7 +114,7 @@ export const ChecklistClient = ({ const handleDelete = (deletedId: string) => { checkNavigation(() => { - router.push("/"); + router.push("/?mode=checklists"); }); }; @@ -130,13 +138,7 @@ export const ChecklistClient = ({ checklist={localChecklist} onBack={handleBack} onEdit={handleEdit} - onDelete={ - localChecklist.isShared - ? user?.isAdmin || user?.username === localChecklist.owner - ? handleDeleteList - : undefined - : handleDeleteList - } + onDelete={handleDeleteList} onShare={() => setShowShareModal(true)} onConvertType={() => setShowConversionModal(true)} onArchive={handleArchive} @@ -155,8 +157,6 @@ export const ChecklistClient = ({ onEdit={handleEdit} onDelete={handleDelete} onClone={handleClone} - currentUsername={user?.username} - isAdmin={user?.isAdmin} sensors={sensors} /> ); @@ -190,8 +190,14 @@ export const ChecklistClient = ({ onConfirm={handleConfirmConversion} title={t("checklists.convertChecklistType")} message={t("checklists.convertTypeConfirmation", { - currentType: localChecklist.type === "simple" ? t("checklists.simpleChecklist") : t("checklists.taskProject"), - newType: getNewType(localChecklist.type) === "simple" ? t("checklists.simpleChecklist") : t("checklists.taskProject") + currentType: + localChecklist.type === "simple" + ? t("checklists.simpleChecklist") + : t("checklists.taskProject"), + newType: + getNewType(localChecklist.type) === "simple" + ? t("checklists.simpleChecklist") + : t("checklists.taskProject"), })} confirmText={t("checklists.convert")} /> diff --git a/app/_components/FeatureComponents/Checklists/Parts/Common/ChecklistModals.tsx b/app/_components/FeatureComponents/Checklists/Parts/Common/ChecklistModals.tsx index 3c865e1a..01f66ace 100644 --- a/app/_components/FeatureComponents/Checklists/Parts/Common/ChecklistModals.tsx +++ b/app/_components/FeatureComponents/Checklists/Parts/Common/ChecklistModals.tsx @@ -5,6 +5,8 @@ import { Checklist } from "@/app/_types"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; +import type { JSX } from "react"; + interface ChecklistModalsProps { localList: Checklist; showShareModal: boolean; diff --git a/app/_components/FeatureComponents/Checklists/Parts/Kanban/KanbanItemContent.tsx b/app/_components/FeatureComponents/Checklists/Parts/Kanban/KanbanItemContent.tsx index 75cdfafd..2053786d 100644 --- a/app/_components/FeatureComponents/Checklists/Parts/Kanban/KanbanItemContent.tsx +++ b/app/_components/FeatureComponents/Checklists/Parts/Kanban/KanbanItemContent.tsx @@ -1,6 +1,6 @@ "use client"; -import { memo } from "react"; +import { memo, type JSX } from "react"; import { UserAvatar } from "@/app/_components/GlobalComponents/User/UserAvatar"; import { Dropdown } from "@/app/_components/GlobalComponents/Dropdowns/Dropdown"; import { ProgressBar } from "@/app/_components/GlobalComponents/Statistics/ProgressBar"; @@ -17,7 +17,7 @@ interface KanbanItemContentProps { isShared: boolean; getUserAvatarUrl: (username: string) => string; getStatusIcon: (status?: string) => JSX.Element | null; - inputRef: React.RefObject; + inputRef: React.RefObject; onEditTextChange: (text: string) => void; onEditSave: () => void; onEditKeyDown: (e: React.KeyboardEvent) => void; diff --git a/app/_components/FeatureComponents/Checklists/Parts/Simple/NestedChecklistItem.tsx b/app/_components/FeatureComponents/Checklists/Parts/Simple/NestedChecklistItem.tsx index 6fbedfc8..ad8d5777 100644 --- a/app/_components/FeatureComponents/Checklists/Parts/Simple/NestedChecklistItem.tsx +++ b/app/_components/FeatureComponents/Checklists/Parts/Simple/NestedChecklistItem.tsx @@ -29,6 +29,7 @@ import { usePermissions } from "@/app/_providers/PermissionsProvider"; import { useTranslations } from "next-intl"; import { Droppable } from "./Droppable"; import { DropIndicator } from "./DropIndicator"; +import { useEditorActivityStore } from "@/app/_utils/editor-activity-store"; interface NestedChecklistItemProps { item: Item; @@ -79,7 +80,7 @@ const NestedChecklistItemComponent = ({ return ( usersPublicData?.find( - (user) => user.username?.toLowerCase() === username?.toLowerCase() + (user) => user.username?.toLowerCase() === username?.toLowerCase(), )?.avatarUrl || "" ); }; @@ -107,6 +108,20 @@ const NestedChecklistItemComponent = ({ const dropdownRef = useRef(null); const dropdownButtonRef = useRef(null); + const editorActivity = useEditorActivityStore(); + + useEffect(() => { + const editorId = `checklist-item-${item.id}`; + if (isEditing) { + editorActivity.register(editorId); + } else { + editorActivity.unregister(editorId); + } + return () => { + editorActivity.unregister(editorId); + }; + }, [isEditing, item.id]); + useEffect(() => { if (isEditing && inputRef.current) { inputRef.current.focus(); @@ -231,7 +246,7 @@ const NestedChecklistItemComponent = ({ useEffect(() => { if (hasChildren && item.children) { const allChildrenCompleted = item.children.every( - (child) => child.completed + (child) => child.completed, ); if (allChildrenCompleted) { setIsExpanded(false); @@ -263,19 +278,19 @@ const NestedChecklistItemComponent = ({ className={cn( "relative my-1", hasChildren && - !isChild && - "border-l-2 bg-muted/30 border-l-primary/70 rounded-jotty border-dashed border-t", + !isChild && + "border-l-2 bg-muted/30 border-l-primary/70 rounded-jotty border-dashed border-t", !hasChildren && - !isChild && - "border-l-2 bg-muted/30 border-l-primary/70 rounded-jotty border-dashed border-t", + !isChild && + "border-l-2 bg-muted/30 border-l-primary/70 rounded-jotty border-dashed border-t", isChild && - "ml-4 rounded-jotty border-dashed border-l border-border border-l-primary/70", + "ml-4 rounded-jotty border-dashed border-l border-border border-l-primary/70", "first:mt-0 transition-colors duration-150", isActive && "bg-muted/20", isDragging && "opacity-50 z-50", isSubtask && "bg-muted/30 border-l-0 !ml-0 !pl-0", isDropdownOpen && "z-50", - isOverDroppable && "ring-2 ring-primary/30 ring-inset" + isOverDroppable && "ring-2 ring-primary/30 ring-inset", )} > {isOver && overPosition === "before" && ( @@ -288,7 +303,7 @@ const NestedChecklistItemComponent = ({ isChild ? "px-2.5 py-1.5 lg:py-2" : "p-1.5 lg:p-2", completed && "opacity-80", !permissions?.canEdit && - "opacity-50 cursor-not-allowed pointer-events-none" + "opacity-50 cursor-not-allowed pointer-events-none", )} > {!isPublicView && !isDragDisabled && permissions?.canEdit && ( @@ -313,7 +328,7 @@ const NestedChecklistItemComponent = ({ className={cn( "h-5 w-5 rounded border-input focus:ring-none focus:ring-offset-2 focus:ring-ring", "transition-all duration-150", - (item.completed || completed) && "bg-primary border-primary" + (item.completed || completed) && "bg-primary border-primary", )} /> @@ -352,16 +367,16 @@ const NestedChecklistItemComponent = ({ )} ) : ( -
-
+
+
@@ -452,7 +469,7 @@ const NestedChecklistItemComponent = ({ "absolute right-0 z-50 w-48 bg-card border border-border rounded-jotty shadow-lg", dropdownOpenUpward ? "bottom-full mb-1 top-auto" - : "top-full mt-1" + : "top-full mt-1", )} >
@@ -481,7 +498,11 @@ const NestedChecklistItemComponent = ({ size="sm" onClick={() => setIsExpanded(!isExpanded)} className="h-6 w-6 p-0" - aria-label={isExpanded ? t("common.collapseAll") : t("common.expandAll")} + aria-label={ + isExpanded + ? t("common.collapseAll") + : t("common.expandAll") + } > {isExpanded ? ( @@ -542,8 +563,9 @@ const NestedChecklistItemComponent = ({
{draggedItemId !== item.id && (
- - } - onClick={() => - checkNavigation(() => { - onModeChange?.(Modes.CHECKLISTS); - router.push("/"); - }) - } - /> - - - } - onClick={() => - checkNavigation(() => { - onModeChange?.(Modes.NOTES); - router.push("/"); - }) - } - /> + {(user?.landingPage === Modes.NOTES + ? ([Modes.NOTES, Modes.CHECKLISTS] as AppMode[]) + : ([Modes.CHECKLISTS, Modes.NOTES] as AppMode[]) + ).map((modeOption) => ( + + ) : ( + + ) + } + onClick={() => + checkNavigation(() => { + onModeChange?.(modeOption); + router.push("/"); + }) + } + /> + ))}
diff --git a/app/_components/FeatureComponents/Home/Parts/ChecklistHome.tsx b/app/_components/FeatureComponents/Home/Parts/ChecklistHome.tsx index b439ea21..b66dcfaa 100644 --- a/app/_components/FeatureComponents/Home/Parts/ChecklistHome.tsx +++ b/app/_components/FeatureComponents/Home/Parts/ChecklistHome.tsx @@ -22,7 +22,8 @@ import { useTranslations } from "next-intl"; import { useSettings } from "@/app/_utils/settings-store"; import { ChecklistListItem } from "@/app/_components/GlobalComponents/Cards/ChecklistListItem"; import { ChecklistGridItem } from "@/app/_components/GlobalComponents/Cards/ChecklistGridItem"; -import { useMemo } from "react"; +import { useMemo, useState, useEffect, useTransition } from "react"; +import { getChecklistsForDisplay } from "@/app/_server/actions/checklist"; interface ChecklistHomeProps { lists: Checklist[]; @@ -32,15 +33,40 @@ interface ChecklistHomeProps { } export const ChecklistHome = ({ - lists, + lists: initialLists, user, onCreateModal, onSelectChecklist, }: ChecklistHomeProps) => { const t = useTranslations(); - const { userSharedItems, selectedFilter, setSelectedFilter } = useAppMode(); - const selectedCategory = selectedFilter?.type === 'category' ? selectedFilter.value : null; + const { + userSharedItems, + selectedFilter, + setSelectedFilter, + checklists: allChecklistsMetadata, + } = useAppMode(); + const selectedCategory = + selectedFilter?.type === "category" ? selectedFilter.value : null; const { viewMode } = useSettings(); + const [isPending, startTransition] = useTransition(); + const [displayLists, setDisplayLists] = useState(initialLists); + + useEffect(() => { + if (!selectedFilter || selectedFilter.type !== "category") { + setDisplayLists(initialLists); + return; + } + + startTransition(async () => { + const result = await getChecklistsForDisplay({ + type: "category", + value: selectedFilter.value, + }); + if (result.success && result.data) { + setDisplayLists(result.data as Checklist[]); + } + }); + }, [selectedFilter, initialLists]); const { sensors, @@ -54,39 +80,39 @@ export const ChecklistHome = ({ isListPinned, activeList, draggedItemWidth, - } = useChecklistHome({ lists, user }); - - const filteredLists = useMemo(() => { - if (!selectedCategory) return null; - return lists.filter((list) => { - const listCategory = list.category || "Uncategorized"; - return listCategory === selectedCategory || listCategory.startsWith(selectedCategory + "/"); - }); - }, [lists, selectedCategory]); + } = useChecklistHome({ lists: displayLists, user }); const filteredTaskLists = useMemo(() => { if (!selectedCategory) return taskLists; - return (filteredLists || []).filter((list) => list.type === "task"); - }, [taskLists, filteredLists, selectedCategory]); + return displayLists + .filter((list) => list.type === "task") + .filter((list) => !pinned.some((p) => p.id === list.id)); + }, [taskLists, displayLists, selectedCategory, pinned]); const filteredSimpleLists = useMemo(() => { if (!selectedCategory) return simpleLists; - return (filteredLists || []).filter((list) => list.type !== "task"); - }, [simpleLists, filteredLists, selectedCategory]); + return displayLists + .filter((list) => list.type !== "task") + .filter((list) => !pinned.some((p) => p.id === list.id)); + }, [simpleLists, displayLists, selectedCategory, pinned]); - const categoryDisplayName = selectedCategory?.split("/").pop() || selectedCategory; + const categoryDisplayName = + selectedCategory?.split("/").pop() || selectedCategory; const getListSharer = (list: Checklist) => { const encodedCategory = encodeCategoryPath( - list.category || "Uncategorized" + list.category || "Uncategorized", ); const sharedItem = userSharedItems?.checklists?.find( - (item) => item.id === list.id && item.category === encodedCategory + (item) => item.id === list.id && item.category === encodedCategory, ); return sharedItem?.sharer; }; - if (lists.length === 0) { + const hasAnyChecklists = + allChecklistsMetadata && allChecklistsMetadata.length > 0; + + if (!hasAnyChecklists) { return (
{ }} + onSelect={() => {}} isPinned={true} isDraggable={false} sharer={getListSharer(activeList)} @@ -230,7 +256,7 @@ export const ChecklistHome = ({ {viewMode === "list" && ( { }} + onSelect={() => {}} isPinned={true} sharer={getListSharer(activeList)} /> @@ -238,7 +264,7 @@ export const ChecklistHome = ({ {viewMode === "grid" && ( { }} + onSelect={() => {}} isPinned={true} sharer={getListSharer(activeList)} /> @@ -250,13 +276,17 @@ export const ChecklistHome = ({
)} - {(selectedCategory ? (filteredTaskLists.length > 0 || filteredSimpleLists.length > 0) : recent.length > 0) && ( + {(selectedCategory + ? filteredTaskLists.length > 0 || filteredSimpleLists.length > 0 + : recent.length > 0) && (
{filteredTaskLists.length > 0 && (

- {selectedCategory ? t("tasks.title") : t("tasks.recentTasks")} + {selectedCategory + ? t("tasks.title") + : t("tasks.recentTasks")}

+ + {t("settings.view")} + )} {onOpenDecryptModal && ( + + {t("encryption.decrypt")} + )}
@@ -136,7 +152,7 @@ export const NoteEditorContent = ({
@@ -159,10 +175,13 @@ export const NoteEditorContent = ({ <>
- + {referencingItems.length > 0 && appSettings?.editor?.enableBilateralLinks && ( diff --git a/app/_components/FeatureComponents/Notes/Parts/SwipeNavigationWrapper.tsx b/app/_components/FeatureComponents/Notes/Parts/SwipeNavigationWrapper.tsx new file mode 100644 index 00000000..3262443a --- /dev/null +++ b/app/_components/FeatureComponents/Notes/Parts/SwipeNavigationWrapper.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useState, useEffect, useRef, ReactNode, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { Note } from "@/app/_types"; +import { useAdjacentNotes } from "@/app/_hooks/useAdjacentNotes"; +import { useSwipeNavigation } from "@/app/_hooks/useSwipeNavigation"; +import { useNavigationGuard } from "@/app/_providers/NavigationGuardProvider"; +import { isMobileDevice, buildCategoryPath } from "@/app/_utils/global-utils"; + +interface SwipeNavigationWrapperProps { + children: ReactNode; + noteId: string; + noteCategory?: string; + enabled: boolean; +} + +const getNoteUrl = (note: Partial | null, embed = false): string | null => { + if (!note?.id) return null; + const base = `/note/${buildCategoryPath(note.category || "Uncategorized", note.id)}`; + return embed ? `${base}?embed=true` : base; +}; + +export const SwipeNavigationWrapper = ({ + children, + noteId, + noteCategory, + enabled, +}: SwipeNavigationWrapperProps) => { + const router = useRouter(); + const { checkNavigation } = useNavigationGuard(); + const wrapperRef = useRef(null); + const currentRef = useRef(null); + const prevRef = useRef(null); + const nextRef = useRef(null); + const [isMobile, setIsMobile] = useState(false); + + const { prev, next } = useAdjacentNotes(noteId); + + const prevUrl = getNoteUrl(prev, true); + const nextUrl = getNoteUrl(next, true); + const prevNavUrl = getNoteUrl(prev); + const nextNavUrl = getNoteUrl(next); + + useEffect(() => { + const isInIframe = window.self !== window.top; + if (isInIframe) { + document.documentElement.classList.add("jotty-embed"); + return; + } + + setIsMobile(isMobileDevice()); + }, []); + + useEffect(() => { + if (!isMobile) return; + if (prevNavUrl) router.prefetch(prevNavUrl); + if (nextNavUrl) router.prefetch(nextNavUrl); + }, [isMobile, prevNavUrl, nextNavUrl, router]); + + const navigateToNote = useCallback((note: Partial | null) => { + const url = getNoteUrl(note); + if (!url) return; + checkNavigation(() => { + router.push(url); + }); + }, [checkNavigation, router]); + + const handleNavigateLeft = useCallback(() => { + navigateToNote(next); + }, [next, navigateToNote]); + + const handleNavigateRight = useCallback(() => { + navigateToNote(prev); + }, [prev, navigateToNote]); + + useSwipeNavigation({ + enabled: isMobile && enabled, + onNavigateLeft: handleNavigateLeft, + onNavigateRight: handleNavigateRight, + wrapperRef, + currentRef, + prevRef, + nextRef, + hasPrev: !!prev, + hasNext: !!next, + }); + + if (!isMobile) { + return <>{children}; + } + + return ( +
+
+ {children} +
+ + {prevUrl && ( +
+
{t('common.user')} - {t('admin.itemsShared')} + {t("common.user")} - {t('admin.activityLevel')} + {t("admin.itemsShared")}
-
-
-
-
- - {sharer.sharedCount > - mostActiveSharers[0].sharedCount * 0.8 - ? t('common.high') - : sharer.sharedCount > - mostActiveSharers[0].sharedCount * 0.5 - ? t('common.medium') - : t('common.low')} - -
-