-
Notifications
You must be signed in to change notification settings - Fork 2.8k
feat: FIT-14: LabelStudio Playground 2.0 #7521
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from 48 commits
Commits
Show all changes
97 commits
Select commit
Hold shift + click to select a range
b422440
feat: OPTIC-1799: LabelStudio Playground 2.0
bmartel 75b82f3
updating docs and code structure
bmartel 400b990
refactor to use Jotai atoms
bmartel 7749a51
add playground to top level commands for web
bmartel a3ee374
use the right styles
bmartel 5dd75d9
more styling
bmartel 4a54d68
fix editor
bmartel 6609d7c
fix mounting/updating
bmartel 9b408a6
fixing code editor
bmartel 2536c8d
force bottom panel
bmartel 7cecd22
fix
bmartel 05caf32
force bottom panel
bmartel 7ad7bfa
collapsible bottom panel
bmartel 9414a2a
collapse button
bmartel 4003b8a
collapse
bmartel 41e895e
fix collapse height
bmartel 9eba7e3
default collapsed
bmartel 998707e
Merge remote-tracking branch 'origin/develop' into optic-1799
bmartel b8cae89
fix border
bmartel d1e78cf
panel resize
bmartel 98af1cd
fix tw config
bmartel 2494677
fix bottom panel resizing
bmartel 4b249df
adding ThemeToggle
bmartel 722aca2
improving LSF handling to use React 18 createRoot and improved tear down
bmartel 6a74ccd
fix imports
bmartel 696ab22
Merge remote-tracking branch 'origin/develop' into optic-1799
bmartel 8e2dc5c
Merge remote-tracking branch 'origin/develop' into fb-fit-14
bmartel 92ce012
Merge remote-tracking branch 'origin/develop' into fb-fit-14
bmartel 353cba9
fix bottom panel styles
bmartel ae52e4d
make all resizer separators handled the same
bmartel 6416d3a
tabs styles for editor bottom panel to match the playground minimal t…
bmartel d5d09bc
fix bottom panel height resize
bmartel 138e00a
fix sample generator
bmartel 1dfa226
sample data
bmartel fdb0ef8
allow proper sizing of content panel according to resize of bottom panel
bmartel 033ec79
linting
bmartel 9c900cf
fix loading config from urlencoded string and url
bmartel fe3e85d
trying to fix async unmount of root node
bmartel fc1853d
ause atoms to communicate change in annotation and sample task data
bmartel 3414ae8
linting
bmartel 294d466
fix annotation cleanup
bmartel 075cbf2
fix dragging dividers to not select or drop contents of the panels to…
bmartel 19f5960
linting
bmartel 4e6ceaf
fix unmount errors
bmartel 34e00f1
linting
bmartel ae30f72
update README
bmartel 67fc40a
Update web/libs/editor/src/components/App/App.jsx
bmartel 0f354c4
need this to force bottom panel through settings
bmartel 9d9a8e7
refactor main component files
bmartel e9e5550
removing old file
bmartel 760823d
adding a fix to annotation createResult which errors when using Choic…
bmartel 3a670a3
embed feature flags from oss in the playground
bmartel c40cb19
refactor components more
bmartel 71a5a6c
fix imports
bmartel 2f4bb18
refactor code editor config to utils, improve the resizing of bottom …
bmartel 67187bf
move tags schema json to core lib
bmartel 1355cfe
fix default tailwind content paths
bmartel c51ab25
Merge remote-tracking branch 'origin/develop' into fb-fit-14
bmartel 3c2ffc0
Apply suggestions from code review
bmartel ea1de06
update readme
bmartel c75ace1
Merge branch 'fb-fit-14' of github.com:HumanSignal/label-studio into …
bmartel a74361b
removing setTimeout
bmartel a10001e
this flag should be off in all cases
bmartel 178908f
allow react 17 fallback support by default of LabelStudio rendering
bmartel f904f8b
fixed LabelStudio missing functions
bmartel 9616bd2
remove console.log
bmartel 1d1925b
revert annotation cleanup change as it is too aggressive and not need…
bmartel 370d495
Merge remote-tracking branch 'origin/develop' into fb-fit-14
bmartel 7248040
Merge remote-tracking branch 'origin/develop' into fb-fit-14
bmartel c9cc185
fix Button import
bmartel 6acfc29
no body border, update tags schema command to point to new location
bmartel d969d44
make app title nicer
bmartel e1ef646
fix app doc title
bmartel 78ec2d6
Merge remote-tracking branch 'origin/develop' into fb-fit-14
bmartel e765e95
adding some tests
bmartel cf7117c
linting all
bmartel 1a0ecbf
fix tests
bmartel 0bcc026
linting all
bmartel 04af95f
more label config tests
bmartel 7c9f87f
all advanced template tests
bmartel 5c80dd9
adding more tests audio classification to HTML classification
yyassi-heartex cde717a
more tests
yyassi-heartex 152d38c
more of the base templates
bmartel fdaf12f
Merge branch 'fb-fit-14' of github.com:HumanSignal/label-studio into …
bmartel 116fdf3
fix timeseries and csv data
bmartel 08b17db
fix tests and also fix a brush initialization problem for TimeSeries
bmartel d9024c6
copy and share buttons
bmartel 6078abb
move the functions and constants to root module
bmartel 8739bf6
fix linting errors
bmartel e8e289c
spacing
bmartel c8c0a39
update tooltip text
bmartel 5a6d5b1
allow share url to work standalone or within iframe
bmartel ab049f5
change ocr sample
bmartel 60975af
add paragraph special case handling
bmartel ce92204
fix share url to replace config query param directly so they don't stack
bmartel 08cdbf6
fix ranker to use same content as list
bmartel 0c844e9
revert the changes to Annotation createResult,fix the dynamic choices…
bmartel File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
{ | ||
"presets": [ | ||
[ | ||
"@nx/react/babel", | ||
{ | ||
"runtime": "automatic" | ||
} | ||
] | ||
], | ||
"plugins": [] | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,131 @@ | ||
--- | ||
description: | ||
globs: | ||
alwaysApply: false | ||
--- | ||
# Playground | ||
|
||
## Overview | ||
The LabelStudio Playground is a standalone, embeddable React application for editing and previewing LabelStudio XML labeling configurations. It is designed to be embedded via iframe in documentation or external web applications, providing a focused environment for working with XML-based labeling configs and live previewing the LabelStudio interface. | ||
|
||
## Main Features | ||
|
||
- **Live XML Config Editing:** Edit Label Studio XML configs in real time and instantly preview the result. | ||
- **Sample Data Generation:** Automatically generates sample data for all supported media types (image, audio, video, PDF, website, CSV, OCR, etc.) using public domain URLs (primarily from Wikimedia Commons). This ensures copyright-safe, always-accessible previews. | ||
- **Live Annotation Output:** The preview panel displays the current annotation as a live-updating JSON string, reflecting user interactions in real time. | ||
- **Sticky Bottom Panel:** The right preview panel ensures the bottom panel (annotation controls) is always visible and sticky, with the main preview area scrollable. | ||
- **Resizable Panels:** The left (editor) and right (preview) panels are resizable and fully responsive. | ||
- **Robust Error Handling:** Displays clear error messages for invalid configs, network issues, or MobX State Tree (MST) errors. All async and MST errors are handled gracefully to avoid UI crashes. | ||
|
||
## Main Components | ||
|
||
### 1. `PlaygroundApp` | ||
- **Location:** `src/components/PlaygroundApp.tsx` | ||
- **Role:** The main application component. Handles: | ||
- UI rendering and atom wiring only; all state is managed via Jotai atoms. | ||
- Reads and writes config, loading, error, and interfaces state via atoms. | ||
- Uses utility functions for parsing URL parameters and interface options. | ||
- Renders the code editor and preview panels side by side using Tailwind CSS for layout. | ||
|
||
### 2. `PlaygroundPreview` | ||
- **Location:** `src/components/PlaygroundPreview.tsx` | ||
- **Role:** Renders the live LabelStudio preview panel. | ||
- Receives all state as props (from atoms in PlaygroundApp). | ||
- Dynamically loads the `@humansignal/editor` package and instantiates a LabelStudio instance with the current config and interface options. | ||
- Handles cleanup of the LabelStudio instance on config/interface changes or unmount. | ||
- Observes MST annotation changes and updates a Jotai atom or local state with the serialized annotation JSON, which is displayed below the preview. | ||
- Displays loading and error states using Tailwind classes. | ||
|
||
### 3. `main.tsx` | ||
- **Role:** Entry point. Mounts the `PlaygroundApp` to the DOM. | ||
|
||
## State Management | ||
- All application state (config, loading, error, interfaces, annotation, sample task) is managed using Jotai atoms, defined in `src/atoms/configAtoms.ts`. | ||
- Components use the `useAtom` hook to read and write state. | ||
- This ensures a strict separation of state logic from UI, and enables easy extension and testing. | ||
|
||
## Utility Functions | ||
- All logic for parsing URL parameters and interface options is placed in strict utility functions in `src/utils/query.ts`. | ||
- Sample data generation is handled in `src/utils/generateSampleTask.ts`. | ||
- Components import and use these utilities for all non-UI logic. | ||
|
||
## Data Flow | ||
- On load, `PlaygroundApp` uses utility functions to parse URL parameters: | ||
- `?config` (base64-encoded XML config) | ||
- `?configUrl` (URL to fetch XML config) | ||
- `?interfaces` (comma-separated list of LabelStudio interface options) | ||
- The config, loading, error, interfaces, annotation, and sample task state are set via Jotai atoms. | ||
- The code editor is a controlled component, updating the config atom on change. | ||
- The preview panel receives the current config and interface options as props and re-renders the LabelStudio instance accordingly. | ||
- Annotation changes in the preview are observed and the serialized annotation is displayed live below the preview. | ||
|
||
## MobX State Tree (MST) Integration | ||
- All MST model mutations (including cleanup) are performed via MST actions to avoid protection errors. | ||
- The annotation model includes a `cleanup` action for safe teardown, which is called from React cleanup. | ||
- Only plain objects (not MST models) are stored in React state or Jotai atoms. | ||
|
||
## Underlying Libraries | ||
|
||
### React | ||
- The app is built with React (function components, hooks, strict mode). | ||
- State and effects are managed with Jotai atoms and hooks. | ||
|
||
### Jotai | ||
- Used for all state management (config, loading, error, interfaces, annotation, sample task). | ||
- Atoms are defined in `src/atoms/configAtoms.ts`. | ||
- Components use `useAtom` for state access and updates. | ||
|
||
### Tailwind CSS | ||
- All layout, spacing, color, and typography is handled with Tailwind utility classes. | ||
- Only semantic and token-based Tailwind classes are used, following project rules. | ||
|
||
### @humansignal/ui | ||
- Provides the `CodeEditor` component for XML editing. | ||
- Ensures consistent UI and design token usage across the app. | ||
|
||
### @humansignal/editor | ||
- Provides the LabelStudio labeling interface for live preview. | ||
- Dynamically imported in the preview panel for performance and to avoid loading unnecessary code until needed. | ||
- The LabelStudio instance is created with the current config and interface options, and is destroyed/cleaned up on changes. | ||
|
||
## URL-based API | ||
- The app can be configured via URL parameters: | ||
- `?config` (base64-encoded XML config string) | ||
- `?configUrl` (URL to fetch XML config) | ||
- `?interfaces` (comma-separated list of LabelStudio interface options) | ||
- This allows external documentation or apps to embed the playground with preloaded configs and custom preview options. | ||
|
||
## Embeddability | ||
- The app is fully responsive and designed to be embedded via iframe. | ||
- All UI is self-contained and does not require authentication or external state. | ||
|
||
## Extensibility | ||
- Components are split into single-file-per-component for maintainability, and all live under `src/components/`. | ||
- All state is managed via Jotai atoms in `src/atoms/`. | ||
- All logic is placed in strict utility functions in `src/utils/`. | ||
- The code editor and preview logic are decoupled, allowing for future enhancements (e.g., validation, additional preview options, custom data, etc). | ||
- The app can be extended to support more URL parameters, additional LabelStudio features, or integration with other HumanSignal libraries. | ||
- To add new sample data types, update `generateSampleTask.ts` with new logic and public domain URLs. | ||
- To customize annotation output, update the preview logic to observe and display additional MST state as needed. | ||
|
||
## Directory Structure | ||
|
||
``` | ||
web/apps/playground/ | ||
README.mdc | ||
src/ | ||
main.tsx | ||
index.html | ||
components/ | ||
PlaygroundApp.tsx | ||
PlaygroundPreview.tsx | ||
atoms/ | ||
configAtoms.ts | ||
utils/ | ||
codeEditor.ts | ||
generateSampleTask.ts | ||
query.ts | ||
``` | ||
|
||
## Summary | ||
The Playground app is a modern, modular, and embeddable tool for experimenting with and sharing LabelStudio configs. It leverages the HumanSignal UI and editor libraries, is styled with Tailwind, uses Jotai for state, and is designed for easy integration into documentation and external web applications. It now features robust sample data generation, live annotation output, and safe MobX State Tree integration for a seamless developer experience. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
/* eslint-disable */ | ||
export default { | ||
displayName: "playground", | ||
preset: "../../jest.preset.js", | ||
transform: { | ||
"^(?!.*\\.(js|jsx|ts|tsx|css|json)$)": "@nx/react/plugins/jest", | ||
"^.+\\.[tj]sx?$": ["babel-jest", { presets: ["@nx/react/babel"] }], | ||
}, | ||
moduleFileExtensions: ["ts", "tsx", "js", "jsx"], | ||
moduleNameMapper: { | ||
"^apps/playground/(.*)$": "<rootDir>/$1", | ||
}, | ||
coverageDirectory: "../../coverage/apps/playground", | ||
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
{ | ||
"name": "playground", | ||
"$schema": "../../node_modules/nx/schemas/project-schema.json", | ||
"sourceRoot": "apps/playground/src", | ||
"projectType": "application", | ||
"targets": { | ||
"build": { | ||
"executor": "@nx/webpack:webpack", | ||
"outputs": ["{options.outputPath}"], | ||
"defaultConfiguration": "production", | ||
"options": { | ||
"compiler": "babel", | ||
"outputPath": "dist/apps/playground", | ||
"index": "apps/playground/src/index.html", | ||
"baseHref": "/", | ||
"main": "apps/playground/src/main.tsx", | ||
"tsConfig": "apps/playground/tsconfig.app.json", | ||
"assets": [], | ||
"styles": [], | ||
"scripts": [], | ||
"isolatedConfig": true, | ||
"webpackConfig": "webpack.config.js" | ||
}, | ||
"configurations": { | ||
"development": { | ||
"extractLicenses": false, | ||
"optimization": false, | ||
"sourceMap": true, | ||
"vendorChunk": true | ||
}, | ||
"production": { | ||
"fileReplacements": [ | ||
{ | ||
"replace": "apps/playground/src/environments/environment.ts", | ||
"with": "apps/playground/src/environments/environment.prod.ts" | ||
} | ||
], | ||
"optimization": true, | ||
"sourceMap": false, | ||
"namedChunks": false, | ||
"extractLicenses": true, | ||
"vendorChunk": false | ||
} | ||
} | ||
}, | ||
"serve": { | ||
"executor": "@nx/webpack:dev-server", | ||
"defaultConfiguration": "development", | ||
"options": { | ||
"buildTarget": "playground:build", | ||
"port": 4200, | ||
"hmr": true | ||
}, | ||
"configurations": { | ||
"development": { | ||
"buildTarget": "playground:build:development" | ||
}, | ||
"production": { | ||
"buildTarget": "playground:build:production", | ||
"hmr": false | ||
} | ||
} | ||
}, | ||
"serve-static": { | ||
"executor": "@nx/web:file-server", | ||
"options": { | ||
"buildTarget": "playground:build" | ||
} | ||
}, | ||
"unit": { | ||
"executor": "@nx/jest:jest", | ||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"], | ||
"options": { | ||
"jestConfig": "apps/playground/jest.config.ts", | ||
"passWithNoTests": true | ||
}, | ||
"configurations": { | ||
"ci": { | ||
"ci": true, | ||
"codeCoverage": true | ||
} | ||
} | ||
} | ||
}, | ||
"tags": [] | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
import { atom } from "jotai"; | ||
|
||
export const defaultConfig = "<View>\n <!-- Paste your XML config here -->\n</View>"; | ||
|
||
export const configAtom = atom<string>(defaultConfig); | ||
export const loadingAtom = atom<boolean>(false); | ||
export const errorAtom = atom<string | null>(null); | ||
export const interfacesAtom = atom<string[]>(["side-column"]); | ||
export const showPreviewAtom = atom<boolean>(true); | ||
export const sampleTaskAtom = atom<any>({}); | ||
export const annotationAtom = atom<any>([]); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
import type React from "react"; | ||
import { forwardRef } from "react"; | ||
import { useAtomValue } from "jotai"; | ||
import { annotationAtom, sampleTaskAtom } from "../atoms/configAtoms"; | ||
import { IconCollapseSmall, IconExpandSmall } from "@humansignal/icons"; | ||
import { cnm } from "@humansignal/ui/utils/utils"; | ||
|
||
export type BottomPanelRef = { | ||
handleAnnotationUpdate: (annotation: any) => void; | ||
}; | ||
|
||
interface BottomPanelProps { | ||
isCollapsed: boolean; | ||
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>; | ||
} | ||
|
||
const HEADER_HEIGHT = 33; | ||
|
||
export const BottomPanel = forwardRef<BottomPanelRef, BottomPanelProps>(({ isCollapsed, setIsCollapsed }, ref) => { | ||
const currentAnnotation = useAtomValue(annotationAtom); | ||
const sampleTask = useAtomValue(sampleTaskAtom); | ||
|
||
return ( | ||
<div | ||
className={cnm("flex flex-col transition-all duration-200 min-h-0 min-w-0 h-full", { | ||
"border-t border-neutral-border": isCollapsed, | ||
})} | ||
> | ||
{/* Header (always visible, 33px) */} | ||
<div | ||
className="relative h-[33px] flex flex-row items-center bg-neutral-surface select-none" | ||
style={{ minHeight: HEADER_HEIGHT, maxHeight: HEADER_HEIGHT }} | ||
> | ||
<div className="flex flex-row w-full"> | ||
<div className="flex-1 flex items-center font-semibold text-body-small px-4">Data Input</div> | ||
<div className="w-[1px] h-[33px] bg-neutral-border" /> | ||
<div className="flex-1 flex items-center font-semibold text-body-small px-4">Data Output</div> | ||
</div> | ||
{/* Floating collapse/expand button */} | ||
<button | ||
type="button" | ||
aria-label={isCollapsed ? "Expand" : "Collapse"} | ||
onClick={() => setIsCollapsed(!isCollapsed)} | ||
className="lsf-button lsf-button_look_ lsf-collapsible-bottom-panel-toggle absolute right-[5px] top-1/2 -translate-y-1/2 !h-6 !w-6 !p-0 flex items-center justify-center !bg-transparent !border-none" | ||
style={{ zIndex: 10 }} | ||
yyassi-heartex marked this conversation as resolved.
Show resolved
Hide resolved
|
||
> | ||
{isCollapsed ? <IconExpandSmall /> : <IconCollapseSmall />} | ||
</button> | ||
</div> | ||
{/* Panel content (only when not collapsed) */} | ||
{!isCollapsed && ( | ||
<div className="flex flex-1 min-h-0"> | ||
{/* Sample Data Panel */} | ||
<div className="flex-1 border-r border-neutral-border p-4 overflow-auto"> | ||
<pre className="text-body-small whitespace-pre-wrap">{JSON.stringify(sampleTask.data, null, 2)}</pre> | ||
</div> | ||
{/* Annotation Output Panel */} | ||
<div className="flex-1 p-4 overflow-auto"> | ||
<pre className="text-body-small whitespace-pre-wrap"> | ||
{JSON.stringify(currentAnnotation || {}, null, 2)} | ||
</pre> | ||
</div> | ||
</div> | ||
)} | ||
</div> | ||
); | ||
}); |
44 changes: 44 additions & 0 deletions
44
web/apps/playground/src/components/PlaygroundApp.module.scss
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
.root { | ||
:global(.react-codemirror2 .CodeMirror) { | ||
border: none; | ||
border-radius: 0; | ||
} | ||
|
||
:global(.lsf-tabs-panel__body) { | ||
height: 100%; | ||
} | ||
|
||
:global(.lsf-panel-tabs__tab) { | ||
color: var(--color-neutral-content-subtler); | ||
} | ||
|
||
:global(.lsf-panel-tabs__tab_active) { | ||
transform: none; | ||
border-width: 0; | ||
color: var(--color-neutral-content); | ||
box-shadow: 1px -1px 0 rgba(var(--color-neutral-shadow-raw) / 4%), -1px -1px 0 rgba(var(--color-neutral-shadow-raw) / 4%); | ||
} | ||
|
||
:global(.lsf-tabs__tabs-row) { | ||
border: none; | ||
} | ||
|
||
:global(.lsf-sidepanels_collapsed .lsf-sidepanels__wrapper .lsf-tabs__contents) { | ||
border: none; | ||
} | ||
|
||
:global(.lsf-sidepanels_collapsed) { | ||
flex: 1; | ||
} | ||
|
||
:global(.lsf-wrapper) { | ||
flex: 1; | ||
display: flex; | ||
flex-direction: column; | ||
} | ||
} | ||
|
||
body { | ||
border: 1px solid var(--color-neutral-border); | ||
overflow: hidden; | ||
} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.