From 0501c2bd52b861b91b6f7f12d296073e3207c617 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Fri, 19 Dec 2025 08:15:06 -0500 Subject: [PATCH 01/19] initial implementation of short links using codex --- ...f0e6b8c91_add_neuroglancer_states_table.py | 42 ++++ ...1_add_short_name_to_neuroglancer_states.py | 24 +++ fileglancer/app.py | 123 +++++++++++ fileglancer/database.py | 75 +++++++ fileglancer/filestore.py | 9 +- fileglancer/model.py | 70 +++++++ frontend/src/App.tsx | 9 + frontend/src/components/Views.tsx | 70 +++++++ .../ui/Dialogs/NeuroglancerViewDialog.tsx | 196 ++++++++++++++++++ frontend/src/components/ui/Navbar/Navbar.tsx | 8 +- .../src/components/ui/Table/TableCard.tsx | 18 +- .../src/components/ui/Table/viewsColumns.tsx | 196 ++++++++++++++++++ frontend/src/contexts/NeuroglancerContext.tsx | 53 +++++ frontend/src/layouts/MainLayout.tsx | 17 +- frontend/src/queries/neuroglancerQueries.ts | 106 ++++++++++ tests/test_endpoints.py | 35 +++- 16 files changed, 1036 insertions(+), 15 deletions(-) create mode 100644 fileglancer/alembic/versions/2d1f0e6b8c91_add_neuroglancer_states_table.py create mode 100644 fileglancer/alembic/versions/3c5b7a9f2c11_add_short_name_to_neuroglancer_states.py create mode 100644 frontend/src/components/Views.tsx create mode 100644 frontend/src/components/ui/Dialogs/NeuroglancerViewDialog.tsx create mode 100644 frontend/src/components/ui/Table/viewsColumns.tsx create mode 100644 frontend/src/contexts/NeuroglancerContext.tsx create mode 100644 frontend/src/queries/neuroglancerQueries.ts 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..7406849b --- /dev/null +++ b/frontend/src/components/ui/Dialogs/NeuroglancerViewDialog.tsx @@ -0,0 +1,196 @@ +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 (err) { + 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..b6f3525f 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 ( @@ -506,6 +515,7 @@ function TableCard({ data={data} dataType={dataType} errorState={errorState} + headerActions={headerActions} gridColsClass={gridColsClass} 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..eeccf20c --- /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={cell.id} + > + + + {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..84a4d531 --- /dev/null +++ b/frontend/src/contexts/NeuroglancerContext.tsx @@ -0,0 +1,53 @@ +import { createContext, useContext } from 'react'; +import type { ReactNode } from 'react'; + +import { + useNeuroglancerShortLinksQuery, + useCreateNeuroglancerShortLinkMutation +} from '@/queries/neuroglancerQueries'; + +type NeuroglancerContextType = { + allNeuroglancerLinksQuery: ReturnType< + typeof useNeuroglancerShortLinksQuery + >; + 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 - From 55040dd7b21f5024d188ddfc52aad3366c4a15b3 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Tue, 23 Dec 2025 15:04:31 -0500 Subject: [PATCH 02/19] formatting --- .../ui/Dialogs/NeuroglancerViewDialog.tsx | 14 +++++++++++--- frontend/src/contexts/NeuroglancerContext.tsx | 8 ++------ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/ui/Dialogs/NeuroglancerViewDialog.tsx b/frontend/src/components/ui/Dialogs/NeuroglancerViewDialog.tsx index 7406849b..bf7b8c23 100644 --- a/frontend/src/components/ui/Dialogs/NeuroglancerViewDialog.tsx +++ b/frontend/src/components/ui/Dialogs/NeuroglancerViewDialog.tsx @@ -27,7 +27,9 @@ export default function NeuroglancerViewDialog({ const [inputMode, setInputMode] = useState('url'); const [neuroglancerUrl, setNeuroglancerUrl] = useState(''); const [stateJson, setStateJson] = useState(''); - const [urlBase, setUrlBase] = useState('https://neuroglancer-demo.appspot.com/'); + const [urlBase, setUrlBase] = useState( + 'https://neuroglancer-demo.appspot.com/' + ); const [shortName, setShortName] = useState(''); const [error, setError] = useState(null); @@ -139,7 +141,10 @@ export default function NeuroglancerViewDialog({ ) : ( <>
- + Neuroglancer base URL
- + State JSON ; + allNeuroglancerLinksQuery: ReturnType; createNeuroglancerShortLinkMutation: ReturnType< typeof useCreateNeuroglancerShortLinkMutation >; }; -const NeuroglancerContext = createContext( - null -); +const NeuroglancerContext = createContext(null); export const useNeuroglancerContext = () => { const context = useContext(NeuroglancerContext); From 79c13a5b2a09917a9058d3d4434ab40e7468ae14 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Tue, 23 Dec 2025 15:05:46 -0500 Subject: [PATCH 03/19] fixed eslint warnings --- frontend/src/components/ui/Dialogs/NeuroglancerViewDialog.tsx | 2 +- frontend/src/components/ui/Table/TableCard.tsx | 2 +- frontend/src/components/ui/Table/viewsColumns.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/ui/Dialogs/NeuroglancerViewDialog.tsx b/frontend/src/components/ui/Dialogs/NeuroglancerViewDialog.tsx index bf7b8c23..fc4fc70d 100644 --- a/frontend/src/components/ui/Dialogs/NeuroglancerViewDialog.tsx +++ b/frontend/src/components/ui/Dialogs/NeuroglancerViewDialog.tsx @@ -73,7 +73,7 @@ export default function NeuroglancerViewDialog({ url_base: urlBase.trim(), short_name: shortName.trim() || undefined }); - } catch (err) { + } catch { setError('State JSON must be valid JSON.'); } }; diff --git a/frontend/src/components/ui/Table/TableCard.tsx b/frontend/src/components/ui/Table/TableCard.tsx index b6f3525f..cd3cc11c 100644 --- a/frontend/src/components/ui/Table/TableCard.tsx +++ b/frontend/src/components/ui/Table/TableCard.tsx @@ -515,8 +515,8 @@ function TableCard({ data={data} dataType={dataType} errorState={errorState} - headerActions={headerActions} gridColsClass={gridColsClass} + headerActions={headerActions} loadingState={loadingState} /> diff --git a/frontend/src/components/ui/Table/viewsColumns.tsx b/frontend/src/components/ui/Table/viewsColumns.tsx index eeccf20c..e266d86c 100644 --- a/frontend/src/components/ui/Table/viewsColumns.tsx +++ b/frontend/src/components/ui/Table/viewsColumns.tsx @@ -168,11 +168,11 @@ export function useViewsColumns(): ColumnDef[] { return (
{ e.preventDefault(); onContextMenu?.(e, { value: key }); }} - key={cell.id} > From 7d222c95d60f4b72001570da92a0df30bfc81a23 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 5 Jan 2026 16:17:00 -0500 Subject: [PATCH 04/19] moved new link button --- frontend/src/components/Views.tsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/Views.tsx b/frontend/src/components/Views.tsx index 71fb798b..51fb238e 100644 --- a/frontend/src/components/Views.tsx +++ b/frontend/src/components/Views.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { IconButton, Typography } from '@material-tailwind/react'; +import { Button, Typography } from '@material-tailwind/react'; import { HiOutlinePlus } from 'react-icons/hi'; import toast from 'react-hot-toast'; @@ -40,21 +40,21 @@ export default function 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 ? ( From 7cbb3ea3f3d2a414bc06f1ef1401e53c1466342c Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 5 Jan 2026 16:38:34 -0500 Subject: [PATCH 05/19] update dialog and naming --- frontend/src/components/Views.tsx | 16 +- .../ui/Dialogs/NeuroglancerViewDialog.tsx | 221 +++++------------- frontend/src/components/ui/Navbar/Navbar.tsx | 2 +- .../src/components/ui/Table/TableCard.tsx | 2 +- 4 files changed, 73 insertions(+), 168 deletions(-) diff --git a/frontend/src/components/Views.tsx b/frontend/src/components/Views.tsx index 51fb238e..0cce68ea 100644 --- a/frontend/src/components/Views.tsx +++ b/frontend/src/components/Views.tsx @@ -15,18 +15,16 @@ export default function Views() { const [showCreateDialog, setShowCreateDialog] = useState(false); const handleCreate = async (payload: { - url?: string; - state?: Record; - url_base?: string; + url: string; short_name?: string; }) => { try { await createNeuroglancerShortLinkMutation.mutateAsync(payload); - toast.success('View created'); + toast.success('Link created'); setShowCreateDialog(false); } catch (error) { const message = - error instanceof Error ? error.message : 'Failed to create view'; + error instanceof Error ? error.message : 'Failed to create link'; toast.error(message); } }; @@ -34,11 +32,11 @@ export default function Views() { return ( <> - Views + Neuroglancer Links - Views store Neuroglancer state for easy sharing. Create a short link and - share it with collaborators. + Store Neuroglancer state for easy sharing. Create a short link and share + it with collaborators.
- -
-
- -
- - 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} - /> -
- - )} - +
+ + Create short Neuroglancer link + + + Neuroglancer URL + + ) => + setNeuroglancerUrl(e.target.value) + } + placeholder="https://neuroglancer-demo.appspot.com/#!{...}" + type="text" + value={neuroglancerUrl} + /> + + Name (optional) + + ) => + setShortName(e.target.value) + } + placeholder="Example: Hemibrain view" + type="text" + value={shortName} + /> {error ? ( - + {error} ) : null} - -
- - -
+
+
+ +
); diff --git a/frontend/src/components/ui/Navbar/Navbar.tsx b/frontend/src/components/ui/Navbar/Navbar.tsx index e5283f0c..569732b8 100644 --- a/frontend/src/components/ui/Navbar/Navbar.tsx +++ b/frontend/src/components/ui/Navbar/Navbar.tsx @@ -37,7 +37,7 @@ const LINKS = [ }, { icon: HiOutlineEye, - title: 'Views', + title: 'NG Links', href: '/views' }, { diff --git a/frontend/src/components/ui/Table/TableCard.tsx b/frontend/src/components/ui/Table/TableCard.tsx index cd3cc11c..8f0d8929 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' | 'views'; +type DataType = 'data links' | 'tasks' | 'NG links'; type TableProps = { readonly columns: ColumnDef[]; From 3b9dea1300884a12f68133c90ce4712a1e26e349 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 5 Jan 2026 16:46:45 -0500 Subject: [PATCH 06/19] deleted duplicate migration --- ...1_add_short_name_to_neuroglancer_states.py | 24 ------------------- 1 file changed, 24 deletions(-) delete mode 100644 fileglancer/alembic/versions/3c5b7a9f2c11_add_short_name_to_neuroglancer_states.py 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 deleted file mode 100644 index 8922e797..00000000 --- a/fileglancer/alembic/versions/3c5b7a9f2c11_add_short_name_to_neuroglancer_states.py +++ /dev/null @@ -1,24 +0,0 @@ -"""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') From fa1dbb6b3a748558b5a41231ebe4965564b3cad0 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 5 Jan 2026 16:58:02 -0500 Subject: [PATCH 07/19] added optional title --- fileglancer/app.py | 9 ++++++++ fileglancer/model.py | 12 ++++++++++ frontend/src/components/Views.tsx | 1 + .../ui/Dialogs/NeuroglancerViewDialog.tsx | 22 ++++++++++++++++++- frontend/src/queries/neuroglancerQueries.ts | 2 ++ 5 files changed, 45 insertions(+), 1 deletion(-) diff --git a/fileglancer/app.py b/fileglancer/app.py index a7e9f7d0..1f5a51ab 100644 --- a/fileglancer/app.py +++ b/fileglancer/app.py @@ -692,6 +692,7 @@ async def shorten_neuroglancer_state(request: Request, if short_key: _validate_short_key(short_key) short_name = payload.short_name.strip() if payload.short_name else None + title = payload.title.strip() if payload.title else None if payload.url and payload.state: raise HTTPException(status_code=400, detail="Provide either url or state, not both") @@ -710,6 +711,10 @@ async def shorten_neuroglancer_state(request: Request, else: raise HTTPException(status_code=400, detail="Either url or state must be provided") + # Add title to state if provided + if title: + state = {**state, "title": title} + with db.get_db_session(settings.db_url) as session: try: entry = db.create_neuroglancer_state( @@ -730,6 +735,7 @@ async def shorten_neuroglancer_state(request: Request, return NeuroglancerShortenResponse( short_key=created_short_key, short_name=created_short_name, + title=title, state_url=state_url, neuroglancer_url=neuroglancer_url ) @@ -824,9 +830,12 @@ async def get_neuroglancer_short_links(request: Request, 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}" + # Read title from the stored state + title = entry.state.get("title") if isinstance(entry.state, dict) else None links.append(NeuroglancerShortLink( short_key=entry.short_key, short_name=entry.short_name, + title=title, created_at=entry.created_at, updated_at=entry.updated_at, state_url=state_url, diff --git a/fileglancer/model.py b/fileglancer/model.py index 066948b9..7af47c22 100644 --- a/fileglancer/model.py +++ b/fileglancer/model.py @@ -226,6 +226,10 @@ class NeuroglancerShortenRequest(BaseModel): description="Optional human-friendly name for the short link", default=None ) + title: Optional[str] = Field( + description="Optional title that appears in the Neuroglancer tab name", + default=None + ) url: Optional[str] = Field( description="Neuroglancer URL containing the encoded JSON state after #!", default=None @@ -249,6 +253,10 @@ class NeuroglancerShortenResponse(BaseModel): description="Optional human-friendly name for the short link", default=None ) + title: Optional[str] = Field( + description="Optional title that appears in the Neuroglancer tab name", + default=None + ) state_url: str = Field( description="Absolute URL to the stored state JSON" ) @@ -266,6 +274,10 @@ class NeuroglancerShortLink(BaseModel): description="Optional human-friendly name for the short link", default=None ) + title: Optional[str] = Field( + description="Optional title that appears in the Neuroglancer tab name", + default=None + ) created_at: datetime = Field( description="When this short link was created" ) diff --git a/frontend/src/components/Views.tsx b/frontend/src/components/Views.tsx index 0cce68ea..b62c9860 100644 --- a/frontend/src/components/Views.tsx +++ b/frontend/src/components/Views.tsx @@ -17,6 +17,7 @@ export default function Views() { const handleCreate = async (payload: { url: string; short_name?: string; + title?: string; }) => { try { await createNeuroglancerShortLinkMutation.mutateAsync(payload); diff --git a/frontend/src/components/ui/Dialogs/NeuroglancerViewDialog.tsx b/frontend/src/components/ui/Dialogs/NeuroglancerViewDialog.tsx index d1c1613a..3a59d494 100644 --- a/frontend/src/components/ui/Dialogs/NeuroglancerViewDialog.tsx +++ b/frontend/src/components/ui/Dialogs/NeuroglancerViewDialog.tsx @@ -11,6 +11,7 @@ type NeuroglancerViewDialogProps = { readonly onCreate: (payload: { url: string; short_name?: string; + title?: string; }) => Promise; }; @@ -22,6 +23,7 @@ export default function NeuroglancerViewDialog({ }: NeuroglancerViewDialogProps) { const [neuroglancerUrl, setNeuroglancerUrl] = useState(''); const [shortName, setShortName] = useState(''); + const [title, setTitle] = useState(''); const [error, setError] = useState(null); const resetAndClose = () => { @@ -39,7 +41,8 @@ export default function NeuroglancerViewDialog({ await onCreate({ url: neuroglancerUrl.trim(), - short_name: shortName.trim() || undefined + short_name: shortName.trim() || undefined, + title: title.trim() || undefined }); }; @@ -67,6 +70,23 @@ export default function NeuroglancerViewDialog({ type="text" value={neuroglancerUrl} /> + + Title (optional, appears in tab name) + + ) => + setTitle(e.target.value) + } + placeholder="Example: Hemibrain EM" + type="text" + value={title} + /> Date: Mon, 5 Jan 2026 17:12:08 -0500 Subject: [PATCH 08/19] use name in url --- fileglancer/app.py | 31 +++++++++++++-- .../ui/Dialogs/NeuroglancerViewDialog.tsx | 4 +- tests/test_endpoints.py | 39 +++++++++++++++++-- 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/fileglancer/app.py b/fileglancer/app.py index 1f5a51ab..b9345c39 100644 --- a/fileglancer/app.py +++ b/fileglancer/app.py @@ -730,7 +730,11 @@ async def shorten_neuroglancer_state(request: Request, 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)) + # Generate URL based on whether short_name is provided + if created_short_name: + state_url = str(request.url_for("get_neuroglancer_state", short_key=created_short_key, short_name=created_short_name)) + else: + state_url = str(request.url_for("get_neuroglancer_state_simple", short_key=created_short_key)) neuroglancer_url = f"{url_base}#!{state_url}" return NeuroglancerShortenResponse( short_key=created_short_key, @@ -811,12 +815,27 @@ 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")): + @app.get("/ng/{short_key}", name="get_neuroglancer_state_simple", include_in_schema=False) + async def get_neuroglancer_state_simple(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") + # If this entry has a short_name, require it in the URL + if entry.short_name: + raise HTTPException(status_code=404, detail="Neuroglancer state not found") + return JSONResponse(content=entry.state, headers={"Cache-Control": "no-store"}) + + @app.get("/ng/{short_key}/{short_name}", 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"), + short_name: str = Path(..., description="Short name 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") + # Validate short_name matches + if entry.short_name != short_name: + raise HTTPException(status_code=404, detail="Neuroglancer state not found") return JSONResponse(content=entry.state, headers={"Cache-Control": "no-store"}) @@ -828,7 +847,11 @@ async def get_neuroglancer_short_links(request: Request, 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)) + # Generate URL based on whether short_name is provided + if entry.short_name: + state_url = str(request.url_for("get_neuroglancer_state", short_key=entry.short_key, short_name=entry.short_name)) + else: + state_url = str(request.url_for("get_neuroglancer_state_simple", short_key=entry.short_key)) neuroglancer_url = f"{entry.url_base}#!{state_url}" # Read title from the stored state title = entry.state.get("title") if isinstance(entry.state, dict) else None diff --git a/frontend/src/components/ui/Dialogs/NeuroglancerViewDialog.tsx b/frontend/src/components/ui/Dialogs/NeuroglancerViewDialog.tsx index 3a59d494..37ea3fa7 100644 --- a/frontend/src/components/ui/Dialogs/NeuroglancerViewDialog.tsx +++ b/frontend/src/components/ui/Dialogs/NeuroglancerViewDialog.tsx @@ -92,7 +92,7 @@ export default function NeuroglancerViewDialog({ className="text-foreground font-semibold" htmlFor="short-name" > - Name (optional) + Name (optional, used in shortened URL) ) => setShortName(e.target.value) } - placeholder="Example: Hemibrain view" + placeholder="Example: hemibrain-em-1" type="text" value={shortName} /> diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 77e78a62..0ad31f3c 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -198,25 +198,32 @@ def test_neuroglancer_shortener(test_client): encoded_state = quote(json.dumps(state)) url = f"https://neuroglancer-demo.appspot.com/#!{encoded_state}" + # Test with short_name - URL should include both short_key and short_name response = test_client.post( "/api/neuroglancer/shorten", - json={"url": url, "short_name": "Example View"} + 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 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}") + short_name = data["short_name"] + assert data["state_url"].endswith(f"/ng/{short_key}/{short_name}") assert data["neuroglancer_url"].startswith("https://neuroglancer-demo.appspot.com/#!") - state_response = test_client.get(f"/ng/{short_key}") + # Retrieving with both short_key and short_name should work + state_response = test_client.get(f"/ng/{short_key}/{short_name}") assert state_response.status_code == 200 assert state_response.json() == state + # Retrieving with only short_key should fail (404) + state_response_simple = test_client.get(f"/ng/{short_key}") + assert state_response_simple.status_code == 404 + list_response = test_client.get("/api/neuroglancer/short-links") assert list_response.status_code == 200 list_data = list_response.json() @@ -224,6 +231,30 @@ def test_neuroglancer_shortener(test_client): assert any(link["short_key"] == short_key for link in list_data["links"]) +def test_neuroglancer_shortener_no_name(test_client): + """Test creating a shortened Neuroglancer state without short_name""" + state = {"layers": []} + 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} + ) + assert response.status_code == 200 + data = response.json() + assert "short_key" in data + assert data["short_name"] is None + + short_key = data["short_key"] + assert data["state_url"].endswith(f"/ng/{short_key}") + + # Retrieving with only short_key should work when no short_name was set + state_response = test_client.get(f"/ng/{short_key}") + assert state_response.status_code == 200 + assert state_response.json() == state + + def test_create_proxied_path(test_client, temp_dir): """Test creating a new proxied path""" path = "test_proxied_path" From fa75a752b642f9c757677ef1fb22653f3ccb575c Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 5 Jan 2026 17:20:49 -0500 Subject: [PATCH 09/19] added edit action --- fileglancer/app.py | 42 +++++++ fileglancer/database.py | 21 ++++ fileglancer/model.py | 11 ++ frontend/src/components/Views.tsx | 64 ++++++++-- .../ui/Dialogs/NeuroglancerViewDialog.tsx | 117 +++++++++++++----- .../src/components/ui/Table/viewsColumns.tsx | 25 +++- frontend/src/contexts/NeuroglancerContext.tsx | 11 +- frontend/src/queries/neuroglancerQueries.ts | 33 +++++ 8 files changed, 274 insertions(+), 50 deletions(-) diff --git a/fileglancer/app.py b/fileglancer/app.py index b9345c39..7df0964c 100644 --- a/fileglancer/app.py +++ b/fileglancer/app.py @@ -745,6 +745,48 @@ async def shorten_neuroglancer_state(request: Request, ) + @app.put("/api/neuroglancer/short-links/{short_key}", response_model=NeuroglancerShortenResponse, + description="Update a stored Neuroglancer state") + async def update_neuroglancer_short_link(request: Request, + short_key: str, + payload: NeuroglancerUpdateRequest, + username: str = Depends(get_current_user)): + title = payload.title.strip() if payload.title else None + url_base, state = _parse_neuroglancer_url(payload.url.strip()) + + # Add title to state if provided + if title: + state = {**state, "title": title} + + with db.get_db_session(settings.db_url) as session: + entry = db.update_neuroglancer_state( + session, + username, + short_key, + url_base, + state + ) + if not entry: + raise HTTPException(status_code=404, detail="Neuroglancer state not found") + # Extract values before session closes + updated_short_key = entry.short_key + updated_short_name = entry.short_name + + # Generate URL based on whether short_name is present + if updated_short_name: + state_url = str(request.url_for("get_neuroglancer_state", short_key=updated_short_key, short_name=updated_short_name)) + else: + state_url = str(request.url_for("get_neuroglancer_state_simple", short_key=updated_short_key)) + neuroglancer_url = f"{url_base}#!{state_url}" + return NeuroglancerShortenResponse( + short_key=updated_short_key, + short_name=updated_short_name, + title=title, + 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"), diff --git a/fileglancer/database.py b/fileglancer/database.py index daf3c703..3022cb8b 100644 --- a/fileglancer/database.py +++ b/fileglancer/database.py @@ -646,6 +646,27 @@ def get_neuroglancer_states(session: Session, username: str) -> List[Neuroglance ) +def update_neuroglancer_state( + session: Session, + username: str, + short_key: str, + url_base: str, + state: Dict +) -> Optional[NeuroglancerStateDB]: + """Update a Neuroglancer state entry. Returns the updated entry or None if not found.""" + entry = session.query(NeuroglancerStateDB).filter_by( + short_key=short_key, + username=username + ).first() + if not entry: + return None + entry.url_base = url_base + entry.state = state + entry.updated_at = datetime.now(UTC) + session.commit() + return entry + + 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/model.py b/fileglancer/model.py index 7af47c22..425f83f2 100644 --- a/fileglancer/model.py +++ b/fileglancer/model.py @@ -244,6 +244,17 @@ class NeuroglancerShortenRequest(BaseModel): ) +class NeuroglancerUpdateRequest(BaseModel): + """Request payload for updating a Neuroglancer state""" + url: str = Field( + description="Neuroglancer URL containing the encoded JSON state after #!" + ) + title: Optional[str] = Field( + description="Optional title that appears in the Neuroglancer tab name", + default=None + ) + + class NeuroglancerShortenResponse(BaseModel): """Response payload for shortened Neuroglancer state""" short_key: str = Field( diff --git a/frontend/src/components/Views.tsx b/frontend/src/components/Views.tsx index b62c9860..89682337 100644 --- a/frontend/src/components/Views.tsx +++ b/frontend/src/components/Views.tsx @@ -7,12 +7,33 @@ import { TableCard } from '@/components/ui/Table/TableCard'; import { useViewsColumns } from '@/components/ui/Table/viewsColumns'; import NeuroglancerViewDialog from '@/components/ui/Dialogs/NeuroglancerViewDialog'; import { useNeuroglancerContext } from '@/contexts/NeuroglancerContext'; +import type { NeuroglancerShortLink } from '@/queries/neuroglancerQueries'; export default function Views() { - const { allNeuroglancerLinksQuery, createNeuroglancerShortLinkMutation } = - useNeuroglancerContext(); - const viewsColumns = useViewsColumns(); - const [showCreateDialog, setShowCreateDialog] = useState(false); + const { + allNeuroglancerLinksQuery, + createNeuroglancerShortLinkMutation, + updateNeuroglancerShortLinkMutation + } = useNeuroglancerContext(); + const [showDialog, setShowDialog] = useState(false); + const [editItem, setEditItem] = useState( + undefined + ); + + const handleOpenCreate = () => { + setEditItem(undefined); + setShowDialog(true); + }; + + const handleOpenEdit = (item: NeuroglancerShortLink) => { + setEditItem(item); + setShowDialog(true); + }; + + const handleClose = () => { + setShowDialog(false); + setEditItem(undefined); + }; const handleCreate = async (payload: { url: string; @@ -22,7 +43,7 @@ export default function Views() { try { await createNeuroglancerShortLinkMutation.mutateAsync(payload); toast.success('Link created'); - setShowCreateDialog(false); + handleClose(); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to create link'; @@ -30,6 +51,24 @@ export default function Views() { } }; + const handleUpdate = async (payload: { + short_key: string; + url: string; + title?: string; + }) => { + try { + await updateNeuroglancerShortLinkMutation.mutateAsync(payload); + toast.success('Link updated'); + handleClose(); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to update link'; + toast.error(message); + } + }; + + const viewsColumns = useViewsColumns(handleOpenEdit); + return ( <> @@ -42,7 +81,7 @@ export default function Views() {
void; }; @@ -25,7 +25,7 @@ function ActionsCell({ readonly item: NeuroglancerShortLink; readonly onEdit: (item: NeuroglancerShortLink) => void; }) { - const menuItems: MenuItem[] = [ + const menuItems: MenuItem[] = [ { name: 'Edit', action: ({ item, onEdit }) => { @@ -59,7 +59,7 @@ function ActionsCell({ return (
e.stopPropagation()}> - + actionProps={{ item, onEdit }} menuItems={menuItems} /> @@ -68,7 +68,7 @@ function ActionsCell({ ); } -export function useViewsColumns( +export function useNGLinksColumns( onEdit: (item: NeuroglancerShortLink) => void ): ColumnDef[] { return useMemo( From be6857c186aba7d552dd00795e1688b0b2b633c2 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 5 Jan 2026 17:37:36 -0500 Subject: [PATCH 12/19] updated URL paths --- fileglancer/app.py | 6 +++--- frontend/src/App.tsx | 2 +- frontend/src/components/ui/Navbar/Navbar.tsx | 2 +- frontend/src/queries/neuroglancerQueries.ts | 6 +++--- tests/test_endpoints.py | 6 +++--- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/fileglancer/app.py b/fileglancer/app.py index 7df0964c..9ff03321 100644 --- a/fileglancer/app.py +++ b/fileglancer/app.py @@ -683,7 +683,7 @@ 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, + @app.post("/api/neuroglancer/nglinks", response_model=NeuroglancerShortenResponse, description="Store a Neuroglancer state and return a shortened link") async def shorten_neuroglancer_state(request: Request, payload: NeuroglancerShortenRequest, @@ -745,7 +745,7 @@ async def shorten_neuroglancer_state(request: Request, ) - @app.put("/api/neuroglancer/short-links/{short_key}", response_model=NeuroglancerShortenResponse, + @app.put("/api/neuroglancer/nglinks/{short_key}", response_model=NeuroglancerShortenResponse, description="Update a stored Neuroglancer state") async def update_neuroglancer_short_link(request: Request, short_key: str, @@ -881,7 +881,7 @@ async def get_neuroglancer_state(short_key: str = Path(..., description="Short k return JSONResponse(content=entry.state, headers={"Cache-Control": "no-store"}) - @app.get("/api/neuroglancer/short-links", response_model=NeuroglancerShortLinkResponse, + @app.get("/api/neuroglancer/nglinks", 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)): diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e02cfaed..fd7c97c6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -105,7 +105,7 @@ const AppComponent = () => { } - path="views" + path="nglinks" /> {tasksEnabled ? ( => { try { const response = await sendFetchRequest( - '/api/neuroglancer/short-links', + '/api/neuroglancer/nglinks', 'GET', undefined, { signal } @@ -96,7 +96,7 @@ export function useCreateNeuroglancerShortLinkMutation(): UseMutationResult< return useMutation({ mutationFn: async (payload: CreateShortLinkPayload) => { const response = await sendFetchRequest( - '/api/neuroglancer/shorten', + '/api/neuroglancer/nglinks', 'POST', payload ); @@ -123,7 +123,7 @@ export function useUpdateNeuroglancerShortLinkMutation(): UseMutationResult< return useMutation({ mutationFn: async (payload: UpdateShortLinkPayload) => { const response = await sendFetchRequest( - `/api/neuroglancer/short-links/${encodeURIComponent(payload.short_key)}`, + `/api/neuroglancer/nglinks/${encodeURIComponent(payload.short_key)}`, 'PUT', { url: payload.url, title: payload.title } ); diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 0ad31f3c..09d117ef 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -200,7 +200,7 @@ def test_neuroglancer_shortener(test_client): # Test with short_name - URL should include both short_key and short_name response = test_client.post( - "/api/neuroglancer/shorten", + "/api/neuroglancer/nglinks", json={"url": url, "short_name": "example-view"} ) assert response.status_code == 200 @@ -224,7 +224,7 @@ def test_neuroglancer_shortener(test_client): state_response_simple = test_client.get(f"/ng/{short_key}") assert state_response_simple.status_code == 404 - list_response = test_client.get("/api/neuroglancer/short-links") + list_response = test_client.get("/api/neuroglancer/nglinks") assert list_response.status_code == 200 list_data = list_response.json() assert "links" in list_data @@ -238,7 +238,7 @@ def test_neuroglancer_shortener_no_name(test_client): url = f"https://neuroglancer-demo.appspot.com/#!{encoded_state}" response = test_client.post( - "/api/neuroglancer/shorten", + "/api/neuroglancer/nglinks", json={"url": url} ) assert response.status_code == 200 From c8df93f7707ec1e9e7b2b7c478c7961a64d6f785 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 5 Jan 2026 17:54:00 -0500 Subject: [PATCH 13/19] added delete action --- fileglancer/app.py | 11 ++++ fileglancer/database.py | 10 +++ frontend/src/components/NGLinks.tsx | 61 ++++++++++++++++++- .../components/ui/Table/ngLinksColumns.tsx | 27 ++++++-- frontend/src/contexts/NeuroglancerContext.tsx | 11 +++- frontend/src/queries/neuroglancerQueries.ts | 25 ++++++++ 6 files changed, 136 insertions(+), 9 deletions(-) diff --git a/fileglancer/app.py b/fileglancer/app.py index 9ff03321..74e377d5 100644 --- a/fileglancer/app.py +++ b/fileglancer/app.py @@ -787,6 +787,17 @@ async def update_neuroglancer_short_link(request: Request, ) + @app.delete("/api/neuroglancer/nglinks/{short_key}", + description="Delete a stored Neuroglancer state") + async def delete_neuroglancer_short_link(short_key: str = Path(..., description="The short key of the Neuroglancer state"), + username: str = Depends(get_current_user)): + with db.get_db_session(settings.db_url) as session: + deleted = db.delete_neuroglancer_state(session, username, short_key) + if deleted == 0: + raise HTTPException(status_code=404, detail="Neuroglancer link not found") + return {"message": f"Neuroglancer link {short_key} deleted"} + + @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"), diff --git a/fileglancer/database.py b/fileglancer/database.py index 3022cb8b..d3795fdf 100644 --- a/fileglancer/database.py +++ b/fileglancer/database.py @@ -667,6 +667,16 @@ def update_neuroglancer_state( return entry +def delete_neuroglancer_state(session: Session, username: str, short_key: str) -> int: + """Delete a Neuroglancer state entry. Returns the number of deleted rows.""" + deleted = session.query(NeuroglancerStateDB).filter_by( + short_key=short_key, + username=username + ).delete() + session.commit() + return deleted + + 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/frontend/src/components/NGLinks.tsx b/frontend/src/components/NGLinks.tsx index 3b46d133..060597ba 100644 --- a/frontend/src/components/NGLinks.tsx +++ b/frontend/src/components/NGLinks.tsx @@ -6,6 +6,8 @@ import toast from 'react-hot-toast'; import { TableCard } from '@/components/ui/Table/TableCard'; import { useNGLinksColumns } from '@/components/ui/Table/ngLinksColumns'; import NeuroglancerViewDialog from '@/components/ui/Dialogs/NeuroglancerViewDialog'; +import FgDialog from '@/components/ui/Dialogs/FgDialog'; +import DeleteBtn from '@/components/ui/buttons/DeleteBtn'; import { useNeuroglancerContext } from '@/contexts/NeuroglancerContext'; import type { NeuroglancerShortLink } from '@/queries/neuroglancerQueries'; @@ -13,12 +15,16 @@ export default function NGLinks() { const { allNeuroglancerLinksQuery, createNeuroglancerShortLinkMutation, - updateNeuroglancerShortLinkMutation + updateNeuroglancerShortLinkMutation, + deleteNeuroglancerShortLinkMutation } = useNeuroglancerContext(); const [showDialog, setShowDialog] = useState(false); const [editItem, setEditItem] = useState( undefined ); + const [deleteItem, setDeleteItem] = useState< + NeuroglancerShortLink | undefined + >(undefined); const handleOpenCreate = () => { setEditItem(undefined); @@ -67,7 +73,32 @@ export default function NGLinks() { } }; - const ngLinksColumns = useNGLinksColumns(handleOpenEdit); + const handleOpenDelete = (item: NeuroglancerShortLink) => { + setDeleteItem(item); + }; + + const handleCloseDelete = () => { + setDeleteItem(undefined); + }; + + const handleConfirmDelete = async () => { + if (!deleteItem) { + return; + } + try { + await deleteNeuroglancerShortLinkMutation.mutateAsync( + deleteItem.short_key + ); + toast.success('Link deleted'); + handleCloseDelete(); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to delete link'; + toast.error(message); + } + }; + + const ngLinksColumns = useNGLinksColumns(handleOpenEdit, handleOpenDelete); return ( <> @@ -108,6 +139,32 @@ export default function NGLinks() { } /> ) : null} + {deleteItem ? ( + + + Are you sure you want to delete " + {deleteItem.short_name || deleteItem.short_key}"? + +
+ + +
+
+ ) : null} ); } diff --git a/frontend/src/components/ui/Table/ngLinksColumns.tsx b/frontend/src/components/ui/Table/ngLinksColumns.tsx index 3482ccba..d4faa74a 100644 --- a/frontend/src/components/ui/Table/ngLinksColumns.tsx +++ b/frontend/src/components/ui/Table/ngLinksColumns.tsx @@ -16,14 +16,17 @@ const TRIGGER_CLASSES = 'h-min max-w-full'; type NGLinkRowActionProps = { item: NeuroglancerShortLink; onEdit: (item: NeuroglancerShortLink) => void; + onDelete: (item: NeuroglancerShortLink) => void; }; function ActionsCell({ item, - onEdit + onEdit, + onDelete }: { readonly item: NeuroglancerShortLink; readonly onEdit: (item: NeuroglancerShortLink) => void; + readonly onDelete: (item: NeuroglancerShortLink) => void; }) { const menuItems: MenuItem[] = [ { @@ -53,6 +56,13 @@ function ActionsCell({ toast.error(`Failed to copy: ${result.error}`); } } + }, + { + name: 'Delete', + color: 'text-error', + action: ({ item, onDelete }) => { + onDelete(item); + } } ]; @@ -60,7 +70,7 @@ function ActionsCell({
e.stopPropagation()}> - actionProps={{ item, onEdit }} + actionProps={{ item, onEdit, onDelete }} menuItems={menuItems} />
@@ -69,7 +79,8 @@ function ActionsCell({ } export function useNGLinksColumns( - onEdit: (item: NeuroglancerShortLink) => void + onEdit: (item: NeuroglancerShortLink) => void, + onDelete: (item: NeuroglancerShortLink) => void ): ColumnDef[] { return useMemo( () => [ @@ -191,10 +202,16 @@ export function useNGLinksColumns( { id: 'actions', header: 'Actions', - cell: ({ row }) => , + cell: ({ row }) => ( + + ), enableSorting: false } ], - [onEdit] + [onEdit, onDelete] ); } diff --git a/frontend/src/contexts/NeuroglancerContext.tsx b/frontend/src/contexts/NeuroglancerContext.tsx index 56e2c98d..13502584 100644 --- a/frontend/src/contexts/NeuroglancerContext.tsx +++ b/frontend/src/contexts/NeuroglancerContext.tsx @@ -4,7 +4,8 @@ import type { ReactNode } from 'react'; import { useNeuroglancerShortLinksQuery, useCreateNeuroglancerShortLinkMutation, - useUpdateNeuroglancerShortLinkMutation + useUpdateNeuroglancerShortLinkMutation, + useDeleteNeuroglancerShortLinkMutation } from '@/queries/neuroglancerQueries'; type NeuroglancerContextType = { @@ -15,6 +16,9 @@ type NeuroglancerContextType = { updateNeuroglancerShortLinkMutation: ReturnType< typeof useUpdateNeuroglancerShortLinkMutation >; + deleteNeuroglancerShortLinkMutation: ReturnType< + typeof useDeleteNeuroglancerShortLinkMutation + >; }; const NeuroglancerContext = createContext(null); @@ -39,11 +43,14 @@ export const NeuroglancerProvider = ({ useCreateNeuroglancerShortLinkMutation(); const updateNeuroglancerShortLinkMutation = useUpdateNeuroglancerShortLinkMutation(); + const deleteNeuroglancerShortLinkMutation = + useDeleteNeuroglancerShortLinkMutation(); const value: NeuroglancerContextType = { allNeuroglancerLinksQuery, createNeuroglancerShortLinkMutation, - updateNeuroglancerShortLinkMutation + updateNeuroglancerShortLinkMutation, + deleteNeuroglancerShortLinkMutation }; return ( diff --git a/frontend/src/queries/neuroglancerQueries.ts b/frontend/src/queries/neuroglancerQueries.ts index 676273b7..9a4192f8 100644 --- a/frontend/src/queries/neuroglancerQueries.ts +++ b/frontend/src/queries/neuroglancerQueries.ts @@ -139,3 +139,28 @@ export function useUpdateNeuroglancerShortLinkMutation(): UseMutationResult< } }); } + +export function useDeleteNeuroglancerShortLinkMutation(): UseMutationResult< + void, + Error, + string +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (shortKey: string) => { + const response = await sendFetchRequest( + `/api/neuroglancer/nglinks/${encodeURIComponent(shortKey)}`, + 'DELETE' + ); + if (!response.ok) { + throw await toHttpError(response); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: neuroglancerQueryKeys.all + }); + } + }); +} From 6934c3cb6d862196011af253ccaad6144c7dfad4 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 5 Jan 2026 17:57:57 -0500 Subject: [PATCH 14/19] update text --- frontend/src/components/NGLinks.tsx | 2 +- .../src/components/ui/Dialogs/NeuroglancerViewDialog.tsx | 4 ++-- frontend/src/components/ui/Table/ngLinksColumns.tsx | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/NGLinks.tsx b/frontend/src/components/NGLinks.tsx index 060597ba..382c316d 100644 --- a/frontend/src/components/NGLinks.tsx +++ b/frontend/src/components/NGLinks.tsx @@ -106,7 +106,7 @@ export default function NGLinks() { Neuroglancer Links - Store Neuroglancer state for easy sharing. Create a short link and share + Store your Neuroglancer states for easy sharing. Create a short link and share it with internal collaborators. You can update the link later if needed.
diff --git a/frontend/src/components/ui/Dialogs/NeuroglancerViewDialog.tsx b/frontend/src/components/ui/Dialogs/NeuroglancerViewDialog.tsx index 6043d80d..63c932fc 100644 --- a/frontend/src/components/ui/Dialogs/NeuroglancerViewDialog.tsx +++ b/frontend/src/components/ui/Dialogs/NeuroglancerViewDialog.tsx @@ -98,7 +98,7 @@ export default function NeuroglancerViewDialog({ className="text-foreground font-semibold" htmlFor="neuroglancer-url" > - Neuroglancer URL + Original Neuroglancer Link - Name (optional, used in shortened URL) + Name (optional, used in shortened link) { const result = await copyToClipboard(item.neuroglancer_url); if (result.success) { - toast.success('Neuroglancer URL copied'); + toast.success('Neuroglancer link copied'); } else { toast.error(`Failed to copy: ${result.error}`); } } }, { - name: 'Copy JSON state URL', + name: 'Copy JSON state link', action: async ({ item }) => { const result = await copyToClipboard(item.state_url); if (result.success) { - toast.success('JSON state URL copied'); + toast.success('JSON state link copied'); } else { toast.error(`Failed to copy: ${result.error}`); } From 6696a9aea6f90a01e7a4ac8723be547d335651ae Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 5 Jan 2026 18:05:20 -0500 Subject: [PATCH 15/19] complete renaming to nglink for consistency --- frontend/src/components/NGLinks.tsx | 56 ++++++++--------- ...glancerViewDialog.tsx => NGLinkDialog.tsx} | 10 +-- .../components/ui/Table/ngLinksColumns.tsx | 20 +++--- frontend/src/contexts/NGLinkContext.tsx | 50 +++++++++++++++ frontend/src/contexts/NeuroglancerContext.tsx | 63 ------------------- frontend/src/layouts/MainLayout.tsx | 6 +- ...euroglancerQueries.ts => ngLinkQueries.ts} | 63 +++++++++---------- 7 files changed, 122 insertions(+), 146 deletions(-) rename frontend/src/components/ui/Dialogs/{NeuroglancerViewDialog.tsx => NGLinkDialog.tsx} (95%) create mode 100644 frontend/src/contexts/NGLinkContext.tsx delete mode 100644 frontend/src/contexts/NeuroglancerContext.tsx rename frontend/src/queries/{neuroglancerQueries.ts => ngLinkQueries.ts} (61%) diff --git a/frontend/src/components/NGLinks.tsx b/frontend/src/components/NGLinks.tsx index 382c316d..8664d5f7 100644 --- a/frontend/src/components/NGLinks.tsx +++ b/frontend/src/components/NGLinks.tsx @@ -5,33 +5,29 @@ import toast from 'react-hot-toast'; import { TableCard } from '@/components/ui/Table/TableCard'; import { useNGLinksColumns } from '@/components/ui/Table/ngLinksColumns'; -import NeuroglancerViewDialog from '@/components/ui/Dialogs/NeuroglancerViewDialog'; +import NGLinkDialog from '@/components/ui/Dialogs/NGLinkDialog'; import FgDialog from '@/components/ui/Dialogs/FgDialog'; import DeleteBtn from '@/components/ui/buttons/DeleteBtn'; -import { useNeuroglancerContext } from '@/contexts/NeuroglancerContext'; -import type { NeuroglancerShortLink } from '@/queries/neuroglancerQueries'; +import { useNGLinkContext } from '@/contexts/NGLinkContext'; +import type { NGLink } from '@/queries/ngLinkQueries'; export default function NGLinks() { const { - allNeuroglancerLinksQuery, - createNeuroglancerShortLinkMutation, - updateNeuroglancerShortLinkMutation, - deleteNeuroglancerShortLinkMutation - } = useNeuroglancerContext(); + allNGLinksQuery, + createNGLinkMutation, + updateNGLinkMutation, + deleteNGLinkMutation + } = useNGLinkContext(); const [showDialog, setShowDialog] = useState(false); - const [editItem, setEditItem] = useState( - undefined - ); - const [deleteItem, setDeleteItem] = useState< - NeuroglancerShortLink | undefined - >(undefined); + const [editItem, setEditItem] = useState(undefined); + const [deleteItem, setDeleteItem] = useState(undefined); const handleOpenCreate = () => { setEditItem(undefined); setShowDialog(true); }; - const handleOpenEdit = (item: NeuroglancerShortLink) => { + const handleOpenEdit = (item: NGLink) => { setEditItem(item); setShowDialog(true); }; @@ -47,7 +43,7 @@ export default function NGLinks() { title?: string; }) => { try { - await createNeuroglancerShortLinkMutation.mutateAsync(payload); + await createNGLinkMutation.mutateAsync(payload); toast.success('Link created'); handleClose(); } catch (error) { @@ -63,7 +59,7 @@ export default function NGLinks() { title?: string; }) => { try { - await updateNeuroglancerShortLinkMutation.mutateAsync(payload); + await updateNGLinkMutation.mutateAsync(payload); toast.success('Link updated'); handleClose(); } catch (error) { @@ -73,7 +69,7 @@ export default function NGLinks() { } }; - const handleOpenDelete = (item: NeuroglancerShortLink) => { + const handleOpenDelete = (item: NGLink) => { setDeleteItem(item); }; @@ -86,9 +82,7 @@ export default function NGLinks() { return; } try { - await deleteNeuroglancerShortLinkMutation.mutateAsync( - deleteItem.short_key - ); + await deleteNGLinkMutation.mutateAsync(deleteItem.short_key); toast.success('Link deleted'); handleCloseDelete(); } catch (error) { @@ -106,8 +100,9 @@ export default function NGLinks() { Neuroglancer Links - Store your Neuroglancer states for easy sharing. Create a short link and share - it with internal collaborators. You can update the link later if needed. + Store your Neuroglancer states for easy sharing. Create a short link and + share it with internal collaborators. You can update the link later if + needed.