diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index fc2e24d..2050114 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -1,5 +1,5 @@ app: - version: "0.1.5-alpha" + version: "0.1.6-alpha" spring: application: diff --git a/frontend/src/components/DashboardView.tsx b/frontend/src/components/DashboardView.tsx index d9be4ac..e69a54c 100644 --- a/frontend/src/components/DashboardView.tsx +++ b/frontend/src/components/DashboardView.tsx @@ -1,6 +1,7 @@ import React, { useState } from "react"; -import { Link } from "@tanstack/react-router"; +import { Link, useNavigate } from "@tanstack/react-router"; import { toast } from "sonner"; + import * as Icons from "lucide-react"; import { useProjectsQuery, @@ -13,15 +14,20 @@ import { ConfirmModal } from "./ConfirmModal"; import { getIconComponent } from "../utils/icons"; import styles from "../routes/index.module.css"; +type TabFilter = "ACTIVE" | "ARCHIVED" | "ALL"; + export const DashboardView: React.FC = () => { - const { data: projectsData } = useProjectsQuery(); + const navigate = useNavigate(); + const { data: projectsData, isLoading } = useProjectsQuery(); + const archiveMutation = useArchiveProjectMutation(); const unarchiveMutation = useUnarchiveProjectMutation(); const deleteMutation = useDeleteProjectMutation(); + const [searchQuery, setSearchQuery] = useState(""); + const [tabFilter, setTabFilter] = useState("ACTIVE"); const [editingProjectId, setEditingProjectId] = useState(undefined); const [isCreateOpen, setIsCreateOpen] = useState(false); - const [showArchived, setShowArchived] = useState(false); const [confirmModal, setConfirmModal] = useState<{ isOpen: boolean; itemId: string; @@ -30,10 +36,29 @@ export const DashboardView: React.FC = () => { }>({ isOpen: false, itemId: "", itemName: "", isLoading: false }); const projects = projectsData?.content || []; - const activeProjects = projects.filter((p) => !p.archived); - const archivedProjects = projects.filter((p) => p.archived); + const activeProjectsCount = projects.filter((p) => !p.archived).length; + const archivedProjectsCount = projects.filter((p) => p.archived).length; + + const effectiveTab: TabFilter = + tabFilter === "ARCHIVED" && archivedProjectsCount === 0 ? "ACTIVE" : tabFilter; + + const isMutationPending = + archiveMutation.isPending || unarchiveMutation.isPending || deleteMutation.isPending; + + const filteredProjects = projects.filter((project) => { + // Filter by effective tab + if (effectiveTab === "ACTIVE" && project.archived) return false; + if (effectiveTab === "ARCHIVED" && !project.archived) return false; + + // Filter by search query + if (!searchQuery.trim()) return true; + const q = searchQuery.toLowerCase(); + return ( + project.name.toLowerCase().includes(q) || + (project.description && project.description.toLowerCase().includes(q)) + ); + }); - const isMutationPending = archiveMutation.isPending || unarchiveMutation.isPending || deleteMutation.isPending; const handleArchive = async (id: string, name: string) => { try { @@ -71,183 +96,315 @@ export const DashboardView: React.FC = () => { return (
+ {/* Header section */}
-

DASHBOARD

-

Manage your projects, snippets, and credentials

+
+
+ + WORKSPACES +
+

PROJECTS

+

+ Select a project workspace to manage snippets, vault credentials, notes, and links. +

+
+ +
-
-
- Active Projects - {activeProjects.length} + {/* Control Bar: Search & Tab Filters */} +
+
+ + setSearchQuery(e.target.value)} + /> + {searchQuery && ( + + )}
-
- Archived Projects - {archivedProjects.length} + +
+ + {archivedProjectsCount > 0 && ( + + )} +
-
-

Active Projects

+ {/* Projects Grid */} + {isLoading ? ( +
+ + LOADING WORKSPACES... +
+ ) : filteredProjects.length === 0 ? ( +
+ +

+ {searchQuery + ? `No projects matching "${searchQuery}"` + : effectiveTab === "ARCHIVED" + ? "No archived projects found" + : "No projects created yet"} +

+

+ {searchQuery + ? "Try adjusting your search filter." + : "Get started by creating your first dev project workspace."} +

+ {searchQuery ? ( + + ) : ( + + )} +
+ ) : (
- {activeProjects.map((project) => { + {filteredProjects.map((project) => { const ProjectIcon = getIconComponent(project.icon); + const cardColor = project.color || "var(--color-primary, #6366f1)"; + return ( - + navigate({ + to: "/projects/$projectId", + params: { projectId: project.id }, + }) + } + onKeyDown={(e) => { + if (e.target !== e.currentTarget) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + navigate({ + to: "/projects/$projectId", + params: { projectId: project.id }, + }); + } + }} > + {/* Top Accent Strip */}
+ + {/* Card Header */}
-
-
- -
+
+ +
+ +

{project.name}

+ {project.archived && ( + ARCHIVED + )}
+ {/* Card Body */}

{project.description || "No description provided."}

+ {/* Direct Shortcut Feature Badges */} +
e.stopPropagation()}> + e.stopPropagation()} + > + + Snippets + + e.stopPropagation()} + > + + Vault + + e.stopPropagation()} + > + + Problems + + e.stopPropagation()} + > + + Notes + + e.stopPropagation()} + > + + Links + +
+ + {/* Card Footer */}
-
- +
e.stopPropagation()}> + {project.archived ? ( + + ) : ( + + )}
- +
); })} - -
-
- {archivedProjects.length > 0 && ( -
+ {/* Create Project Card in Grid */} - - {showArchived && ( -
- {archivedProjects.map((project) => { - const ProjectIcon = getIconComponent(project.icon); - return ( -
-
-
-
- -
-

{project.name}

-
-
- -

- {project.description || "No description provided."} -

- -
-
- - -
- ARCHIVED -
-
- ); - })} +
+
- )} +
+

Create Project

+

Add a new environment workspace

+
+
)} @@ -273,7 +430,7 @@ export const DashboardView: React.FC = () => { title="Delete Project" message="Are you sure you want to permanently delete the project" itemName={confirmModal.itemName} - warningText="This cannot be undone. All snippets and diagnostics in this project will be permanently deleted." + warningText="This cannot be undone. All snippets, credentials, problems, notes, and links in this project will be permanently deleted." isLoading={confirmModal.isLoading} />
diff --git a/frontend/src/components/HackerLogo.module.css b/frontend/src/components/HackerLogo.module.css index 401c272..c66a747 100644 --- a/frontend/src/components/HackerLogo.module.css +++ b/frontend/src/components/HackerLogo.module.css @@ -3,14 +3,18 @@ display: inline-flex; align-items: center; justify-content: center; + transform: translateZ(0); + -webkit-transform: translateZ(0); + backface-visibility: hidden; + -webkit-backface-visibility: hidden; } .organicAura { position: absolute; - width: 150%; - height: 150%; - left: -25%; - top: -25%; + width: 140%; + height: 140%; + left: -20%; + top: -20%; border-radius: 50%; background: radial-gradient( ellipse at center, @@ -18,10 +22,13 @@ rgba(16, 185, 129, 0.1) 50%, rgba(16, 185, 129, 0) 75% ); - filter: blur(14px); + filter: blur(12px); + -webkit-filter: blur(12px); pointer-events: none; z-index: 0; animation: organic-pulse 3s ease-in-out infinite alternate; + transform: translateZ(0); + -webkit-transform: translateZ(0); } :global(:root[data-theme="light"]) .organicAura { @@ -36,11 +43,11 @@ @keyframes organic-pulse { 0% { opacity: 0.5; - transform: scale(0.9) rotate(0deg); + transform: scale(0.9) rotate(0deg) translateZ(0); } 100% { opacity: 0.95; - transform: scale(1.15) rotate(5deg); + transform: scale(1.1) rotate(4deg) translateZ(0); } } @@ -50,12 +57,14 @@ pointer-events: none; overflow: visible; z-index: 10; + transform: translateZ(0); + -webkit-transform: translateZ(0); } @keyframes float-particle { 0% { opacity: 0; - transform: translateY(8px) scale(0.85); + transform: translateY(8px) scale(0.85) translateZ(0); } 25% { opacity: 0.9; @@ -65,7 +74,7 @@ } 100% { opacity: 0; - transform: translateY(-32px) scale(1.1); + transform: translateY(-32px) scale(1.1) translateZ(0); } } @@ -77,14 +86,17 @@ pointer-events: none; user-select: none; animation: float-particle 3s ease-in-out infinite; - filter: drop-shadow(0 0 5px rgba(16, 185, 129, 0.8)); + filter: drop-shadow(0 0 4px rgba(16, 185, 129, 0.7)); + -webkit-filter: drop-shadow(0 0 4px rgba(16, 185, 129, 0.7)); will-change: transform, opacity; + transform: translateZ(0); + -webkit-transform: translateZ(0); } - :global(:root[data-theme="light"]) .cssParticle { color: #059669; - filter: drop-shadow(0 0 4px rgba(5, 150, 105, 0.7)); + filter: drop-shadow(0 0 4px rgba(5, 150, 105, 0.6)); + -webkit-filter: drop-shadow(0 0 4px rgba(5, 150, 105, 0.6)); } .logoSvg { @@ -92,16 +104,20 @@ z-index: 1; filter: none !important; transition: color 0.3s ease; + transform: translateZ(0); + -webkit-transform: translateZ(0); + backface-visibility: hidden; + -webkit-backface-visibility: hidden; } .greenLogo { color: #10b981 !important; - filter: drop-shadow(0 0 5px rgba(16, 185, 129, 0.8)) - drop-shadow(0 0 12px rgba(16, 185, 129, 0.5)) !important; + filter: drop-shadow(0 0 6px rgba(16, 185, 129, 0.6)) !important; + -webkit-filter: drop-shadow(0 0 6px rgba(16, 185, 129, 0.6)) !important; } :global(:root[data-theme="light"]) .greenLogo { color: #059669 !important; - filter: drop-shadow(0 0 4px rgba(5, 150, 105, 0.7)) - drop-shadow(0 0 10px rgba(5, 150, 105, 0.4)) !important; + filter: drop-shadow(0 0 5px rgba(5, 150, 105, 0.6)) !important; + -webkit-filter: drop-shadow(0 0 5px rgba(5, 150, 105, 0.6)) !important; } diff --git a/frontend/src/components/ProjectDetailView.tsx b/frontend/src/components/ProjectDetailView.tsx index f173182..69d6a81 100644 --- a/frontend/src/components/ProjectDetailView.tsx +++ b/frontend/src/components/ProjectDetailView.tsx @@ -13,9 +13,17 @@ import { LinksWorkspace } from "~features/links/components/LinksWorkspace"; import styles from "../routes/projects.$projectId.module.css"; import { getIconComponent } from "../utils/icons"; +import { getRouteApi } from "@tanstack/react-router"; +import type { ProjectTabType } from "../routes/projects.$projectId"; + +const routeApi = getRouteApi("/projects/$projectId"); + export const ProjectDetailView: React.FC = () => { const { projectId } = useParamsHelper(); const { close: closeSidebar } = useSidebar(); + const search = routeApi.useSearch(); + const navigate = routeApi.useNavigate(); + // Auto-close sidebar when entering a project useEffect(() => { @@ -32,21 +40,17 @@ export const ProjectDetailView: React.FC = () => { (p) => p.status === "OPEN" || p.status === "WORKING_ON" ).length; - - - // Workspace sub-navigation state - const [activeTab, setActiveTab] = useState< - "snippets" | "problems" | "credentials" | "notes" | "links" - >("snippets"); + // Workspace sub-navigation state derived directly from URL search params + const activeTab = search.tab || "snippets"; const [isTagsManagerOpen, setIsTagsManagerOpen] = useState(false); - const handleTabChange = ( - tab: "snippets" | "problems" | "credentials" | "notes" | "links" - ) => { - setActiveTab(tab); + const handleTabChange = (tab: ProjectTabType) => { + navigate({ search: { tab }, replace: true }); }; + + const projectIcon = getIconComponent(project?.icon); return ( @@ -158,11 +162,12 @@ export const ProjectDetailView: React.FC = () => { setIsTagsManagerOpen(true)} /> )} + {activeTab === "notes" && ( void; title?: string; + noBorder?: boolean; } export const TagManagerSection: React.FC = ({ @@ -26,6 +27,7 @@ export const TagManagerSection: React.FC = ({ projectId, onOpenManageTagsModal, title = "Associated Tags", + noBorder = false, }) => { const { data: tagsData = [] } = useTagsQuery(projectId); const createTagMutation = useCreateTagMutation(projectId); @@ -108,11 +110,14 @@ export const TagManagerSection: React.FC = ({ ); return ( -
-
- - {title} -
+
+ {title && title.trim() !== "" && ( +
+ + {title} +
+ )} +
{itemTags.map((tag) => ( @@ -143,7 +148,11 @@ export const TagManagerSection: React.FC = ({ {isPopoverOpen && ( -
+
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + >
= ({ className={styles.tagSearchInput} value={tagSearchQuery} onChange={(e) => setTagSearchQuery(e.target.value)} + onKeyDown={(e) => e.stopPropagation()} autoFocus />
+
{unassociatedTags.map((t) => (
); })()} + + + {/* Generic fallback for any unlisted or custom keys in payload */} + {Object.entries(payload) + .filter( + ([key]) => + ![ + "username", + "user", + "email", + "password", + "pass", + "secret", + "apiKey", + "api_key", + "token", + "key", + "rawTextContent", + "rawText", + "content", + "text", + ].includes(key) + ) + .map(([key, val]) => ( +
+
+ {key} +
+
+ {val} + +
+
+ ))}
+ {/* Metadata Section */}
{cred.relatedUrl && ( diff --git a/frontend/src/features/credentials/components/CredentialsWorkspace.tsx b/frontend/src/features/credentials/components/CredentialsWorkspace.tsx index d5d8df6..898cd52 100644 --- a/frontend/src/features/credentials/components/CredentialsWorkspace.tsx +++ b/frontend/src/features/credentials/components/CredentialsWorkspace.tsx @@ -207,11 +207,13 @@ export const CredentialsWorkspace: React.FC = ({ className={styles.credentialCard} onClick={() => setViewingCredentialId(cred.id)} onKeyDown={(e) => { + if (e.target !== e.currentTarget) return; if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setViewingCredentialId(cred.id); } }} + >
@@ -236,9 +238,22 @@ export const CredentialsWorkspace: React.FC = ({ )}
+
+ +
+ {cred.notes && ( +

{cred.notes}

+ )} + + +
+
e.stopPropagation()}> = ({ projectId={projectId} onOpenManageTagsModal={onOpenManageTagsModal} title="" + noBorder={true} />
diff --git a/frontend/src/features/links/components/LinksWorkspace.tsx b/frontend/src/features/links/components/LinksWorkspace.tsx index 0e1293a..ccda979 100644 --- a/frontend/src/features/links/components/LinksWorkspace.tsx +++ b/frontend/src/features/links/components/LinksWorkspace.tsx @@ -125,8 +125,15 @@ export const LinksWorkspace: React.FC = ({ >
{l.title} - +
+ + {new Date(l.createdAt).toLocaleDateString()} + + +
+ + {l.url} diff --git a/frontend/src/features/notes/components/NotesWorkspace.tsx b/frontend/src/features/notes/components/NotesWorkspace.tsx index d0b4a41..87755f7 100644 --- a/frontend/src/features/notes/components/NotesWorkspace.tsx +++ b/frontend/src/features/notes/components/NotesWorkspace.tsx @@ -6,6 +6,9 @@ import DOMPurify from "dompurify"; import { ConfirmModal } from "../../../components/ConfirmModal"; import { TagManagerSection } from "../../../components/TagManagerSection"; import { formatUpdatedDate } from "../../../utils/dateUtils"; +import { copyToClipboard } from "../../../utils/clipboardUtils"; + + import { NoteForm } from "../components/NoteForm"; import { useNotesQuery, @@ -282,8 +285,15 @@ export const NotesWorkspace: React.FC = ({ >
{n.title} - {n.archived && ARCHIVED} +
+ {n.archived && ARCHIVED} + + {new Date(n.createdAt).toLocaleDateString()} + +
+ + {n.tags && n.tags.length > 0 && (
{n.tags.map((tag) => ( @@ -323,6 +333,29 @@ export const NotesWorkspace: React.FC = ({
+ + +
+
diff --git a/frontend/src/features/snippets/components/SnippetsWorkspace.tsx b/frontend/src/features/snippets/components/SnippetsWorkspace.tsx index 2178f86..f7b2b2c 100644 --- a/frontend/src/features/snippets/components/SnippetsWorkspace.tsx +++ b/frontend/src/features/snippets/components/SnippetsWorkspace.tsx @@ -5,8 +5,10 @@ import Editor from "@monaco-editor/react"; import { useTheme } from "../../../hooks/useTheme"; import { ConfirmModal } from "../../../components/ConfirmModal"; import { TagManagerSection } from "../../../components/TagManagerSection"; -import { formatUpdatedDate } from "../../../utils/dateUtils"; import { copyToClipboard } from "../../../utils/clipboardUtils"; +import { formatUpdatedDate } from "../../../utils/dateUtils"; + + import { SnippetForm } from "./SnippetForm"; import { @@ -177,12 +179,12 @@ export const SnippetsWorkspace: React.FC = ({ >
{s.title} - - {formatUpdatedDate(s.updatedAt, s.createdAt) - ? `Upd: ${formatUpdatedDate(s.updatedAt, s.createdAt)}` - : new Date(s.createdAt).toLocaleDateString()} + + {new Date(s.createdAt).toLocaleDateString()}
+ + {s.description &&

{s.description}

}
{s.snippetType} @@ -211,8 +213,17 @@ export const SnippetsWorkspace: React.FC = ({ {selectedSnippet.description && (

{selectedSnippet.description}

)} +
+ Created: {new Date(selectedSnippet.createdAt).toLocaleDateString()} + {formatUpdatedDate(selectedSnippet.updatedAt, selectedSnippet.createdAt) && ( + + Updated: {formatUpdatedDate(selectedSnippet.updatedAt, selectedSnippet.createdAt)} + + )} +
+