Adds optional SSO via Authentik (or any OIDC provider) alongside the existing password flow, and pulls profile images from the provider's `picture` claim or Gravatar so the sharing UI stops looking anonymous. Password login stays available as a recovery path; JIT provisioning and admin-group mapping are env-configurable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
209 lines
7.6 KiB
TypeScript
209 lines
7.6 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import { Timeline } from './components/timeline/Timeline'
|
|
import { DuplicatesView } from './components/duplicates/DuplicatesView'
|
|
import { MapView } from './components/map/MapView'
|
|
import { MemoriesView } from './components/memories/MemoriesView'
|
|
import { TagsView } from './components/tags/TagsView'
|
|
import { ColorsView } from './components/colors/ColorsView'
|
|
import { RatedView } from './components/rated/RatedView'
|
|
import { LeftSidebar } from './components/layout/LeftSidebar'
|
|
import { RightSidebar } from './components/layout/RightSidebar'
|
|
import { TopBar } from './components/layout/TopBar'
|
|
import { ScanProgress } from './components/ScanProgress'
|
|
import { ToastContainer } from './components/ToastContainer'
|
|
import { KeyboardHints } from './components/KeyboardHints'
|
|
import { PreviewView } from './components/preview/PreviewView'
|
|
import { FilterBar } from './components/filter/FilterBar'
|
|
import { DiscardActionBar } from './components/discard/DiscardActionBar'
|
|
import { SettingsPage } from './components/dialogs/SettingsDialog'
|
|
import { usePhotoStore } from './store/photoStore'
|
|
import { useFilterStore } from './store/filterStore'
|
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
|
import { useFilterUrlSync } from './hooks/useFilterUrlSync'
|
|
import { usePhotosQuery } from './hooks/usePhotosQuery'
|
|
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
|
import { LoginPage } from './components/auth/LoginPage'
|
|
import { SetupPage } from './components/auth/SetupPage'
|
|
import { OidcCallback } from './components/auth/OidcCallback'
|
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
|
|
|
function MainApp() {
|
|
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true)
|
|
const [rightSidebarOpen, setRightSidebarOpen] = useState(true)
|
|
// Respect the user's manual collapse of the metadata panel. Once they
|
|
// close it explicitly (via `i` hotkey or the sidebar toggle button),
|
|
// selecting a new photo should NOT force it back open. Cleared when
|
|
// they open it manually again.
|
|
const rightCollapsedByUser = useRef(false)
|
|
const viewMode = usePhotoStore((state) => state.viewMode)
|
|
const activePhotoId = usePhotoStore((state) => state.activePhotoId)
|
|
const currentSection = useFilterStore((s) => s.currentSection)
|
|
|
|
// Close the metadata panel when the user switches between sections so
|
|
// it doesn't carry over a now-irrelevant selection. It re-opens once a
|
|
// photo gains focus in the new section (effect below).
|
|
const prevSectionRef = useRef(currentSection)
|
|
useEffect(() => {
|
|
if (prevSectionRef.current !== currentSection) {
|
|
prevSectionRef.current = currentSection
|
|
setRightSidebarOpen(false)
|
|
}
|
|
}, [currentSection])
|
|
|
|
useEffect(() => {
|
|
if (!activePhotoId) {
|
|
setRightSidebarOpen(false)
|
|
return
|
|
}
|
|
// User explicitly collapsed the panel — don't undo that just because
|
|
// they picked a different photo.
|
|
if (rightCollapsedByUser.current) return
|
|
setRightSidebarOpen(true)
|
|
}, [activePhotoId])
|
|
|
|
const toggleRightSidebar = () => {
|
|
setRightSidebarOpen((prev) => {
|
|
const next = !prev
|
|
rightCollapsedByUser.current = !next
|
|
return next
|
|
})
|
|
}
|
|
|
|
// Bidirectional sync of filter store with URL query params.
|
|
useFilterUrlSync()
|
|
|
|
// Subscribe to the same photos query the Timeline uses, so the keyboard
|
|
// "open preview on first photo" path can read from the live cache regardless
|
|
// of what filter key it's stored under.
|
|
const { data: allPhotos } = usePhotosQuery()
|
|
|
|
// Set up global keyboard shortcuts. Prefer the Timeline's published
|
|
// visible sequence (which respects per-month ordering) over the raw
|
|
// backend list — otherwise "Space on a blank selection" would open
|
|
// the globally first photo, which isn't what the user sees at the
|
|
// top-left of the grid.
|
|
useKeyboardShortcuts({
|
|
onToggleLeftSidebar: () => setLeftSidebarOpen(!leftSidebarOpen),
|
|
onToggleRightSidebar: toggleRightSidebar,
|
|
getFirstPhotoId: () =>
|
|
usePhotoStore.getState().visiblePhotoIds[0] ??
|
|
allPhotos?.[0]?.id ??
|
|
null,
|
|
})
|
|
|
|
// Settings page is a full-page section — hide filter bar, right sidebar,
|
|
// and keyboard hints when it's active.
|
|
const isSettings = currentSection === 'settings'
|
|
|
|
// Right sidebar stays open by default and shows whatever's selected
|
|
// (or an empty state if nothing is). User can still toggle it manually.
|
|
const showRightSidebar = rightSidebarOpen && !isSettings
|
|
|
|
return (
|
|
<TooltipProvider delayDuration={300}>
|
|
<div className="flex flex-col h-screen bg-bg text-text">
|
|
<TopBar />
|
|
|
|
<div className="flex flex-1 overflow-hidden">
|
|
{/* Left Sidebar */}
|
|
<div
|
|
className={`transition-all duration-200 ${
|
|
leftSidebarOpen ? 'w-60' : 'w-0'
|
|
} overflow-hidden border-r border-border bg-surface`}
|
|
>
|
|
<LeftSidebar />
|
|
</div>
|
|
|
|
{/* Main column — filter bar, discard bar, timeline. Lives to the
|
|
* right of the left sidebar so the filter row doesn't bleed
|
|
* across the sidebar. relative so the KeyboardHints overlay
|
|
* centers against this column, not the viewport. */}
|
|
<div className="relative flex min-w-0 flex-1 flex-col">
|
|
{!isSettings && (
|
|
<FilterBar
|
|
leftSidebarOpen={leftSidebarOpen}
|
|
rightSidebarOpen={showRightSidebar}
|
|
onToggleLeftSidebar={() => setLeftSidebarOpen(!leftSidebarOpen)}
|
|
onToggleRightSidebar={toggleRightSidebar}
|
|
/>
|
|
)}
|
|
{!isSettings && <DiscardActionBar />}
|
|
<div className="flex-1 overflow-auto">
|
|
{currentSection === 'settings' ? (
|
|
<SettingsPage />
|
|
) : currentSection === 'map' ? (
|
|
<MapView />
|
|
) : currentSection === 'memories' ? (
|
|
<MemoriesView />
|
|
) : currentSection === 'duplicates' ? (
|
|
<DuplicatesView />
|
|
) : currentSection === 'tags' ? (
|
|
<TagsView />
|
|
) : currentSection === 'colors' ? (
|
|
<ColorsView />
|
|
) : currentSection === 'rated' ? (
|
|
<RatedView />
|
|
) : (
|
|
<Timeline />
|
|
)}
|
|
</div>
|
|
{!isSettings && viewMode !== 'preview' && <KeyboardHints />}
|
|
</div>
|
|
|
|
{/* Right Sidebar */}
|
|
<div
|
|
className={`transition-all duration-200 ${
|
|
showRightSidebar ? 'w-72' : 'w-0'
|
|
} overflow-hidden border-l border-border bg-surface`}
|
|
>
|
|
<RightSidebar />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Scan Progress Indicator */}
|
|
<ScanProgress />
|
|
|
|
{/* Toast Notifications */}
|
|
<ToastContainer />
|
|
|
|
{/* Preview overlay — covers TopBar when active */}
|
|
{viewMode === 'preview' && <PreviewView />}
|
|
|
|
</div>
|
|
</TooltipProvider>
|
|
)
|
|
}
|
|
|
|
/** Auth-gated shell: shows setup, login, or the main app. */
|
|
function App() {
|
|
return (
|
|
<AuthProvider>
|
|
<AuthGate />
|
|
</AuthProvider>
|
|
)
|
|
}
|
|
|
|
function AuthGate() {
|
|
const { user, isLoading, needsSetup } = useAuth()
|
|
|
|
// OIDC callback lands on /auth/callback — handle it even while
|
|
// isLoading, so the callback page can adopt tokens and transition
|
|
// straight to MainApp without flashing the login screen.
|
|
if (window.location.pathname.startsWith('/auth/callback')) {
|
|
return <OidcCallback />
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex min-h-screen items-center justify-center bg-bg">
|
|
<div className="text-text-muted">Loading…</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (needsSetup) return <SetupPage />
|
|
if (!user) return <LoginPage />
|
|
return <MainApp />
|
|
}
|
|
|
|
export default App |