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/app.py b/fileglancer/app.py index 98321fa5..b346b005 100644 --- a/fileglancer/app.py +++ b/fileglancer/app.py @@ -149,6 +149,38 @@ 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 _validate_short_name(short_name: str) -> None: + """Validate short_name: only letters, numbers, hyphens, and underscores allowed.""" + if not all(ch.isalnum() or ch in ("-", "_") for ch in short_name): + raise HTTPException(status_code=400, detail="short_name can only contain letters, numbers, hyphens, and underscores") + + def create_app(settings): # Initialize OAuth client for OKTA @@ -664,6 +696,119 @@ 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/nglinks", 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_name = payload.short_name.strip() if payload.short_name else None + if short_name: + _validate_short_name(short_name) + 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") + + 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") + + # 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( + session, + username, + url_base, + state, + 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)) + + # 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, + short_name=created_short_name, + title=title, + state_url=state_url, + neuroglancer_url=neuroglancer_url + ) + + + @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, + 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.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"), @@ -734,6 +879,59 @@ 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_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"}) + + + @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)): + links = [] + with db.get_db_session(settings.db_url) as session: + entries = db.get_neuroglancer_states(session, username) + for entry in entries: + # 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 + 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, + 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 735b3bc0..21616aac 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' @@ -614,6 +629,86 @@ 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 create_neuroglancer_state( + session: Session, + username: str, + url_base: str, + state: Dict, + short_name: Optional[str] = None +) -> NeuroglancerStateDB: + """Create a new Neuroglancer state entry and return it.""" + 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 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 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/fileglancer/filestore.py b/fileglancer/filestore.py index 90ca4a48..9618654a 100644 --- a/fileglancer/filestore.py +++ b/fileglancer/filestore.py @@ -195,8 +195,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..ffe5330e 100644 --- a/fileglancer/model.py +++ b/fileglancer/model.py @@ -214,3 +214,92 @@ 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_name: Optional[str] = Field( + 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 + ) + 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 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( + description="Short key for retrieving the stored state" + ) + short_name: Optional[str] = Field( + 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" + ) + 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 + ) + 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" + ) + 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..86a167a8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,8 +13,10 @@ import Help from '@/components/Help'; import Jobs from '@/components/Jobs'; import Preferences from '@/components/Preferences'; import Links from '@/components/Links'; +import NGLinks from '@/components/NGLinks'; import Notifications from '@/components/Notifications'; import ErrorFallback from '@/components/ErrorFallback'; +import { NGLinkProvider } from '@/contexts/NGLinkContext'; function RequireAuth({ children }: { readonly children: ReactNode }) { const { loading, authStatus } = useAuthContext(); @@ -98,6 +100,16 @@ const AppComponent = () => { } path="links" /> + + + + + + } + path="nglinks" + /> {tasksEnabled ? ( (undefined); + const [deleteItem, setDeleteItem] = useState(undefined); + + const handleOpenCreate = () => { + setEditItem(undefined); + setShowDialog(true); + }; + + const handleOpenEdit = (item: NGLink) => { + setEditItem(item); + setShowDialog(true); + }; + + const handleClose = () => { + setShowDialog(false); + setEditItem(undefined); + }; + + const handleCreate = async (payload: { + url: string; + short_name?: string; + title?: string; + }) => { + try { + await createNGLinkMutation.mutateAsync(payload); + toast.success('Link created'); + handleClose(); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to create link'; + toast.error(message); + } + }; + + const handleUpdate = async (payload: { + short_key: string; + url: string; + title?: string; + }) => { + try { + await updateNGLinkMutation.mutateAsync(payload); + toast.success('Link updated'); + handleClose(); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to update link'; + toast.error(message); + } + }; + + const handleOpenDelete = (item: NGLink) => { + setDeleteItem(item); + }; + + const handleCloseDelete = () => { + setDeleteItem(undefined); + }; + + const handleConfirmDelete = async () => { + if (!deleteItem) { + return; + } + try { + await deleteNGLinkMutation.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 ( + <> + + 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. + +
+ +
+ + {showDialog ? ( + + ) : null} + {deleteItem ? ( + + + Are you sure you want to delete " + {deleteItem.short_name || deleteItem.short_key}"? + +
+ + +
+
+ ) : null} + + ); +} diff --git a/frontend/src/components/ui/Dialogs/NGLinkDialog.tsx b/frontend/src/components/ui/Dialogs/NGLinkDialog.tsx new file mode 100644 index 00000000..bfbb3804 --- /dev/null +++ b/frontend/src/components/ui/Dialogs/NGLinkDialog.tsx @@ -0,0 +1,456 @@ +import { useState, useEffect } from 'react'; +import type { ChangeEvent } from 'react'; +import { Button, Typography } from '@material-tailwind/react'; + +import FgDialog from '@/components/ui/Dialogs/FgDialog'; +import type { + NGLink, + CreateNGLinkPayload, + UpdateNGLinkPayload +} from '@/queries/ngLinkQueries'; +import { + parseNeuroglancerUrl, + validateJsonState, + constructNeuroglancerUrl +} from '@/utils'; + +type NGLinkDialogProps = { + readonly open: boolean; + readonly pending: boolean; + readonly onClose: () => void; + readonly onCreate?: (payload: CreateNGLinkPayload) => Promise; + readonly onUpdate?: (payload: UpdateNGLinkPayload) => Promise; + readonly editItem?: NGLink; +}; + +const DEFAULT_BASE_URL = 'https://neuroglancer-demo.appspot.com/'; + +export default function NGLinkDialog({ + open, + pending, + onClose, + onCreate, + onUpdate, + editItem +}: NGLinkDialogProps) { + const isEditMode = !!editItem; + + const [inputMode, setInputMode] = useState<'url' | 'state'>('url'); + const [neuroglancerUrl, setNeuroglancerUrl] = useState(''); + const [stateJson, setStateJson] = useState(''); + const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL); + const [shortName, setShortName] = useState(''); + const [title, setTitle] = useState(''); + const [error, setError] = useState(null); + const [shortNameError, setShortNameError] = useState(null); + const [urlValidationError, setUrlValidationError] = useState( + null + ); + const [stateValidationError, setStateValidationError] = useState< + string | null + >(null); + + // Initialize form values when editItem changes + useEffect(() => { + if (editItem) { + setInputMode('url'); + setNeuroglancerUrl(''); + setShortName(editItem.short_name || ''); + setTitle(editItem.title || ''); + setStateJson(''); + setBaseUrl(DEFAULT_BASE_URL); + setUrlValidationError(null); + setStateValidationError(null); + } else { + setInputMode('url'); + setNeuroglancerUrl(''); + setShortName(''); + setTitle(''); + setStateJson(''); + setBaseUrl(DEFAULT_BASE_URL); + setUrlValidationError(null); + setStateValidationError(null); + } + }, [editItem]); + + const validateUrlInput = (value: string): string | null => { + if (!value.trim()) { + return 'Neuroglancer URL is required'; + } + const result = parseNeuroglancerUrl(value); + if (!result.success) { + return result.error; + } + return null; + }; + + const validateStateInput = (value: string): string | null => { + if (!value.trim()) { + return 'JSON state is required'; + } + const result = validateJsonState(value); + if (!result.success) { + return result.error; + } + return null; + }; + + const validateShortName = (value: string): string | null => { + if (!value.trim()) { + return null; // Empty is allowed (optional field) + } + // Only allow alphanumeric characters, hyphens, and underscores + const validPattern = /^[a-zA-Z0-9_-]+$/; + if (!validPattern.test(value.trim())) { + return 'Name can only contain letters, numbers, hyphens, and underscores'; + } + return null; + }; + + const handleModeChange = (mode: 'url' | 'state') => { + setInputMode(mode); + setNeuroglancerUrl(''); + setStateJson(''); + setBaseUrl(DEFAULT_BASE_URL); + setUrlValidationError(null); + setStateValidationError(null); + setError(null); + }; + + const handleUrlChange = (e: ChangeEvent) => { + const value = e.target.value; + setNeuroglancerUrl(value); + if (value.trim()) { + setUrlValidationError(validateUrlInput(value)); + } else { + setUrlValidationError(null); + } + }; + + const handleStateChange = (e: ChangeEvent) => { + const value = e.target.value; + setStateJson(value); + if (value.trim()) { + setStateValidationError(validateStateInput(value)); + } else { + setStateValidationError(null); + } + }; + + const handleShortNameChange = (e: ChangeEvent) => { + const value = e.target.value; + setShortName(value); + setShortNameError(validateShortName(value)); + }; + + const resetAndClose = () => { + setError(null); + setShortNameError(null); + setUrlValidationError(null); + setStateValidationError(null); + setInputMode('url'); + setNeuroglancerUrl(''); + setStateJson(''); + setBaseUrl(DEFAULT_BASE_URL); + setShortName(''); + setTitle(''); + onClose(); + }; + + const handleSubmit = async () => { + setError(null); + + // Check for short_name validation error + if (shortNameError) { + setError('Please fix the errors before submitting.'); + return; + } + + if (inputMode === 'url') { + // URL Mode validation + if (!neuroglancerUrl.trim()) { + setError('Please provide a Neuroglancer URL.'); + return; + } + + const urlError = validateUrlInput(neuroglancerUrl); + if (urlError) { + setUrlValidationError(urlError); + setError(urlError); + return; + } + + if (isEditMode && onUpdate && editItem) { + await onUpdate({ + short_key: editItem.short_key, + url: neuroglancerUrl.trim(), + title: title.trim() || undefined + }); + } else if (onCreate) { + await onCreate({ + url: neuroglancerUrl.trim(), + short_name: shortName.trim() || undefined, + title: title.trim() || undefined + }); + } + } else { + // State Mode validation + if (!stateJson.trim()) { + setError('Please provide JSON state.'); + return; + } + + if (!baseUrl.trim()) { + setError('Please provide a base URL.'); + return; + } + + const stateError = validateStateInput(stateJson); + if (stateError) { + setStateValidationError(stateError); + setError(stateError); + return; + } + + // Validate base URL + if ( + !baseUrl.trim().startsWith('http://') && + !baseUrl.trim().startsWith('https://') + ) { + setError('Base URL must start with http:// or https://'); + return; + } + + // Parse JSON state + const parsedState = JSON.parse(stateJson.trim()); + + if (isEditMode && onUpdate && editItem) { + // For edit mode, construct URL from state and base URL + const constructedUrl = constructNeuroglancerUrl( + parsedState, + baseUrl.trim() + ); + await onUpdate({ + short_key: editItem.short_key, + url: constructedUrl, + title: title.trim() || undefined + }); + } else if (onCreate) { + await onCreate({ + state: parsedState, + url_base: baseUrl.trim(), + short_name: shortName.trim() || undefined, + title: title.trim() || undefined + }); + } + } + }; + + return ( + +
+ + {isEditMode + ? 'Edit Neuroglancer Short Link' + : 'Create Neuroglancer Short Link'} + + + {/* Mode Selector */} +
+ + +
+ + {/* URL Mode Fields */} + {inputMode === 'url' ? ( + <> + + Neuroglancer URL + + + {urlValidationError ? ( + + {urlValidationError} + + ) : ( +
+ )} + + ) : null} + + {/* State Mode Fields */} + {inputMode === 'state' ? ( + <> + + JSON State + +