| import { useDeferredValue, useEffect, useMemo, useState } from 'react' |
| import { useQuery } from '@tanstack/react-query' |
| import clsx from 'clsx' |
| import { AnimatePresence } from 'framer-motion' |
| import { Copy, Download, Link2, Sparkles, X } from 'lucide-react' |
| import ChatArea from './components/Chat/ChatArea' |
| import Composer from './components/Composer/Composer' |
| import Sidebar from './components/Sidebar/Sidebar' |
| import AuthDialog from './components/UI/AuthDialogPanel' |
| import AppHeader from './components/UI/AppHeader' |
| import Button from './components/UI/Button' |
| import ConfirmDialog from './components/UI/ConfirmDialog' |
| import FeedbackDialog from './components/UI/FeedbackDialog' |
| import Modal from './components/UI/Modal' |
| import ModelDialog from './components/UI/ModelDialog' |
| import Toaster from './components/UI/Toaster' |
| import WorkspacePanel from './components/UI/WorkspacePanel' |
| import { useAuth } from './hooks/useAuth' |
| import { useChat } from './hooks/useChat' |
| import { useInstallPrompt } from './hooks/useInstallPrompt' |
| import { decodeSharePayload, useAppStore } from './store/useAppStore' |
| import { api } from './utils/api' |
|
|
| function isSmallViewport() { |
| return typeof window !== 'undefined' && window.innerWidth < 1024 |
| } |
|
|
| function clearShareHash() { |
| if (typeof window === 'undefined' || !window.location.hash.startsWith('#share=')) return |
| window.history.replaceState({}, '', window.location.pathname + window.location.search) |
| } |
|
|
| function buildRenamePayload(sessionId, title) { |
| return sessionId ? { session_id: sessionId, title } : { title, isDraft: true } |
| } |
|
|
| function getEnabledProviderIds(registry = {}) { |
| return Object.entries(registry) |
| .filter(([, providerEntry]) => providerEntry?.enabled !== false) |
| .map(([providerId]) => providerId) |
| } |
|
|
| function getPreferredModelId(providerId, providerEntry, defaultProvider, defaultModel) { |
| if (!providerEntry?.models?.length) { |
| return defaultModel |
| } |
| if (providerId === defaultProvider && providerEntry.models.some((entry) => entry.id === defaultModel)) { |
| return defaultModel |
| } |
| return providerEntry.models[0]?.id || defaultModel |
| } |
|
|
| function getInstallSteps(platform, instructions) { |
| if (platform === 'ios') { |
| return [ |
| 'Open OwnGPT in Safari on your iPhone or iPad.', |
| 'Tap the Share button in the browser toolbar.', |
| 'Choose Add to Home Screen, then confirm Install.', |
| ] |
| } |
|
|
| if (platform === 'android') { |
| return [ |
| 'Open the browser menu in Chrome or Edge.', |
| 'Choose Install app or Add to Home screen.', |
| 'Confirm the install prompt to keep OwnGPT on your device.', |
| ] |
| } |
|
|
| return [ |
| 'Open the browser menu near the address bar.', |
| 'Choose Install OwnGPT, Install app, or Create shortcut.', |
| 'Launch the installed app for a cleaner desktop workspace.', |
| ] |
| } |
|
|
| export default function AppShell() { |
| const { |
| token, |
| user, |
| isAuthenticated, |
| authMode, |
| isAuthDialogOpen, |
| openAuth, |
| closeAuth, |
| login, |
| register, |
| loginPending, |
| registerPending, |
| logout, |
| loginWithGoogle, |
| requireAuth, |
| } = useAuth() |
|
|
| const theme = useAppStore((state) => state.theme) |
| const themePreference = useAppStore((state) => state.themePreference) |
| const setThemePreference = useAppStore((state) => state.setThemePreference) |
| const syncSystemTheme = useAppStore((state) => state.syncSystemTheme) |
| const sidebarOpen = useAppStore((state) => state.sidebarOpen) |
| const setSidebarOpen = useAppStore((state) => state.setSidebarOpen) |
| const toggleSidebar = useAppStore((state) => state.toggleSidebar) |
| const currentSessionId = useAppStore((state) => state.currentSessionId) |
| const currentSessionTitle = useAppStore((state) => state.currentSessionTitle) |
| const composerText = useAppStore((state) => state.composerText) |
| const setComposerText = useAppStore((state) => state.setComposerText) |
| const attachments = useAppStore((state) => state.attachments) |
| const removeAttachment = useAppStore((state) => state.removeAttachment) |
| const mode = useAppStore((state) => state.mode) |
| const setMode = useAppStore((state) => state.setMode) |
| const provider = useAppStore((state) => state.provider) |
| const model = useAppStore((state) => state.model) |
| const setProviderModel = useAppStore((state) => state.setProviderModel) |
| const sessionSearch = useAppStore((state) => state.sessionSearch) |
| const setSessionSearch = useAppStore((state) => state.setSessionSearch) |
| const sidePanel = useAppStore((state) => state.sidePanel) |
| const setSidePanel = useAppStore((state) => state.setSidePanel) |
| const selectedFile = useAppStore((state) => state.selectedFile) |
| const clearSelectedFile = useAppStore((state) => state.clearSelectedFile) |
| const sharedConversation = useAppStore((state) => state.sharedConversation) |
| const setSharedConversation = useAppStore((state) => state.setSharedConversation) |
| const clearSharedConversation = useAppStore((state) => state.clearSharedConversation) |
| const startNewChat = useAppStore((state) => state.startNewChat) |
| const renameCurrentSession = useAppStore((state) => state.renameCurrentSession) |
| const activeModal = useAppStore((state) => state.activeModal) |
| const modalPayload = useAppStore((state) => state.modalPayload) |
| const openModal = useAppStore((state) => state.openModal) |
| const closeModal = useAppStore((state) => state.closeModal) |
| const addToast = useAppStore((state) => state.addToast) |
|
|
| const deferredSearch = useDeferredValue(sessionSearch) |
| const [renameDraft, setRenameDraft] = useState('') |
| const [editDraft, setEditDraft] = useState('') |
|
|
| const { |
| messages, |
| isLoadingHistory, |
| isStreaming, |
| currentMode, |
| uploadFiles, |
| sendMessage, |
| selectSession, |
| renameSession, |
| deleteSession, |
| submitFeedback, |
| feedbackPending, |
| askAboutFile, |
| branchFromUserMessage, |
| regenerateFromAssistant, |
| exportChatAsMarkdown, |
| exportChatAsPdf, |
| shareConversation, |
| toggleSpeech, |
| speakingMessageId, |
| } = useChat({ token, user }) |
|
|
| const { install, installState, platform, manualInstructions } = useInstallPrompt() |
| const previewFile = useAppStore.getState().previewFile |
|
|
| const modelsQuery = useQuery({ |
| queryKey: ['models'], |
| queryFn: () => api.get('/models'), |
| staleTime: 300_000, |
| }) |
|
|
| const healthQuery = useQuery({ |
| queryKey: ['healthz-ready'], |
| queryFn: () => api.get('/healthz/ready'), |
| staleTime: 15_000, |
| retry: 0, |
| refetchInterval: 30_000, |
| }) |
|
|
| const sessionsQuery = useQuery({ |
| queryKey: ['sessions', token || 'guest', deferredSearch], |
| queryFn: () => |
| api.get( |
| deferredSearch?.trim() |
| ? `/search?query=${encodeURIComponent(deferredSearch.trim())}` |
| : '/search', |
| { token }, |
| ), |
| enabled: isAuthenticated, |
| placeholderData: (previous) => previous, |
| staleTime: 15_000, |
| }) |
|
|
| const insightsQuery = useQuery({ |
| queryKey: ['workspace-insights', token || 'guest'], |
| queryFn: () => api.get('/insights/workspace', { token }), |
| enabled: isAuthenticated, |
| staleTime: 30_000, |
| refetchInterval: 60_000, |
| }) |
|
|
| const registry = modelsQuery.data?.providers || {} |
| const sessions = sessionsQuery.data?.sessions || [] |
|
|
| useEffect(() => { |
| const nextRegistry = modelsQuery.data?.providers |
| if (!nextRegistry) return |
|
|
| const defaultProvider = modelsQuery.data.default_provider |
| const defaultModel = modelsQuery.data.default_model |
| const enabledProviderIds = getEnabledProviderIds(nextRegistry) |
| const resolvedProvider = |
| nextRegistry[provider] && nextRegistry[provider].enabled !== false |
| ? provider |
| : enabledProviderIds[0] || defaultProvider |
| const providerEntry = nextRegistry[resolvedProvider] |
| const preferredModel = getPreferredModelId( |
| resolvedProvider, |
| providerEntry, |
| defaultProvider, |
| defaultModel, |
| ) |
|
|
| if (!providerEntry) { |
| setProviderModel(defaultProvider, defaultModel) |
| return |
| } |
|
|
| const modelExists = providerEntry.models.some((entry) => entry.id === model) |
| if (resolvedProvider !== provider || !modelExists) { |
| setProviderModel(resolvedProvider, modelExists ? model : preferredModel) |
| } |
| }, [model, modelsQuery.data, provider, setProviderModel]) |
|
|
| useEffect(() => { |
| if (activeModal === 'renameSession') { |
| setRenameDraft(modalPayload?.title || '') |
| } |
| if (activeModal === 'editMessage') { |
| setEditDraft(modalPayload?.content || '') |
| } |
| }, [activeModal, modalPayload]) |
|
|
| useEffect(() => { |
| if (typeof window === 'undefined') return undefined |
|
|
| const syncSharedConversation = () => { |
| const payload = decodeSharePayload(window.location.hash) |
| if (payload?.messages?.length) { |
| setSharedConversation(payload) |
| setSidePanel('analytics') |
| } else if (window.location.hash.startsWith('#share=')) { |
| clearSharedConversation() |
| } |
| } |
|
|
| syncSharedConversation() |
| window.addEventListener('hashchange', syncSharedConversation) |
| return () => window.removeEventListener('hashchange', syncSharedConversation) |
| }, [clearSharedConversation, setSharedConversation, setSidePanel]) |
|
|
| useEffect(() => { |
| if (typeof window === 'undefined') return undefined |
| const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') |
| const handleThemeChange = () => syncSystemTheme() |
| mediaQuery.addEventListener?.('change', handleThemeChange) |
| return () => mediaQuery.removeEventListener?.('change', handleThemeChange) |
| }, [syncSystemTheme]) |
|
|
| const stats = useMemo( |
| () => ({ |
| sessionCount: sessions.length, |
| messageCount: messages.length, |
| attachmentCount: attachments.length, |
| modeLabel: currentMode.label, |
| modeDescription: currentMode.description, |
| totalSessions: insightsQuery.data?.total_sessions || 0, |
| totalMessages: insightsQuery.data?.total_messages || 0, |
| totalAttachments: insightsQuery.data?.total_attachments || 0, |
| activeDays: insightsQuery.data?.active_days || 0, |
| averageMessagesPerSession: insightsQuery.data?.average_messages_per_session || 0, |
| }), |
| [ |
| attachments.length, |
| currentMode.description, |
| currentMode.label, |
| insightsQuery.data?.active_days, |
| insightsQuery.data?.average_messages_per_session, |
| insightsQuery.data?.total_attachments, |
| insightsQuery.data?.total_messages, |
| insightsQuery.data?.total_sessions, |
| messages.length, |
| sessions.length, |
| ], |
| ) |
|
|
| const pendingAuth = loginPending || registerPending |
|
|
| const handleNewChat = () => { |
| clearShareHash() |
| clearSharedConversation() |
| startNewChat() |
| if (isSmallViewport()) { |
| setSidebarOpen(false) |
| } |
| } |
|
|
| const handleSelectSession = (session) => { |
| clearShareHash() |
| clearSharedConversation() |
| selectSession(session) |
| if (isSmallViewport()) { |
| setSidebarOpen(false) |
| } |
| } |
|
|
| const handleDeleteSessionRequest = (session) => { |
| if (!session?.session_id) return |
| openModal('deleteSession', { |
| session_id: session.session_id, |
| title: session.title || 'this chat', |
| }) |
| } |
|
|
| const handleSubmitMessage = async (text) => { |
| await sendMessage({ text }) |
| } |
|
|
| const handleCopyMessage = async (message) => { |
| try { |
| await navigator.clipboard.writeText(message.content || '') |
| addToast({ |
| title: 'Message copied', |
| description: 'The response is now in your clipboard.', |
| variant: 'success', |
| }) |
| } catch (error) { |
| addToast({ |
| title: 'Copy failed', |
| description: error.message || 'Clipboard access is unavailable.', |
| variant: 'danger', |
| }) |
| } |
| } |
|
|
| const handleOpenFeedback = () => { |
| requireAuth(() => openModal('feedback')) |
| } |
|
|
| const handleShareConversation = async () => { |
| if (!messages.length) { |
| addToast({ |
| title: 'Nothing to share yet', |
| description: 'Start a conversation first, then generate a shareable link.', |
| variant: 'info', |
| }) |
| return |
| } |
| await shareConversation() |
| } |
|
|
| const handleRenameSubmit = async () => { |
| const title = renameDraft.trim() |
| if (!title) return |
|
|
| if (modalPayload?.session_id) { |
| await renameSession({ sessionId: modalPayload.session_id, title }) |
| } else { |
| renameCurrentSession(title) |
| } |
| closeModal() |
| } |
|
|
| const handleDeleteSubmit = async () => { |
| if (!modalPayload?.session_id) return |
| await deleteSession(modalPayload.session_id) |
| closeModal() |
| } |
|
|
| const handleEditSubmit = async () => { |
| const text = editDraft.trim() |
| if (!text || !modalPayload) return |
| await branchFromUserMessage(modalPayload, text) |
| closeModal() |
| } |
|
|
| const handleClosePanel = () => { |
| if (sidePanel === 'file') { |
| clearSelectedFile() |
| return |
| } |
| setSidePanel(null) |
| } |
|
|
| const handleInstallApp = async () => { |
| const result = await install() |
|
|
| if (result.status === 'accepted') { |
| addToast({ |
| title: 'Install started', |
| description: 'Confirm the browser prompt to finish installing OwnGPT.', |
| variant: 'success', |
| }) |
| return |
| } |
|
|
| if (result.status === 'dismissed') { |
| addToast({ |
| title: 'Install dismissed', |
| description: 'You can install OwnGPT anytime from the header button.', |
| variant: 'info', |
| }) |
| return |
| } |
|
|
| if (result.status === 'installed') { |
| addToast({ |
| title: 'Already installed', |
| description: 'OwnGPT is already available as an app on this device.', |
| variant: 'info', |
| }) |
| return |
| } |
|
|
| openModal('installHelp', result) |
| } |
|
|
| const activeProviderLabel = registry[provider]?.label || provider |
| const appTitle = sharedConversation ? sharedConversation.title || 'Shared Conversation' : currentSessionTitle |
| const runtimeStatus = healthQuery.data || { |
| status: healthQuery.isError ? 'degraded' : 'checking', |
| version: 'unknown', |
| checks: { |
| mongo: healthQuery.isError ? 'unreachable' : 'checking', |
| vector_memory: 'unknown', |
| }, |
| enabled_providers: getEnabledProviderIds(registry), |
| } |
|
|
| return ( |
| <div |
| className={clsx( |
| 'relative flex h-screen overflow-hidden bg-background text-foreground transition-[padding] duration-300', |
| sidebarOpen ? 'lg:pl-[288px]' : 'lg:pl-0', |
| )} |
| > |
| <div className="pointer-events-none absolute inset-0 -z-10 overflow-hidden"> |
| <div className="app-background absolute inset-0" /> |
| </div> |
| |
| <Sidebar |
| open={sidebarOpen} |
| sessions={sessions} |
| loading={sessionsQuery.isLoading && !sessions.length} |
| currentSessionId={currentSessionId} |
| searchValue={sessionSearch} |
| onSearchChange={setSessionSearch} |
| onSelectSession={handleSelectSession} |
| onDeleteSession={handleDeleteSessionRequest} |
| onNewSession={handleNewChat} |
| onClose={() => setSidebarOpen(false)} |
| stats={stats} |
| insights={insightsQuery.data} |
| /> |
| |
| <div className="flex min-w-0 flex-1 flex-col"> |
| <AppHeader |
| title={appTitle} |
| theme={theme} |
| themePreference={themePreference} |
| currentSessionId={currentSessionId} |
| canRenameSession={Boolean(user && !sharedConversation)} |
| hasMessages={Boolean(messages.length)} |
| onToggleSidebar={toggleSidebar} |
| onThemePreferenceChange={setThemePreference} |
| onNewChat={handleNewChat} |
| onRenameSession={() => |
| openModal('renameSession', buildRenamePayload(currentSessionId, currentSessionTitle)) |
| } |
| onDeleteSession={() => |
| currentSessionId |
| ? openModal('deleteSession', { |
| session_id: currentSessionId, |
| title: currentSessionTitle, |
| }) |
| : null |
| } |
| onShare={handleShareConversation} |
| onExportMarkdown={exportChatAsMarkdown} |
| onExportPdf={exportChatAsPdf} |
| onToggleAnalytics={() => setSidePanel(sidePanel === 'analytics' ? null : 'analytics')} |
| onFeedback={handleOpenFeedback} |
| user={user} |
| onLogin={() => openAuth('login')} |
| onLogout={logout} |
| /> |
| |
| {sharedConversation ? ( |
| <div className="border-b border-border bg-background px-4 py-3 sm:px-6"> |
| <div className="mx-auto flex max-w-3xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> |
| <div className="flex items-center gap-2 text-sm font-medium text-foreground"> |
| <Link2 className="h-4 w-4 text-muted-foreground" /> |
| Shared conversation |
| </div> |
| <div className="flex flex-wrap items-center gap-2"> |
| <Button variant="secondary" onClick={handleNewChat}> |
| <Copy className="h-4 w-4" /> |
| New chat |
| </Button> |
| <Button |
| variant="ghost" |
| onClick={() => { |
| clearShareHash() |
| clearSharedConversation() |
| }} |
| > |
| <X className="h-4 w-4" /> |
| Dismiss |
| </Button> |
| </div> |
| </div> |
| </div> |
| ) : null} |
| |
| <div className="flex min-h-0 flex-1"> |
| <div className="flex min-w-0 flex-1 flex-col"> |
| <ChatArea |
| messages={messages} |
| loading={isLoadingHistory} |
| user={user} |
| onCopyMessage={handleCopyMessage} |
| onRegenerate={regenerateFromAssistant} |
| onEdit={(message) => openModal('editMessage', message)} |
| onSpeak={toggleSpeech} |
| onPreviewFile={previewFile} |
| speakingMessageId={speakingMessageId} |
| /> |
| |
| <Composer |
| text={composerText} |
| onTextChange={setComposerText} |
| attachments={attachments} |
| onRemoveAttachment={removeAttachment} |
| onUpload={uploadFiles} |
| onSend={handleSubmitMessage} |
| onAskAboutFile={askAboutFile} |
| onPreviewFile={previewFile} |
| mode={mode} |
| onModeChange={setMode} |
| provider={activeProviderLabel} |
| model={model} |
| onOpenModels={() => openModal('model')} |
| disabled={!isAuthenticated} |
| streaming={isStreaming} |
| onRequireAuth={() => openAuth('login')} |
| /> |
| </div> |
| |
| <AnimatePresence initial={false}> |
| <WorkspacePanel |
| sidePanel={sidePanel} |
| selectedFile={selectedFile} |
| stats={stats} |
| insights={insightsQuery.data} |
| isAuthenticated={isAuthenticated} |
| runtimeStatus={runtimeStatus} |
| activeProvider={activeProviderLabel} |
| activeModel={model} |
| onClose={handleClosePanel} |
| onPreviewAnalytics={() => setSidePanel('analytics')} |
| onAskAboutFile={askAboutFile} |
| /> |
| </AnimatePresence> |
| </div> |
| </div> |
| |
| <AuthDialog |
| open={isAuthDialogOpen} |
| mode={authMode || 'login'} |
| onModeChange={openAuth} |
| onClose={closeAuth} |
| onLogin={login} |
| onRegister={register} |
| onGoogleLogin={loginWithGoogle} |
| pending={pendingAuth} |
| /> |
| |
| <FeedbackDialog |
| open={activeModal === 'feedback'} |
| onOpenChange={(next) => { |
| if (!next) closeModal() |
| }} |
| onSubmit={submitFeedback} |
| pending={feedbackPending} |
| /> |
| |
| <ModelDialog |
| open={activeModal === 'model'} |
| onOpenChange={(next) => { |
| if (!next) closeModal() |
| }} |
| registry={registry} |
| currentProvider={provider} |
| currentModel={model} |
| onSelect={setProviderModel} |
| /> |
| |
| <ConfirmDialog |
| open={activeModal === 'deleteSession'} |
| onOpenChange={(next) => { |
| if (!next) closeModal() |
| }} |
| title="Delete conversation?" |
| description={ |
| modalPayload?.title |
| ? `This will permanently remove "${modalPayload.title}" and its saved messages.` |
| : 'This will permanently remove the selected conversation and its saved messages.' |
| } |
| onConfirm={handleDeleteSubmit} |
| confirmLabel="Delete" |
| destructive |
| /> |
| |
| <Modal |
| open={activeModal === 'renameSession'} |
| onOpenChange={(next) => { |
| if (!next) closeModal() |
| }} |
| size="sm" |
| title="Rename conversation" |
| footer={ |
| <div className="flex justify-end gap-2"> |
| <Button variant="ghost" onClick={closeModal}> |
| Cancel |
| </Button> |
| <Button variant="primary" onClick={handleRenameSubmit} disabled={!renameDraft.trim()}> |
| Save title |
| </Button> |
| </div> |
| } |
| > |
| <label className="block space-y-2"> |
| <span className="text-sm font-medium text-foreground">Conversation title</span> |
| <div className="field rounded-lg px-4 py-3"> |
| <input |
| value={renameDraft} |
| onChange={(event) => setRenameDraft(event.target.value)} |
| placeholder="Product launch planning" |
| className="w-full bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground" |
| /> |
| </div> |
| </label> |
| </Modal> |
| |
| <Modal |
| open={activeModal === 'editMessage'} |
| onOpenChange={(next) => { |
| if (!next) closeModal() |
| }} |
| size="md" |
| title="Edit user prompt" |
| footer={ |
| <div className="flex justify-end gap-2"> |
| <Button variant="ghost" onClick={closeModal}> |
| Cancel |
| </Button> |
| <Button variant="primary" onClick={handleEditSubmit} disabled={!editDraft.trim()}> |
| <Sparkles className="h-4 w-4" /> |
| Run new branch |
| </Button> |
| </div> |
| } |
| > |
| <label className="block space-y-2"> |
| <span className="text-sm font-medium text-foreground">Prompt</span> |
| <div className="field rounded-lg px-4 py-3"> |
| <textarea |
| value={editDraft} |
| onChange={(event) => setEditDraft(event.target.value)} |
| className="min-h-[220px] w-full bg-transparent text-sm leading-7 text-foreground outline-none placeholder:text-muted-foreground" |
| placeholder="Refine the original message and branch from it..." |
| /> |
| </div> |
| </label> |
| </Modal> |
| |
| <Modal |
| open={activeModal === 'installHelp'} |
| onOpenChange={(next) => { |
| if (!next) closeModal() |
| }} |
| size="sm" |
| title="Install OwnGPT" |
| footer={ |
| <div className="flex justify-end gap-2"> |
| <Button variant="ghost" onClick={closeModal}> |
| Close |
| </Button> |
| <Button variant="primary" onClick={closeModal}> |
| <Download className="h-4 w-4" /> |
| I understand |
| </Button> |
| </div> |
| } |
| > |
| <div className="space-y-4"> |
| <div className="surface-soft p-4"> |
| <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground"> |
| Recommended steps |
| </p> |
| <div className="mt-3 space-y-3 text-sm leading-6 text-foreground"> |
| {getInstallSteps(modalPayload?.platform || platform, modalPayload?.instructions || manualInstructions).map((step, index) => ( |
| <div key={step} className="flex items-start gap-3"> |
| <span className="mt-0.5 inline-flex h-6 w-6 items-center justify-center rounded-lg bg-accent/10 text-xs font-semibold text-accent"> |
| {index + 1} |
| </span> |
| <span>{step}</span> |
| </div> |
| ))} |
| </div> |
| </div> |
| <div className="rounded-lg border border-border bg-background px-4 py-4 text-sm leading-6 text-muted-foreground"> |
| {modalPayload?.instructions || manualInstructions} |
| </div> |
| </div> |
| </Modal> |
| |
| <Toaster /> |
| </div> |
| ) |
| }
|
|
|