diff --git a/fileglancer/alembic/versions/2d1f0e6b8c91_add_neuroglancer_states_table.py b/fileglancer/alembic/versions/2d1f0e6b8c91_add_neuroglancer_states_table.py new file mode 100644 index 00000000..2b4bd558 --- /dev/null +++ b/fileglancer/alembic/versions/2d1f0e6b8c91_add_neuroglancer_states_table.py @@ -0,0 +1,42 @@ +"""add neuroglancer_states table + +Revision ID: 2d1f0e6b8c91 +Revises: 9812335c52b6 +Create Date: 2025-10-22 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '2d1f0e6b8c91' +down_revision = '9812335c52b6' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'neuroglancer_states', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('short_key', sa.String(), nullable=False), + sa.Column('short_name', sa.String(), nullable=True), + sa.Column('username', sa.String(), nullable=False), + sa.Column('url_base', sa.String(), nullable=False), + sa.Column('state', sa.JSON(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.UniqueConstraint('short_key', name='uq_neuroglancer_states_short_key') + ) + op.create_index( + 'ix_neuroglancer_states_short_key', + 'neuroglancer_states', + ['short_key'], + unique=True + ) + + +def downgrade() -> None: + op.drop_index('ix_neuroglancer_states_short_key', table_name='neuroglancer_states') + op.drop_table('neuroglancer_states') diff --git a/fileglancer/alembic/versions/3c5b7a9f2c11_add_short_name_to_neuroglancer_states.py b/fileglancer/alembic/versions/3c5b7a9f2c11_add_short_name_to_neuroglancer_states.py new file mode 100644 index 00000000..8922e797 --- /dev/null +++ b/fileglancer/alembic/versions/3c5b7a9f2c11_add_short_name_to_neuroglancer_states.py @@ -0,0 +1,24 @@ +"""add short_name to neuroglancer_states + +Revision ID: 3c5b7a9f2c11 +Revises: 2d1f0e6b8c91 +Create Date: 2025-10-22 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3c5b7a9f2c11' +down_revision = '2d1f0e6b8c91' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column('neuroglancer_states', sa.Column('short_name', sa.String(), nullable=True)) + + +def downgrade() -> None: + op.drop_column('neuroglancer_states', 'short_name') diff --git a/fileglancer/app.py b/fileglancer/app.py index 5b2beeec..a7e9f7d0 100644 --- a/fileglancer/app.py +++ b/fileglancer/app.py @@ -149,6 +149,46 @@ def _validate_filename(name: str) -> None: raise HTTPException(status_code=400, detail="File or directory name cannot have leading or trailing whitespace") +def _parse_neuroglancer_url(url: str) -> Tuple[str, Dict]: + """ + Parse a Neuroglancer URL and return its base URL and decoded JSON state. + """ + if not url or "#!" not in url: + raise HTTPException(status_code=400, detail="Neuroglancer URL must include a '#!' state fragment") + + url_base, encoded_state = url.split("#!", 1) + if not url_base.startswith(("http://", "https://")): + raise HTTPException(status_code=400, detail="Neuroglancer URL must start with http or https") + + decoded_state = unquote(encoded_state) + if decoded_state.startswith(("http://", "https://")): + raise HTTPException(status_code=400, detail="Shortened Neuroglancer URLs are not supported; provide a full state URL") + + try: + state = json.loads(decoded_state) + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail="Neuroglancer state must be valid JSON") + + if not isinstance(state, dict): + raise HTTPException(status_code=400, detail="Neuroglancer state must be a JSON object") + + return url_base, state + + +def _normalize_short_key(short_key: Optional[str]) -> Optional[str]: + if short_key is None: + return None + normalized = short_key.strip() + return normalized or None + + +def _validate_short_key(short_key: str) -> None: + if len(short_key) < 4 or len(short_key) > 64: + raise HTTPException(status_code=400, detail="short_key must be between 4 and 64 characters") + if not all(ch.isalnum() or ch in ("-", "_") for ch in short_key): + raise HTTPException(status_code=400, detail="short_key may only contain letters, numbers, '-' or '_'") + + def create_app(settings): # Initialize OAuth client for OKTA @@ -643,6 +683,58 @@ async def delete_preference(key: str, username: str = Depends(get_current_user)) return {"message": f"Preference {key} deleted for user {username}"} + @app.post("/api/neuroglancer/shorten", response_model=NeuroglancerShortenResponse, + description="Store a Neuroglancer state and return a shortened link") + async def shorten_neuroglancer_state(request: Request, + payload: NeuroglancerShortenRequest, + username: str = Depends(get_current_user)): + short_key = _normalize_short_key(payload.short_key) + if short_key: + _validate_short_key(short_key) + short_name = payload.short_name.strip() if payload.short_name else None + + if payload.url and payload.state: + raise HTTPException(status_code=400, detail="Provide either url or state, not both") + + if payload.url: + url_base, state = _parse_neuroglancer_url(payload.url.strip()) + elif payload.state: + if not payload.url_base: + raise HTTPException(status_code=400, detail="url_base is required when providing state directly") + if not isinstance(payload.state, dict): + raise HTTPException(status_code=400, detail="state must be a JSON object") + url_base = payload.url_base.strip() + if not url_base.startswith(("http://", "https://")): + raise HTTPException(status_code=400, detail="url_base must start with http or https") + state = payload.state + else: + raise HTTPException(status_code=400, detail="Either url or state must be provided") + + with db.get_db_session(settings.db_url) as session: + try: + entry = db.create_neuroglancer_state( + session, + username, + url_base, + state, + short_key=short_key, + short_name=short_name + ) + created_short_key = entry.short_key + created_short_name = entry.short_name + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) + + state_url = str(request.url_for("get_neuroglancer_state", short_key=created_short_key)) + neuroglancer_url = f"{url_base}#!{state_url}" + return NeuroglancerShortenResponse( + short_key=created_short_key, + short_name=created_short_name, + state_url=state_url, + neuroglancer_url=neuroglancer_url + ) + + @app.post("/api/proxied-path", response_model=ProxiedPath, description="Create a new proxied path") async def create_proxied_path(fsp_name: str = Query(..., description="The name of the file share path that this proxied path is associated with"), @@ -713,6 +805,37 @@ async def delete_proxied_path(sharing_key: str = Path(..., description="The shar return {"message": f"Proxied path {sharing_key} deleted for user {username}"} + @app.get("/ng/{short_key}", name="get_neuroglancer_state", include_in_schema=False) + async def get_neuroglancer_state(short_key: str = Path(..., description="Short key for a stored Neuroglancer state")): + with db.get_db_session(settings.db_url) as session: + entry = db.get_neuroglancer_state(session, short_key) + if not entry: + raise HTTPException(status_code=404, detail="Neuroglancer state not found") + return JSONResponse(content=entry.state, headers={"Cache-Control": "no-store"}) + + + @app.get("/api/neuroglancer/short-links", response_model=NeuroglancerShortLinkResponse, + description="List stored Neuroglancer short links for the current user") + async def get_neuroglancer_short_links(request: Request, + username: str = Depends(get_current_user)): + links = [] + with db.get_db_session(settings.db_url) as session: + entries = db.get_neuroglancer_states(session, username) + for entry in entries: + state_url = str(request.url_for("get_neuroglancer_state", short_key=entry.short_key)) + neuroglancer_url = f"{entry.url_base}#!{state_url}" + links.append(NeuroglancerShortLink( + short_key=entry.short_key, + short_name=entry.short_name, + created_at=entry.created_at, + updated_at=entry.updated_at, + state_url=state_url, + neuroglancer_url=neuroglancer_url + )) + + return NeuroglancerShortLinkResponse(links=links) + + @app.get("/files/{sharing_key}/{sharing_name}") @app.get("/files/{sharing_key}/{sharing_name}/{path:path}") async def target_dispatcher(request: Request, diff --git a/fileglancer/database.py b/fileglancer/database.py index 50f79b5b..daf3c703 100644 --- a/fileglancer/database.py +++ b/fileglancer/database.py @@ -18,6 +18,7 @@ # Constants SHARING_KEY_LENGTH = 12 +NEUROGLANCER_SHORT_KEY_LENGTH = 12 # Global flag to track if migrations have been run _migrations_run = False @@ -102,6 +103,20 @@ class ProxiedPathDB(Base): ) +class NeuroglancerStateDB(Base): + """Database model for storing Neuroglancer states""" + __tablename__ = 'neuroglancer_states' + + id = Column(Integer, primary_key=True, autoincrement=True) + short_key = Column(String, nullable=False, unique=True, index=True) + short_name = Column(String, nullable=True) + username = Column(String, nullable=False) + url_base = Column(String, nullable=False) + state = Column(JSON, nullable=False) + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC)) + updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)) + + class TicketDB(Base): """Database model for storing proxied paths""" __tablename__ = 'tickets' @@ -571,6 +586,66 @@ def delete_proxied_path(session: Session, username: str, sharing_key: str): _invalidate_sharing_key_cache(sharing_key) +def _generate_unique_neuroglancer_key(session: Session) -> str: + """Generate a unique short key for Neuroglancer states.""" + for _ in range(10): + candidate = secrets.token_urlsafe(NEUROGLANCER_SHORT_KEY_LENGTH) + exists = session.query(NeuroglancerStateDB).filter_by(short_key=candidate).first() + if not exists: + return candidate + raise RuntimeError("Failed to generate a unique Neuroglancer short key") + + +def _validate_custom_neuroglancer_key(session: Session, short_key: str) -> None: + """Ensure the custom short key is available.""" + exists = session.query(NeuroglancerStateDB).filter_by(short_key=short_key).first() + if exists: + raise ValueError("Short key is already in use") + + +def create_neuroglancer_state( + session: Session, + username: str, + url_base: str, + state: Dict, + short_key: Optional[str] = None, + short_name: Optional[str] = None +) -> NeuroglancerStateDB: + """Create a new Neuroglancer state entry and return it.""" + if short_key: + _validate_custom_neuroglancer_key(session, short_key) + else: + short_key = _generate_unique_neuroglancer_key(session) + now = datetime.now(UTC) + entry = NeuroglancerStateDB( + short_key=short_key, + short_name=short_name, + username=username, + url_base=url_base, + state=state, + created_at=now, + updated_at=now + ) + session.add(entry) + session.commit() + return entry + + +def get_neuroglancer_state(session: Session, short_key: str) -> Optional[NeuroglancerStateDB]: + """Get a Neuroglancer state by short key.""" + return session.query(NeuroglancerStateDB).filter_by(short_key=short_key).first() + + +def get_neuroglancer_states(session: Session, username: str) -> List[NeuroglancerStateDB]: + """Get all Neuroglancer states for a user, newest first.""" + return ( + session.query(NeuroglancerStateDB) + .filter_by(username=username) + .order_by(NeuroglancerStateDB.created_at.desc()) + .all() + ) + + def get_tickets(session: Session, username: str, fsp_name: str = None, path: str = None) -> List[TicketDB]: """Get tickets for a user, optionally filtered by fsp_name and path""" logger.info(f"Getting tickets for {username} with fsp_name={fsp_name} and path={path}") diff --git a/fileglancer/filestore.py b/fileglancer/filestore.py index f25f3b67..0ecff0b5 100644 --- a/fileglancer/filestore.py +++ b/fileglancer/filestore.py @@ -183,8 +183,13 @@ def _get_file_info_from_path(self, full_path: str, current_user: str = None) -> Get the FileInfo for a file or directory at the given path. """ stat_result = os.stat(full_path) - # Regenerate the relative path to ensure it is not empty (None and empty string are converted to '.' here) - rel_path = os.path.relpath(full_path, self.root_path) + # Use real paths to avoid /var vs /private/var mismatches on macOS. + root_real = os.path.realpath(self.root_path) + full_real = os.path.realpath(full_path) + if full_real == root_real: + rel_path = '.' + else: + rel_path = os.path.relpath(full_real, root_real) return FileInfo.from_stat(rel_path, full_path, stat_result, current_user) diff --git a/fileglancer/model.py b/fileglancer/model.py index 2156675a..066948b9 100644 --- a/fileglancer/model.py +++ b/fileglancer/model.py @@ -214,3 +214,73 @@ class NotificationResponse(BaseModel): notifications: List[Notification] = Field( description="A list of active notifications" ) + + +class NeuroglancerShortenRequest(BaseModel): + """Request payload for creating a shortened Neuroglancer state""" + short_key: Optional[str] = Field( + description="Optional short key to use instead of a generated one", + default=None + ) + short_name: Optional[str] = Field( + description="Optional human-friendly name for the short link", + default=None + ) + url: Optional[str] = Field( + description="Neuroglancer URL containing the encoded JSON state after #!", + default=None + ) + state: Optional[Dict] = Field( + description="Neuroglancer state as a JSON object", + default=None + ) + url_base: Optional[str] = Field( + description="Base Neuroglancer URL, required when providing state directly", + default=None + ) + + +class NeuroglancerShortenResponse(BaseModel): + """Response payload for shortened Neuroglancer state""" + short_key: str = Field( + description="Short key for retrieving the stored state" + ) + short_name: Optional[str] = Field( + description="Optional human-friendly name for the short link", + default=None + ) + state_url: str = Field( + description="Absolute URL to the stored state JSON" + ) + neuroglancer_url: str = Field( + description="Neuroglancer URL that references the stored state" + ) + + +class NeuroglancerShortLink(BaseModel): + """Stored Neuroglancer short link""" + short_key: str = Field( + description="Short key for retrieving the stored state" + ) + short_name: Optional[str] = Field( + description="Optional human-friendly name for the short link", + default=None + ) + created_at: datetime = Field( + description="When this short link was created" + ) + updated_at: datetime = Field( + description="When this short link was last updated" + ) + state_url: str = Field( + description="Absolute URL to the stored state JSON" + ) + neuroglancer_url: str = Field( + description="Neuroglancer URL that references the stored state" + ) + + +class NeuroglancerShortLinkResponse(BaseModel): + links: List[NeuroglancerShortLink] = Field( + description="A list of stored Neuroglancer short links" + ) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e62dafa7..92a6186f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,6 +13,7 @@ import Help from '@/components/Help'; import Jobs from '@/components/Jobs'; import Preferences from '@/components/Preferences'; import Links from '@/components/Links'; +import Views from '@/components/Views'; import Notifications from '@/components/Notifications'; import ErrorFallback from '@/components/ErrorFallback'; @@ -98,6 +99,14 @@ const AppComponent = () => { } path="links" /> + + + + } + path="views" + /> {tasksEnabled ? ( ; + url_base?: string; + short_name?: string; + }) => { + try { + await createNeuroglancerShortLinkMutation.mutateAsync(payload); + toast.success('View created'); + setShowCreateDialog(false); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to create view'; + toast.error(message); + } + }; + + return ( + <> + + Views + + + Views store Neuroglancer state for easy sharing. Create a short link and + share it with collaborators. + + setShowCreateDialog(true)} + variant="ghost" + > + + + } + loadingState={allNeuroglancerLinksQuery.isPending} + /> + {showCreateDialog ? ( + setShowCreateDialog(false)} + onCreate={handleCreate} + open={showCreateDialog} + pending={createNeuroglancerShortLinkMutation.isPending} + /> + ) : null} + + ); +} diff --git a/frontend/src/components/ui/Dialogs/NeuroglancerViewDialog.tsx b/frontend/src/components/ui/Dialogs/NeuroglancerViewDialog.tsx new file mode 100644 index 00000000..fc4fc70d --- /dev/null +++ b/frontend/src/components/ui/Dialogs/NeuroglancerViewDialog.tsx @@ -0,0 +1,204 @@ +import { useState } from 'react'; +import type { ChangeEvent } from 'react'; +import { Button, Input, Typography } from '@material-tailwind/react'; + +import FgDialog from '@/components/ui/Dialogs/FgDialog'; + +type NeuroglancerViewDialogProps = { + readonly open: boolean; + readonly pending: boolean; + readonly onClose: () => void; + readonly onCreate: (payload: { + url?: string; + state?: Record; + url_base?: string; + short_name?: string; + }) => Promise; +}; + +type InputMode = 'url' | 'state'; + +export default function NeuroglancerViewDialog({ + open, + pending, + onClose, + onCreate +}: NeuroglancerViewDialogProps) { + const [inputMode, setInputMode] = useState('url'); + const [neuroglancerUrl, setNeuroglancerUrl] = useState(''); + const [stateJson, setStateJson] = useState(''); + const [urlBase, setUrlBase] = useState( + 'https://neuroglancer-demo.appspot.com/' + ); + const [shortName, setShortName] = useState(''); + const [error, setError] = useState(null); + + const resetAndClose = () => { + setError(null); + onClose(); + }; + + const handleCreate = async () => { + setError(null); + + if (inputMode === 'url') { + if (!neuroglancerUrl.trim()) { + setError('Please provide a Neuroglancer URL.'); + return; + } + await onCreate({ + url: neuroglancerUrl.trim(), + short_name: shortName.trim() || undefined + }); + return; + } + + if (!stateJson.trim()) { + setError('Please provide a Neuroglancer state JSON object.'); + return; + } + if (!urlBase.trim()) { + setError('Please provide a Neuroglancer base URL.'); + return; + } + + try { + const parsed = JSON.parse(stateJson); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + setError('State JSON must be a JSON object.'); + return; + } + await onCreate({ + state: parsed, + url_base: urlBase.trim(), + short_name: shortName.trim() || undefined + }); + } catch { + setError('State JSON must be valid JSON.'); + } + }; + + const handleInputModeChange = (mode: InputMode) => { + setInputMode(mode); + setError(null); + }; + + return ( + +
+
+ + Create Neuroglancer View + +
+ + +
+
+ +
+ + Short name (optional) + + ) => + setShortName(e.target.value) + } + placeholder="Example: Hemibrain view" + value={shortName} + /> +
+ + {inputMode === 'url' ? ( +
+ + Neuroglancer URL + + ) => + setNeuroglancerUrl(e.target.value) + } + placeholder="https://neuroglancer-demo.appspot.com/#!{...}" + value={neuroglancerUrl} + /> +
+ ) : ( + <> +
+ + Neuroglancer base URL + + ) => + setUrlBase(e.target.value) + } + placeholder="https://neuroglancer-demo.appspot.com/" + value={urlBase} + /> +
+
+ + State JSON + + ) => + setStateJson(e.target.value) + } + placeholder='{"layers":[...]}' + value={stateJson} + /> +
+ + )} + + {error ? ( + + {error} + + ) : null} + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/ui/Navbar/Navbar.tsx b/frontend/src/components/ui/Navbar/Navbar.tsx index 78db86d5..e5283f0c 100644 --- a/frontend/src/components/ui/Navbar/Navbar.tsx +++ b/frontend/src/components/ui/Navbar/Navbar.tsx @@ -13,7 +13,8 @@ import { HiOutlineMenu, HiOutlineX, HiOutlineShare, - HiOutlineSun + HiOutlineSun, + HiOutlineEye } from 'react-icons/hi'; import { HiOutlineFolder, HiOutlineBriefcase } from 'react-icons/hi2'; import { TbBrandGithub } from 'react-icons/tb'; @@ -34,6 +35,11 @@ const LINKS = [ title: 'Data Links', href: '/links' }, + { + icon: HiOutlineEye, + title: 'Views', + href: '/views' + }, { icon: HiOutlineBriefcase, title: 'Tasks', diff --git a/frontend/src/components/ui/Table/TableCard.tsx b/frontend/src/components/ui/Table/TableCard.tsx index d454a919..cd3cc11c 100644 --- a/frontend/src/components/ui/Table/TableCard.tsx +++ b/frontend/src/components/ui/Table/TableCard.tsx @@ -65,7 +65,7 @@ declare module '@tanstack/react-table' { } import type { PathCellValue } from './linksColumns'; -type DataType = 'data links' | 'tasks'; +type DataType = 'data links' | 'tasks' | 'views'; type TableProps = { readonly columns: ColumnDef[]; @@ -74,6 +74,7 @@ type TableProps = { readonly errorState: Error | unknown; readonly gridColsClass: string; readonly loadingState: boolean; + readonly headerActions?: ReactNode; }; function SortIcons({ @@ -218,13 +219,15 @@ function TableHeader({ globalFilter, setGlobalFilter, clearSearch, - inputRef + inputRef, + headerActions }: { readonly table: ReturnType; readonly globalFilter: string; readonly setGlobalFilter: (value: string) => void; readonly clearSearch: () => void; readonly inputRef: React.RefObject; + readonly headerActions?: ReactNode; }) { return (
@@ -305,6 +308,9 @@ function TableHeader({ ) : null}
+ {headerActions ? ( +
{headerActions}
+ ) : null} ); } @@ -331,7 +337,8 @@ function Table({ gridColsClass, loadingState, errorState, - dataType + dataType, + headerActions }: TableProps) { const [sorting, setSorting] = useState([]); const [globalFilter, setGlobalFilter] = useState(''); @@ -420,6 +427,7 @@ function Table({ ({ gridColsClass, loadingState, errorState, - dataType + dataType, + headerActions }: TableProps) { return ( @@ -507,6 +516,7 @@ function TableCard({ dataType={dataType} errorState={errorState} gridColsClass={gridColsClass} + headerActions={headerActions} loadingState={loadingState} /> diff --git a/frontend/src/components/ui/Table/viewsColumns.tsx b/frontend/src/components/ui/Table/viewsColumns.tsx new file mode 100644 index 00000000..e266d86c --- /dev/null +++ b/frontend/src/components/ui/Table/viewsColumns.tsx @@ -0,0 +1,196 @@ +import { useMemo } from 'react'; +import type { MouseEvent } from 'react'; +import { Typography } from '@material-tailwind/react'; +import type { ColumnDef } from '@tanstack/react-table'; + +import type { NeuroglancerShortLink } from '@/queries/neuroglancerQueries'; +import { formatDateString } from '@/utils'; +import FgTooltip from '../widgets/FgTooltip'; +import DataLinksActionsMenu from '@/components/ui/Menus/DataLinksActions'; +import type { MenuItem } from '@/components/ui/Menus/FgMenuItems'; +import toast from 'react-hot-toast'; +import { copyToClipboard } from '@/utils/copyText'; + +const TRIGGER_CLASSES = 'h-min max-w-full'; + +type ViewRowActionProps = { + item: NeuroglancerShortLink; +}; + +function ActionsCell({ item }: { readonly item: NeuroglancerShortLink }) { + const menuItems: MenuItem[] = [ + { + name: 'Copy Neuroglancer URL', + action: async ({ item }) => { + const result = await copyToClipboard(item.neuroglancer_url); + if (result.success) { + toast.success('Neuroglancer URL copied'); + } else { + toast.error(`Failed to copy: ${result.error}`); + } + } + }, + { + name: 'Copy state URL', + action: async ({ item }) => { + const result = await copyToClipboard(item.state_url); + if (result.success) { + toast.success('State URL copied'); + } else { + toast.error(`Failed to copy: ${result.error}`); + } + } + }, + { + name: 'Copy short key', + action: async ({ item }) => { + const result = await copyToClipboard(item.short_key); + if (result.success) { + toast.success('Short key copied'); + } else { + toast.error(`Failed to copy: ${result.error}`); + } + } + } + ]; + + return ( +
+
e.stopPropagation()}> + + actionProps={{ item }} + menuItems={menuItems} + /> +
+
+ ); +} + +export function useViewsColumns(): ColumnDef[] { + return useMemo( + () => [ + { + accessorKey: 'short_name', + header: 'Name', + cell: ({ row, table }) => { + const item = row.original; + const label = item.short_name || item.short_key; + const onContextMenu = table.options.meta?.onCellContextMenu; + return ( +
{ + e.preventDefault(); + onContextMenu?.(e, { value: label }); + }} + > + + + {label} + + +
+ ); + }, + sortingFn: (rowA, rowB) => { + const a = rowA.original.short_name || rowA.original.short_key; + const b = rowB.original.short_name || rowB.original.short_key; + return a.localeCompare(b); + }, + enableSorting: true + }, + { + accessorKey: 'neuroglancer_url', + header: 'Neuroglancer URL', + cell: ({ row, table }) => { + const item = row.original; + const onContextMenu = table.options.meta?.onCellContextMenu; + return ( +
) => { + e.preventDefault(); + onContextMenu?.(e, { value: item.neuroglancer_url }); + }} + > + + + {item.neuroglancer_url} + + +
+ ); + }, + enableSorting: false + }, + { + accessorKey: 'created_at', + header: 'Date Created', + cell: ({ cell, table }) => { + const formattedDate = formatDateString(cell.getValue() as string); + const onContextMenu = table.options.meta?.onCellContextMenu; + return ( +
{ + e.preventDefault(); + onContextMenu?.(e, { value: formattedDate }); + }} + > + + + {formattedDate} + + +
+ ); + }, + enableSorting: true + }, + { + accessorKey: 'short_key', + header: 'Key', + cell: ({ cell, getValue, table }) => { + const key = getValue() as string; + const onContextMenu = table.options.meta?.onCellContextMenu; + return ( +
{ + e.preventDefault(); + onContextMenu?.(e, { value: key }); + }} + > + + + {key} + + +
+ ); + }, + enableSorting: true + }, + { + id: 'actions', + header: 'Actions', + cell: ({ row }) => , + enableSorting: false + } + ], + [] + ); +} diff --git a/frontend/src/contexts/NeuroglancerContext.tsx b/frontend/src/contexts/NeuroglancerContext.tsx new file mode 100644 index 00000000..76743ee4 --- /dev/null +++ b/frontend/src/contexts/NeuroglancerContext.tsx @@ -0,0 +1,49 @@ +import { createContext, useContext } from 'react'; +import type { ReactNode } from 'react'; + +import { + useNeuroglancerShortLinksQuery, + useCreateNeuroglancerShortLinkMutation +} from '@/queries/neuroglancerQueries'; + +type NeuroglancerContextType = { + allNeuroglancerLinksQuery: ReturnType; + createNeuroglancerShortLinkMutation: ReturnType< + typeof useCreateNeuroglancerShortLinkMutation + >; +}; + +const NeuroglancerContext = createContext(null); + +export const useNeuroglancerContext = () => { + const context = useContext(NeuroglancerContext); + if (!context) { + throw new Error( + 'useNeuroglancerContext must be used within a NeuroglancerProvider' + ); + } + return context; +}; + +export const NeuroglancerProvider = ({ + children +}: { + readonly children: ReactNode; +}) => { + const allNeuroglancerLinksQuery = useNeuroglancerShortLinksQuery(); + const createNeuroglancerShortLinkMutation = + useCreateNeuroglancerShortLinkMutation(); + + const value: NeuroglancerContextType = { + allNeuroglancerLinksQuery, + createNeuroglancerShortLinkMutation + }; + + return ( + + {children} + + ); +}; + +export default NeuroglancerContext; diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index e7096f8c..6670b1e2 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -15,6 +15,7 @@ import { ExternalBucketProvider } from '@/contexts/ExternalBucketContext'; import { ProfileContextProvider } from '@/contexts/ProfileContext'; import { NotificationProvider } from '@/contexts/NotificationsContext'; import { ServerHealthProvider } from '@/contexts/ServerHealthContext'; +import { NeuroglancerProvider } from '@/contexts/NeuroglancerContext'; import FileglancerNavbar from '@/components/ui/Navbar/Navbar'; import Notifications from '@/components/ui/Notifications/Notifications'; import ErrorFallback from '@/components/ErrorFallback'; @@ -67,13 +68,15 @@ export const MainLayout = () => { - - - - - - - + + + + + + + + + diff --git a/frontend/src/queries/neuroglancerQueries.ts b/frontend/src/queries/neuroglancerQueries.ts new file mode 100644 index 00000000..a5425570 --- /dev/null +++ b/frontend/src/queries/neuroglancerQueries.ts @@ -0,0 +1,106 @@ +import { + useQuery, + useMutation, + useQueryClient, + UseQueryResult, + UseMutationResult +} from '@tanstack/react-query'; + +import { sendFetchRequest, HTTPError } from '@/utils'; +import { toHttpError } from '@/utils/errorHandling'; + +export type NeuroglancerShortLink = { + short_key: string; + short_name: string | null; + created_at: string; + updated_at: string; + state_url: string; + neuroglancer_url: string; +}; + +type NeuroglancerShortLinksResponse = { + links?: NeuroglancerShortLink[]; +}; + +type NeuroglancerShortenResponse = { + short_key: string; + short_name: string | null; + state_url: string; + neuroglancer_url: string; +}; + +type CreateShortLinkPayload = { + url?: string; + state?: Record; + url_base?: string; + short_name?: string; + short_key?: string; +}; + +export const neuroglancerQueryKeys = { + all: ['neuroglancerLinks'] as const, + list: () => ['neuroglancerLinks', 'list'] as const +}; + +const fetchNeuroglancerShortLinks = async ( + signal?: AbortSignal +): Promise => { + try { + const response = await sendFetchRequest( + '/api/neuroglancer/short-links', + 'GET', + undefined, + { signal } + ); + if (response.status === 404) { + return []; + } + if (!response.ok) { + throw await toHttpError(response); + } + const data = (await response.json()) as NeuroglancerShortLinksResponse; + return data.links ?? []; + } catch (error) { + if (error instanceof HTTPError && error.responseCode === 404) { + return []; + } + throw error; + } +}; + +export function useNeuroglancerShortLinksQuery(): UseQueryResult< + NeuroglancerShortLink[], + Error +> { + return useQuery({ + queryKey: neuroglancerQueryKeys.list(), + queryFn: ({ signal }) => fetchNeuroglancerShortLinks(signal) + }); +} + +export function useCreateNeuroglancerShortLinkMutation(): UseMutationResult< + NeuroglancerShortenResponse, + Error, + CreateShortLinkPayload +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (payload: CreateShortLinkPayload) => { + const response = await sendFetchRequest( + '/api/neuroglancer/shorten', + 'POST', + payload + ); + if (!response.ok) { + throw await toHttpError(response); + } + return (await response.json()) as NeuroglancerShortenResponse; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: neuroglancerQueryKeys.all + }); + } + }); +} diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 4be6ab55..77e78a62 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -1,8 +1,10 @@ +import json import os import tempfile import shutil from datetime import datetime, timezone from unittest.mock import patch, MagicMock +from urllib.parse import quote import pytest from fastapi.testclient import TestClient @@ -190,6 +192,38 @@ def test_delete_preference(test_client): assert response.status_code == 404 +def test_neuroglancer_shortener(test_client): + """Test creating and retrieving a shortened Neuroglancer state""" + state = {"layers": [], "title": "Example"} + encoded_state = quote(json.dumps(state)) + url = f"https://neuroglancer-demo.appspot.com/#!{encoded_state}" + + response = test_client.post( + "/api/neuroglancer/shorten", + json={"url": url, "short_name": "Example View"} + ) + assert response.status_code == 200 + data = response.json() + assert "short_key" in data + assert data["short_name"] == "Example View" + assert "state_url" in data + assert "neuroglancer_url" in data + + short_key = data["short_key"] + assert data["state_url"].endswith(f"/ng/{short_key}") + assert data["neuroglancer_url"].startswith("https://neuroglancer-demo.appspot.com/#!") + + state_response = test_client.get(f"/ng/{short_key}") + assert state_response.status_code == 200 + assert state_response.json() == state + + list_response = test_client.get("/api/neuroglancer/short-links") + assert list_response.status_code == 200 + list_data = list_response.json() + assert "links" in list_data + assert any(link["short_key"] == short_key for link in list_data["links"]) + + def test_create_proxied_path(test_client, temp_dir): """Test creating a new proxied path""" path = "test_proxied_path" @@ -745,4 +779,3 @@ def test_delete_ticket_not_found(mock_delete, test_client): assert response.status_code == 404 data = response.json() assert "error" in data -