diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0e40f8a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.env +.git +.DS_Store +tmp/ +logs/ +explo +src/web/frontend/node_modules/ +src/web/dist/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..974e4a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.env +.DS_Store +/main +tmp/ +.air.toml +logs/ +explo +src/web/dist/ +src/web/frontend/node_modules/ diff --git a/Dockerfile b/Dockerfile index 4f7feaf..2fc7bdb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,10 @@ +FROM node:20-alpine AS ui-builder +WORKDIR /app/src/web/frontend +COPY src/web/frontend/package*.json ./ +RUN npm ci +COPY src/web/frontend/ ./ +RUN npm run build + FROM golang:1.24-alpine AS builder # Set the working directory @@ -6,6 +13,9 @@ WORKDIR /app # Copy the Go source code into the container COPY ./ . +# Copy the built React frontend into the embed path +COPY --from=ui-builder /app/src/web/dist ./src/web/dist + # Build the Go binary based on the target architecture ARG TARGETARCH RUN GOOS=linux GOARCH=$TARGETARCH go build -o explo ./src/main/ @@ -35,7 +45,9 @@ COPY src/downloader/youtube_music/search_ytmusic.py . RUN chmod +x /start.sh ./explo -# Can be defined from compose as well -ENV WEEKLY_EXPLORATION_SCHEDULE="15 0 * * 2" + +ENV WEB_ADDR=":7288" + +EXPOSE 7288 CMD ["/start.sh"] \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index 5e33e5c..a780efd 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -3,6 +3,10 @@ services: image: ghcr.io/lumepart/explo:latest restart: unless-stopped container_name: explo + ports: + - "7288:7288" + extra_hosts: + - "host.docker.internal:host-gateway" volumes: - /path/to/.env:/opt/explo/.env - /path/to/musiclibrary/explo:/data/ # has to be in the same path you have your music system pointed to (it's recommended to put explo under a subfolder) @@ -13,15 +17,14 @@ services: environment: - TZ=UTC # Change this to the timezone set in ListenBrainz (default is UTC) - - WEEKLY_EXPLORATION_SCHEDULE=15 00 * * 2 # Runs weekly, every Tuesday 15 minutes past midnight - - WEEKLY_EXPLORATION_FLAGS= # Run weekly exploration with default settings - - # Uncomment _SCHEDULE and _FLAGS variables to enable fetching different playlist - #- WEEKLY_JAMS_SCHEDULE=30 00 * * 1 # Runs weekly, every Monday 30 minutes past midnight - #- WEEKLY_JAMS_FLAGS=--playlist=weekly-jams --download-mode=skip # Get tracks from weekly-jams, and only add tracks that are found locally to playlist - - #- DAILY_JAMS_SCHEDULE=15 01 * * * # Runs daily, every day 15 minutes past 1PM - #- DAILY_JAMS_FLAGS=--playlist=daily-jams --download-mode=skip # Get tracks from daily-jams, and only add tracks that are found locally to playlist + # [Legacy] Schedules are managed through the web UI. + # These can still be set here for backwards compatibility β€” they will take precedence over the UI. + #- WEEKLY_EXPLORATION_SCHEDULE=15 00 * * 2 + #- WEEKLY_EXPLORATION_FLAGS= + #- WEEKLY_JAMS_SCHEDULE=30 00 * * 1 + #- WEEKLY_JAMS_FLAGS=--playlist=weekly-jams --download-mode=skip + #- DAILY_JAMS_SCHEDULE=15 01 * * * + #- DAILY_JAMS_FLAGS=--playlist=daily-jams --download-mode=skip # Uncomment for testing (runs explo right after launcing the container) #- EXECUTE_ON_START=false # Whether to run explo when starting the container (useful for testing) diff --git a/docker/start.sh b/docker/start.sh index a3b27d8..9b9e5fd 100644 --- a/docker/start.sh +++ b/docker/start.sh @@ -1,6 +1,33 @@ #!/bin/sh +echo "[setup] Starting web UI..." +# If user incorectly mounts the config path as a directory, we'll try to automatically append it to .env inside it instead of failing. +WEB_CFG_PATH="${WEB_CFG_PATH:-/opt/explo/.env}" +if [ -d "$WEB_CFG_PATH" ]; then + WEB_CFG_PATH="$WEB_CFG_PATH/.env" + echo "[setup] Config path is a directory, using $WEB_CFG_PATH" +fi +WEB_UI=true WEB_CFG_PATH="$WEB_CFG_PATH" WEB_ADDR="${WEB_ADDR:-:7288}" /opt/explo/explo & +echo "[setup] Web UI available at http://localhost:${WEB_ADDR##*:}" + echo "[setup] Initializing cron jobs..." +# Load *_SCHEDULE and *_FLAGS from .env if not already set in the environment. +# This allows the web UI to configure schedules by writing to the .env file. +_cfg="${WEB_CFG_PATH:-/opt/explo/.env}" +if [ -f "$_cfg" ]; then + while IFS= read -r _line; do + case "$_line" in \#*|'') continue ;; esac + _key="${_line%%=*}" + case "$_key" in + *_SCHEDULE|*_FLAGS) + if [ -z "$(printenv "$_key" 2>/dev/null)" ]; then + export "$_key=${_line#*=}" + fi + ;; + esac + done < "$_cfg" +fi + # $CRON_SHCEDULE was deprecated in v0.11.0, keeping this block for backwards compatibility if [ -n "$CRON_SCHEDULE" ]; then diff --git a/sample.env b/sample.env index b4097fa..bf10362 100644 --- a/sample.env +++ b/sample.env @@ -127,4 +127,4 @@ YOUTUBE_API_KEY= # Set the log level (DEBUG, INFO, WARN, ERROR) (default: INFO) # LOG_LEVEL=INFO # Set a custom HTTP timeout for music servers (in seconds) (default: 10) -# CLIENT_HTTP_TIMEOUT=10 \ No newline at end of file +# CLIENT_HTTP_TIMEOUT=10 diff --git a/src/client/plex.go b/src/client/plex.go index e51f016..4a0b982 100644 --- a/src/client/plex.go +++ b/src/client/plex.go @@ -33,10 +33,10 @@ type Libraries struct { Size int `json:"size"` AllowSync bool `json:"allowSync"` Title1 string `json:"title1"` - Library []struct { - Title string `json:"title"` - Key string `json:"key"` - Location []struct { + Library []struct { + Title string `json:"title"` + Key string `json:"key"` + Location []struct { ID int `json:"id"` Path string `json:"path"` } `json:"Location"` @@ -50,27 +50,27 @@ type PlexSearch struct { SearchResult []struct { Score float64 `json:"score"` Metadata struct { - LibrarySectionTitle string `json:"librarySectionTitle"` - RatingKey string `json:"ratingKey"` - Key string `json:"key"` - Type string `json:"type"` - Title string `json:"title"` // Track - GrandparentTitle string `json:"grandparentTitle"` // Artist - ParentTitle string `json:"parentTitle"` // Album - OriginalTitle string `json:"originalTitle"` - Summary string `json:"summary"` - Duration int `json:"duration"` - AddedAt int `json:"addedAt"` - UpdatedAt int `json:"updatedAt"` - Media []struct { - ID int `json:"id"` - Duration int `json:"duration"` - Part []struct { - ID int `json:"id"` - Key string `json:"key"` - Duration int `json:"duration"` - File string `json:"file"` - Size int `json:"size"` + LibrarySectionTitle string `json:"librarySectionTitle"` + RatingKey string `json:"ratingKey"` + Key string `json:"key"` + Type string `json:"type"` + Title string `json:"title"` // Track + GrandparentTitle string `json:"grandparentTitle"` // Artist + ParentTitle string `json:"parentTitle"` // Album + OriginalTitle string `json:"originalTitle"` + Summary string `json:"summary"` + Duration int `json:"duration"` + AddedAt int `json:"addedAt"` + UpdatedAt int `json:"updatedAt"` + Media []struct { + ID int `json:"id"` + Duration int `json:"duration"` + Part []struct { + ID int `json:"id"` + Key string `json:"key"` + Duration int `json:"duration"` + File string `json:"file"` + Size int `json:"size"` } `json:"Part"` AudioChannels int `json:"audioChannels"` AudioCodec string `json:"audioCodec"` @@ -81,7 +81,6 @@ type PlexSearch struct { } `json:"MediaContainer"` } - type PlexServer struct { MediaContainer struct { Size int `json:"size"` @@ -112,15 +111,15 @@ type PlexPlaylist struct { } type Plex struct { - machineID string - LibraryID string + machineID string + LibraryID string HttpClient *util.HttpClient - Cfg config.ClientConfig + Cfg config.ClientConfig } func NewPlex(cfg config.ClientConfig, httpClient *util.HttpClient) *Plex { return &Plex{ - Cfg: cfg, + Cfg: cfg, HttpClient: httpClient} } @@ -135,7 +134,7 @@ func (c *Plex) AddHeader() error { c.Cfg.Creds.Headers["X-Plex-Token"] = c.Cfg.Creds.APIKey if err := c.getServer(); err != nil { return err - } + } return nil } return fmt.Errorf("couldn't get API key") @@ -154,7 +153,6 @@ func (c *Plex) GetAuth() error { // Get user token from plex return fmt.Errorf("failed to marshal payload: %s", err.Error()) } - body, err := c.HttpClient.MakeRequest("POST", "https://plex.tv/users/sign_in.json", bytes.NewBuffer(payloadBytes), c.Cfg.Creds.Headers) if err != nil { return fmt.Errorf("%s", err.Error()) @@ -236,7 +234,7 @@ func (c *Plex) SearchSongs(tracks []*models.Track) error { slog.Warn("search request failed for '%s': %s", track.Title, err.Error()) continue } - + var searchResults PlexSearch if err = util.ParseResp(body, &searchResults); err != nil { slog.Warn("failed to parse response for '%s': %s", track.Title, err.Error()) @@ -277,7 +275,6 @@ func (c *Plex) SearchPlaylist() error { return nil } - func (c *Plex) CreatePlaylist(tracks []*models.Track) error { params := fmt.Sprintf("/playlists?title=%s&type=audio&smart=0&uri=server://%s/com.plexapp.plugins.library/%s", c.Cfg.PlaylistName, c.machineID, c.LibraryID) @@ -302,7 +299,7 @@ func (c *Plex) CreatePlaylist(tracks []*models.Track) error { func (c *Plex) UpdatePlaylist() error { params := fmt.Sprintf("/playlists/%s?summary=%s", c.Cfg.PlaylistID, url.QueryEscape(c.Cfg.PlaylistDescr)) - if _, err := c.HttpClient.MakeRequest("PUT",c.Cfg.URL+params, nil, c.Cfg.Creds.Headers); err != nil { + if _, err := c.HttpClient.MakeRequest("PUT", c.Cfg.URL+params, nil, c.Cfg.Creds.Headers); err != nil { return err } return nil @@ -358,7 +355,7 @@ func getPlexSong(track *models.Track, searchResults PlexSearch) (string, error) media := md.Media[0] pathMatch := strings.Contains(strings.ToLower(media.Part[0].File), strings.ToLower(track.File)) - durationMatch := util.Abs(media.Duration - track.Duration) < 10000 // duration within 10s + durationMatch := util.Abs(media.Duration-track.Duration) < 10000 // duration within 10s if durationMatch && pathMatch { slog.Debug(fmt.Sprintf("matched track via path: %s by %s", track.Title, track.Artist)) @@ -380,4 +377,4 @@ func (c *Plex) addtoPlaylist(tracks []*models.Track) { } } } -} \ No newline at end of file +} diff --git a/src/config/config.go b/src/config/config.go index a88068b..73eda41 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -15,110 +15,109 @@ import ( ) type Config struct { - DownloadCfg DownloadConfig + DownloadCfg DownloadConfig DiscoveryCfg DiscoveryConfig - ClientCfg ClientConfig - NotifyCfg NotifyConfig - Flags Flags - PersistENV bool `env:"PERSIST" env-default:"true"` - Persist bool - System string `env:"EXPLO_SYSTEM"` - Debug bool `env:"DEBUG" env-default:"false"` - LogLevel string `env:"LOG_LEVEL" env-default:"INFO"` + ClientCfg ClientConfig + NotifyCfg NotifyConfig + Flags Flags + PersistENV bool `env:"PERSIST" env-default:"true"` + Persist bool + System string `env:"EXPLO_SYSTEM"` + Debug bool `env:"DEBUG" env-default:"false"` + LogLevel string `env:"LOG_LEVEL" env-default:"INFO"` } type Flags struct { - CfgPath string - Playlist string + CfgPath string + Playlist string DownloadMode string ExcludeLocal bool - Persist bool - PersistSet bool + Persist bool + PersistSet bool } type ClientConfig struct { - ClientID string `env:"CLIENT_ID" env-default:"explo"` - LibraryName string `env:"LIBRARY_NAME" env-default:"Explo"` - URL string `env:"SYSTEM_URL"` - DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"` - PlaylistDir string `env:"PLAYLIST_DIR"` - PlaylistName string + ClientID string `env:"CLIENT_ID" env-default:"explo"` + LibraryName string `env:"LIBRARY_NAME" env-default:"Explo"` + URL string `env:"SYSTEM_URL"` + DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"` + PlaylistDir string `env:"PLAYLIST_DIR"` + PlaylistName string PlaylistNFormat string `env:"PLAYLISTNAME_FORMAT" env-default:"week"` - PlaylistDescr string - PlaylistID string - Sleep int `env:"SLEEP" env-default:"2"` - HTTPTimeout int `env:"CLIENT_HTTP_TIMEOUT" env-default:"10"` - Creds Credentials - AdminCreds AdminCredentials - Subsonic SubsonicConfig + PlaylistDescr string + PlaylistID string + Sleep int `env:"SLEEP" env-default:"2"` + HTTPTimeout int `env:"CLIENT_HTTP_TIMEOUT" env-default:"10"` + Creds Credentials + AdminCreds AdminCredentials + Subsonic SubsonicConfig } type Credentials struct { - APIKey string `env:"API_KEY"` - User string `env:"SYSTEM_USERNAME"` + APIKey string `env:"API_KEY"` + User string `env:"SYSTEM_USERNAME"` Password string `env:"SYSTEM_PASSWORD"` - Headers map[string]string - Token string - Salt string + Headers map[string]string + Token string + Salt string } type AdminCredentials struct { - User string `env:"ADMIN_SYSTEM_USERNAME"` + User string `env:"ADMIN_SYSTEM_USERNAME"` Password string `env:"ADMIN_SYSTEM_PASSWORD"` } - type SubsonicConfig struct { - Version string `env:"SUBSONIC_VERSION" env-default:"1.16.1"` - ID string `env:"CLIENT" env-default:"explo"` - PublicPlaylist bool `env:"PUBLIC_PLAYLIST" env-default:"false"` + Version string `env:"SUBSONIC_VERSION" env-default:"1.16.1"` + ID string `env:"CLIENT" env-default:"explo"` + PublicPlaylist bool `env:"PUBLIC_PLAYLIST" env-default:"false"` } type DownloadConfig struct { - DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"` - Youtube Youtube - YoutubeMusic YoutubeMusic - Slskd Slskd - ExcludeLocal bool - KeepPermissions bool `env:"KEEP_PERMISSIONS" env-default:"true"` // keep original file permissions when migrating download - RenameTrack bool `env:"RENAME_TRACK" env-default:"false"` // Rename track in {title}-{artist} format - UseSubDir bool `env:"USE_SUBDIRECTORY" env-default:"true"` - Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"` - Services []string `env:"DOWNLOAD_SERVICES" env-default:"youtube"` + DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"` + Youtube Youtube + YoutubeMusic YoutubeMusic + Slskd Slskd + ExcludeLocal bool + KeepPermissions bool `env:"KEEP_PERMISSIONS" env-default:"true"` // keep original file permissions when migrating download + RenameTrack bool `env:"RENAME_TRACK" env-default:"false"` // Rename track in {title}-{artist} format + UseSubDir bool `env:"USE_SUBDIRECTORY" env-default:"true"` + Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"` + Services []string `env:"DOWNLOAD_SERVICES" env-default:"youtube"` } type Filters struct { - Extensions []string `env:"EXTENSIONS" env-default:"flac,mp3"` - MinBitDepth int `env:"MIN_BIT_DEPTH" env-default:"8"` - MinBitRate int `env:"MIN_BITRATE" env-default:"256"` - FilterList []string `env:"FILTER_LIST" env-default:"live,remix,instrumental,extended,clean,acapella"` + Extensions []string `env:"EXTENSIONS" env-default:"flac,mp3"` + MinBitDepth int `env:"MIN_BIT_DEPTH" env-default:"8"` + MinBitRate int `env:"MIN_BITRATE" env-default:"256"` + FilterList []string `env:"FILTER_LIST" env-default:"live,remix,instrumental,extended,clean,acapella"` } type Youtube struct { - APIKey string `env:"YOUTUBE_API_KEY"` - FfmpegPath string `env:"FFMPEG_PATH"` - YtdlpPath string `env:"YTDLP_PATH"` + APIKey string `env:"YOUTUBE_API_KEY"` + FfmpegPath string `env:"FFMPEG_PATH"` + YtdlpPath string `env:"YTDLP_PATH"` FileExtension string `env:"TRACK_EXTENSION" env-default:"opus"` - CookiesPath string `env:"COOKIES_PATH" env-default:"./cookies.txt"` - Filters Filters + CookiesPath string `env:"COOKIES_PATH" env-default:"./cookies.txt"` + Filters Filters } type YoutubeMusic struct { FfmpegPath string `env:"FFMPEG_PATH"` - YtdlpPath string `env:"YTDLP_PATH"` - Filters Filters + YtdlpPath string `env:"YTDLP_PATH"` + Filters Filters } type Slskd struct { - APIKey string `env:"SLSKD_API_KEY"` - URL string `env:"SLSKD_URL"` - Retry int `env:"SLSKD_RETRY" env-default:"5"` // Number of times to check search status before skipping the track - DownloadAttempts int `env:"SLSKD_DL_ATTEMPTS" env-default:"3"` // Max number of files to attempt downloading per track - SlskdDir string `env:"SLSKD_DIR" env-default:"/slskd/"` - MigrateDL bool `env:"MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from SlskdDir to DownloadDir - Timeout int `env:"SLSKD_TIMEOUT" env-default:"20"` - Filters Filters - MonitorConfig SlskdMon + APIKey string `env:"SLSKD_API_KEY"` + URL string `env:"SLSKD_URL"` + Retry int `env:"SLSKD_RETRY" env-default:"5"` // Number of times to check search status before skipping the track + DownloadAttempts int `env:"SLSKD_DL_ATTEMPTS" env-default:"3"` // Max number of files to attempt downloading per track + SlskdDir string `env:"SLSKD_DIR" env-default:"/slskd/"` + MigrateDL bool `env:"MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from SlskdDir to DownloadDir + Timeout int `env:"SLSKD_TIMEOUT" env-default:"20"` + Filters Filters + MonitorConfig SlskdMon } type SlskdMon struct { @@ -127,37 +126,38 @@ type SlskdMon struct { } type DiscoveryConfig struct { - Discovery string `env:"DISCOVERY_SERVICE" env-default:"listenbrainz"` + Discovery string `env:"DISCOVERY_SERVICE" env-default:"listenbrainz"` Listenbrainz Listenbrainz } type Listenbrainz struct { - Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"` - User string `env:"LISTENBRAINZ_USER"` + Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"` + User string `env:"LISTENBRAINZ_USER"` ImportPlaylist string - SingleArtist bool `env:"SINGLE_ARTIST" env-default:"true"` + SingleArtist bool `env:"SINGLE_ARTIST" env-default:"true"` } type NotifyConfig struct { - Matrix MatrixNotif + Matrix MatrixNotif Discord DiscordNotif - Http HttpNotif + Http HttpNotif } type MatrixNotif struct { - UserID string `env:"MATRIX_USERID"` - RoomID string `env:"MATRIX_ROOMID"` - HomeServer string `env:"MATRIX_HOMESERVER_URL"` + UserID string `env:"MATRIX_USERID"` + RoomID string `env:"MATRIX_ROOMID"` + HomeServer string `env:"MATRIX_HOMESERVER_URL"` AccessToken string `env:"MATRIX_ACCESSTOKEN"` } type DiscordNotif struct { - BotToken string `env:"DISCORD_BOT_TOKEN"` + BotToken string `env:"DISCORD_BOT_TOKEN"` ChannelIDs []string `env:"DISCORD_CHANNEL_ID"` } type HttpNotif struct { ReceiverURLs []string `env:"HTTP_RECEIVER"` } + func (cfg *Config) ReadEnv() { // Try to read from .env file first @@ -180,7 +180,8 @@ func (cfg *Config) ReadEnv() { func (cfg *Config) CommonFixes() { cfg.DownloadCfg.Youtube.FileExtension = strings.TrimPrefix(cfg.DownloadCfg.Youtube.FileExtension, ".") - cfg.ClientCfg.URL = strings.TrimSuffix(cfg.ClientCfg.URL, "/") + cfg.ClientCfg.URL = fixBaseURL(cfg.ClientCfg.URL) + cfg.DownloadCfg.Slskd.URL = fixBaseURL(cfg.DownloadCfg.Slskd.URL) cfg.NormalizeDir() } @@ -199,7 +200,18 @@ func fixDir(dir string) string { return dir } -func (cfg *Config) HandleDeprecation() { // +func fixBaseURL(rawURL string) string { + u := strings.TrimSpace(rawURL) + if u == "" { + return "" + } + if !strings.Contains(u, "://") { + u = "http://" + u + } + return strings.TrimRight(u, "/") +} + +func (cfg *Config) HandleDeprecation() { // if cfg.Debug { slog.Warn("'DEBUG' variable is deprecated, please use LOG_LEVEL=DEBUG instead") cfg.LogLevel = "DEBUG" @@ -215,14 +227,13 @@ func (cfg *Config) HandleDeprecation() { // func (cfg *Config) GenPlaylistName() { // Generate playlist name and description - cfg.ClientCfg.PlaylistName = getPlaylistName(cfg.Flags.Playlist, cfg.ClientCfg.PlaylistNFormat, cfg.Persist) cfg.ClientCfg.PlaylistDescr = fmt.Sprintf( "Created for %s by Explo, using ListenBrainz recommendations.", cfg.DiscoveryCfg.Listenbrainz.User) if cfg.DownloadCfg.UseSubDir { - // add playlist name to downloadDir so all songs get downloaded to a single sub directory. + // add playlist name to downloadDir so all songs get downloaded to a single sub directory. cfg.DownloadCfg.DownloadDir = filepath.Join( cfg.DownloadCfg.DownloadDir, cfg.ClientCfg.PlaylistName) @@ -266,4 +277,4 @@ func getPlaylistName(playlistType, format string, persist bool) string { year, week, ) -} \ No newline at end of file +} diff --git a/src/config/flags.go b/src/config/flags.go index 9874337..58cf0a5 100644 --- a/src/config/flags.go +++ b/src/config/flags.go @@ -1,67 +1,67 @@ -package config - -import ( - "slices" - "fmt" - "strings" - flag "github.com/spf13/pflag" -) - -var ( - validPlaylists = []string{"weekly-exploration", "weekly-jams", "daily-jams"} - validDownloadMode = []string{"normal", "skip", "force"} -) - -func (cfg *Config) GetFlags() error { - var configPath string - var playlist string - var downloadMode string - var excludeLocal bool - var persist bool - // Long flags - flag.StringVarP(&configPath, "config", "c", ".env", "Path of the configuration file") - flag.StringVarP(&playlist, "playlist", "p", "weekly-exploration", "Playlist where to get tracks. Supported: weekly-exploration, weekly-jams, daily-jams") - flag.StringVarP(&downloadMode, "download-mode", "d", "normal", "Download mode: 'normal' (download only when track is not found locally), 'skip' (skip downloading, only use tracks already found locally), 'force' (always download, don't check for local tracks)") - flag.BoolVarP(&excludeLocal, "exclude-local", "e", false, "Exclude locally found tracks from the imported playlist") - flag.BoolVar(&persist, "persist", true, "Keep playlists between generations") - - flag.Parse() - persistSet := flag.Lookup("persist").Changed - - // Validation for playlist - if !contains(validPlaylists, playlist) { - return fmt.Errorf("flag validation error: invalid playlist %s (must be one of: %s)", - playlist, strings.Join(validPlaylists, ", ")) - } - - // Validation for download mode - if !contains(validDownloadMode, downloadMode) { - return fmt.Errorf("flag validation error: invalid download mode %s (must be one of: %s)", - downloadMode, strings.Join(validDownloadMode, ", ")) - } - - cfg.Flags.CfgPath = configPath - cfg.Flags.Playlist = playlist - cfg.Flags.DownloadMode = downloadMode - cfg.Flags.ExcludeLocal = excludeLocal - cfg.Flags.Persist = persist - - // for deprecation purposes (can be removed at a later date) - cfg.Flags.PersistSet = persistSet - - return nil -} - -func (cfg *Config) MergeFlags() { - cfg.DiscoveryCfg.Listenbrainz.ImportPlaylist = cfg.Flags.Playlist - cfg.DownloadCfg.ExcludeLocal = cfg.Flags.ExcludeLocal - if cfg.Flags.PersistSet { - cfg.Persist = cfg.Flags.Persist - } else { - cfg.Persist = cfg.PersistENV - } -} - -func contains(valid []string, val string) bool { - return slices.Contains(valid, val) -} \ No newline at end of file +package config + +import ( + "fmt" + flag "github.com/spf13/pflag" + "slices" + "strings" +) + +var ( + validPlaylists = []string{"weekly-exploration", "weekly-jams", "daily-jams"} + validDownloadMode = []string{"normal", "skip", "force"} +) + +func (cfg *Config) GetFlags() error { + var configPath string + var playlist string + var downloadMode string + var excludeLocal bool + var persist bool + // Long flags + flag.StringVarP(&configPath, "config", "c", ".env", "Path of the configuration file") + flag.StringVarP(&playlist, "playlist", "p", "weekly-exploration", "Playlist where to get tracks. Supported: weekly-exploration, weekly-jams, daily-jams") + flag.StringVarP(&downloadMode, "download-mode", "d", "normal", "Download mode: 'normal' (download only when track is not found locally), 'skip' (skip downloading, only use tracks already found locally), 'force' (always download, don't check for local tracks)") + flag.BoolVarP(&excludeLocal, "exclude-local", "e", false, "Exclude locally found tracks from the imported playlist") + flag.BoolVar(&persist, "persist", true, "Keep playlists between generations") + + flag.Parse() + persistSet := flag.Lookup("persist").Changed + + // Validation for playlist + if !contains(validPlaylists, playlist) { + return fmt.Errorf("flag validation error: invalid playlist %s (must be one of: %s)", + playlist, strings.Join(validPlaylists, ", ")) + } + + // Validation for download mode + if !contains(validDownloadMode, downloadMode) { + return fmt.Errorf("flag validation error: invalid download mode %s (must be one of: %s)", + downloadMode, strings.Join(validDownloadMode, ", ")) + } + + cfg.Flags.CfgPath = configPath + cfg.Flags.Playlist = playlist + cfg.Flags.DownloadMode = downloadMode + cfg.Flags.ExcludeLocal = excludeLocal + cfg.Flags.Persist = persist + + // for deprecation purposes (can be removed at a later date) + cfg.Flags.PersistSet = persistSet + + return nil +} + +func (cfg *Config) MergeFlags() { + cfg.DiscoveryCfg.Listenbrainz.ImportPlaylist = cfg.Flags.Playlist + cfg.DownloadCfg.ExcludeLocal = cfg.Flags.ExcludeLocal + if cfg.Flags.PersistSet { + cfg.Persist = cfg.Flags.Persist + } else { + cfg.Persist = cfg.PersistENV + } +} + +func contains(valid []string, val string) bool { + return slices.Contains(valid, val) +} diff --git a/src/discovery/listenbrainz.go b/src/discovery/listenbrainz.go index 4a19d15..3151e8c 100644 --- a/src/discovery/listenbrainz.go +++ b/src/discovery/listenbrainz.go @@ -1,11 +1,11 @@ package discovery import ( + "errors" "fmt" + "log/slog" "strings" "time" - "log/slog" - "errors" cfg "explo/src/config" "explo/src/models" @@ -63,16 +63,16 @@ type CreatedFor struct { PlaylistCount int `json:"playlist_count"` Playlists []struct { Playlist struct { - Creator string `json:"creator"` - Date time.Time `json:"date"` - Extension struct { + Creator string `json:"creator"` + Date time.Time `json:"date"` + Extension struct { HTTPSJspfPlaylist struct { AdditionalMetadata struct { AlgorithmMetadata struct { SourcePatch string `json:"source_patch"` } `json:"algorithm_metadata"` } `json:"additional_metadata"` - CreatedFor string `json:"created_for"` + CreatedFor string `json:"created_for"` } `json:"https://musicbrainz.org/doc/jspf#playlist"` } `json:"extension"` Identifier string `json:"identifier"` @@ -88,9 +88,9 @@ type Exploration struct { Identifier string `json:"identifier"` Title string `json:"title"` Tracks []struct { - Album string `json:"album"` - Creator string `json:"creator"` - Duration int `json:"duration"` + Album string `json:"album"` + Creator string `json:"creator"` + Duration int `json:"duration"` Extension struct { HTTPSJspfTrack struct { AddedAt time.Time `json:"added_at"` @@ -108,25 +108,24 @@ type Exploration struct { } `json:"https://musicbrainz.org/doc/jspf#track"` } `json:"extension"` Identifier []string `json:"identifier"` - Title string `json:"title"` + Title string `json:"title"` } `json:"track"` } `json:"playlist"` } type ListenBrainz struct { HttpClient *util.HttpClient - cfg cfg.Listenbrainz - Separator string + cfg cfg.Listenbrainz + Separator string } - func NewListenBrainz(cfg cfg.DiscoveryConfig, httpClient *util.HttpClient) *ListenBrainz { return &ListenBrainz{ - cfg: cfg.Listenbrainz, + cfg: cfg.Listenbrainz, HttpClient: httpClient, } } -func (c *ListenBrainz) QueryTracks() ([]*models.Track, error) { +func (c *ListenBrainz) QueryTracks() ([]*models.Track, error) { var tracks []*models.Track switch c.cfg.Discovery { @@ -139,7 +138,7 @@ func (c *ListenBrainz) QueryTracks() ([]*models.Track, error) { if err != nil { return nil, err } - + default: mbids, err := c.getAPIRecommendations(c.cfg.User) if err != nil { @@ -203,7 +202,7 @@ func (c *ListenBrainz) getTracks(mbids []string, singleArtist bool) ([]*models.T mainArtist := recording.Artist.Name recArtists := recording.Artist.Artists - + if len(recArtists) > 1 { mainArtist = recArtists[0].Name if singleArtist { @@ -219,16 +218,16 @@ func (c *ListenBrainz) getTracks(mbids []string, singleArtist bool) ([]*models.T title = b.String() artist = mainArtist - } + } } tracks = append(tracks, &models.Track{ - Album: recording.Release.Name, - Artist: artist, - MainArtist: mainArtist, - CleanTitle: rec.Name, - Title: title, - Duration: rec.Length, + Album: recording.Release.Name, + Artist: artist, + MainArtist: mainArtist, + CleanTitle: rec.Name, + Title: title, + Duration: rec.Length, }) } @@ -266,7 +265,7 @@ func (c *ListenBrainz) getImportPlaylist(user string) (string, error) { if err != nil { return "", fmt.Errorf("getImportPlaylist(): %s", err.Error()) } - + if id, err := c.parseCreatedFor(playlists); err == nil { return id, nil } @@ -289,7 +288,7 @@ func (c *ListenBrainz) parseCreatedFor(playlists CreatedFor) (string, error) { } else { _, currentWeek = now.ISOWeek() } - + for _, p := range playlists.Playlists { meta := p.Playlist.Extension.HTTPSJspfPlaylist.AdditionalMetadata @@ -354,7 +353,7 @@ func (c *ListenBrainz) parsePlaylist(identifier string, singleArtist bool) ([]*m for _, a := range trackArtists[2:] { b.WriteString(", ") b.WriteString(a.ArtistCreditName) -} + } title = b.String() artist = trackArtists[0].ArtistCreditName } @@ -377,9 +376,7 @@ func (c *ListenBrainz) parsePlaylist(identifier string, singleArtist bool) ([]*m // Handle ListenBrainz API requests func (c *ListenBrainz) lbRequest(path string) ([]byte, error) { - reqURL := fmt.Sprintf("https://api.listenbrainz.org/1/%s", path) - body, err := c.HttpClient.MakeRequest("GET", reqURL, nil, nil) if err != nil { return nil, fmt.Errorf("failed to make request to ListenBrainz API: %s", err) @@ -388,6 +385,6 @@ func (c *ListenBrainz) lbRequest(path string) ([]byte, error) { if len(body) == 0 { return nil, fmt.Errorf("ListenBrainz API returned empty response for: %s", reqURL) } - + return body, nil -} \ No newline at end of file +} diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go index b8393b4..c979265 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -26,6 +26,7 @@ type Downloader interface { GetTrack(*models.Track) error Monitor } + // get download services from config and append them to DownloadClient func NewDownloader(cfg *cfg.DownloadConfig, httpClient *util.HttpClient, filterLocal bool) (*DownloadClient, error) { var downloader []Downloader @@ -55,7 +56,7 @@ func (c *DownloadClient) StartDownload(tracks *[]*models.Track) { slog.Error(err.Error()) return } - + for _, d := range c.Downloaders { var g errgroup.Group g.SetLimit(1) @@ -132,7 +133,7 @@ func getFilename(title, artist string) string { // Remove illegal characters for file naming t := util.FilenameSafe(title) - a :=util.FilenameSafe(artist) + a := util.FilenameSafe(artist) // truncate long filename runes := []rune(fmt.Sprintf("%s-%s", t, a)) @@ -150,14 +151,14 @@ func ContainsKeyword(track models.Track, contentTitle string, filterList []strin content := strings.ToLower(contentTitle) for _, keyword := range filterList { - keyword = strings.ToLower(keyword) - if strings.Contains(title, keyword) || strings.Contains(artist, keyword) { - continue - } - if strings.Contains(content, keyword) { - return true + keyword = strings.ToLower(keyword) + if strings.Contains(title, keyword) || strings.Contains(artist, keyword) { + continue + } + if strings.Contains(content, keyword) { + return true + } } -} return false } @@ -191,7 +192,7 @@ func (c *DownloadClient) MoveDownload(srcDir, destDir, trackPath string, track * if err = os.MkdirAll(destDir, os.ModePerm); err != nil { return fmt.Errorf("couldn't make download directory: %s", err.Error()) - } + } dstFile := filepath.Join(destDir, track.File) out, err := os.Create(dstFile) @@ -258,4 +259,4 @@ func isDirEmpty(path string) (bool, error) { return true, nil // no entries } return false, err -} \ No newline at end of file +} diff --git a/src/main/main.go b/src/main/main.go index e034f4b..164a217 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -2,6 +2,7 @@ package main import ( "explo/src/logging" + "explo/src/web" "log" "log/slog" "os" @@ -34,6 +35,23 @@ func setup(cfg *config.Config) { } func main() { + if os.Getenv("WEB_UI") == "true" { + cfgPath := os.Getenv("WEB_CFG_PATH") + if cfgPath == "" { + cfgPath = ".env" + } + exploPath, err := os.Executable() + if err != nil { + log.Fatal("could not determine executable path: ", err) + } + addr := os.Getenv("WEB_ADDR") + if addr == "" { + addr = ":7288" + } + srv := web.NewServer(cfgPath, exploPath) + log.Fatal(srv.Start(addr)) + } + var cfg config.Config if err := cfg.GetFlags(); err != nil { log.Fatal(err) @@ -44,19 +62,19 @@ func main() { slog.Info("Starting Explo...") httpClient := initHttpClient() - client, err := client.NewClient(&cfg) + discovery := discovery.NewDiscoverer(cfg.DiscoveryCfg, httpClient) + tracks, err := discovery.Discover() if err != nil { slog.Error(err.Error(), "notify", true) os.Exit(1) } - discovery := discovery.NewDiscoverer(cfg.DiscoveryCfg, httpClient) - downloader, err := downloader.NewDownloader(&cfg.DownloadCfg, httpClient, cfg.Flags.ExcludeLocal) + + client, err := client.NewClient(&cfg) if err != nil { slog.Error(err.Error(), "notify", true) os.Exit(1) } - - tracks, err := discovery.Discover() + downloader, err := downloader.NewDownloader(&cfg.DownloadCfg, httpClient, cfg.Flags.ExcludeLocal) if err != nil { slog.Error(err.Error(), "notify", true) os.Exit(1) @@ -89,4 +107,4 @@ func main() { } else { slog.Info("playlist created successfully", "system", cfg.System, "playlistName", cfg.ClientCfg.PlaylistName, "notify", true) } -} \ No newline at end of file +} diff --git a/src/models/types.go b/src/models/types.go index 91cf956..18ef47e 100644 --- a/src/models/types.go +++ b/src/models/types.go @@ -3,15 +3,15 @@ package models // for structs used across the project type Track struct { - Album string - ID string - Artist string // All artists as returned by LB - MainArtist string + Album string + ID string + Artist string // All artists as returned by LB + MainArtist string MainArtistID string - CleanTitle string // Title as returned by LB - Title string // Title as built in listenbrainz.go - File string // File name - Size int // File size - Present bool // is track present in the system or not - Duration int // Track duration in milliseconds (not available for every track) -} \ No newline at end of file + CleanTitle string // Title as returned by LB + Title string // Title as built in listenbrainz.go + File string // File name + Size int // File size + Present bool // is track present in the system or not + Duration int // Track duration in milliseconds (not available for every track) +} diff --git a/src/web/frontend/index.html b/src/web/frontend/index.html new file mode 100644 index 0000000..9fdd16a --- /dev/null +++ b/src/web/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Explo + + +
+ + + diff --git a/src/web/frontend/package-lock.json b/src/web/frontend/package-lock.json new file mode 100644 index 0000000..9ffa4ba --- /dev/null +++ b/src/web/frontend/package-lock.json @@ -0,0 +1,2383 @@ +{ + "name": "explo-frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "explo-frontend", + "version": "0.0.1", + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@vitejs/plugin-react": "^4.3.0", + "tailwindcss": "^4.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", + "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", + "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-x64": "4.2.4", + "@tailwindcss/oxide-freebsd-x64": "4.2.4", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-x64-musl": "4.2.4", + "@tailwindcss/oxide-wasm32-wasi": "4.2.4", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", + "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", + "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", + "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", + "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", + "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", + "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", + "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", + "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", + "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", + "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", + "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", + "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", + "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", + "tailwindcss": "4.2.4" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz", + "integrity": "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001790", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", + "integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", + "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", + "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/src/web/frontend/package.json b/src/web/frontend/package.json new file mode 100644 index 0000000..fd89b72 --- /dev/null +++ b/src/web/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "explo-frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@vitejs/plugin-react": "^4.3.0", + "tailwindcss": "^4.0.0", + "vite": "^6.0.0" + } +} diff --git a/src/web/frontend/src/App.jsx b/src/web/frontend/src/App.jsx new file mode 100644 index 0000000..3312bc9 --- /dev/null +++ b/src/web/frontend/src/App.jsx @@ -0,0 +1,38 @@ +import { useState, useEffect } from 'react' +import { fetchConfig } from './lib/api' +import Wizard from './components/Wizard' +import Settings from './components/Settings' + +export default function App() { + const [view, setView] = useState(null) + const [config, setConfig] = useState({}) + const [envSources, setEnvSources] = useState({}) + + useEffect(() => { + fetchConfig().then(({ values, sources }) => { + setConfig(values) + setEnvSources(sources || {}) + setView(values.LISTENBRAINZ_USER ? 'settings' : 'wizard') + }) + }, []) + + if (!view) return
+ + if (view === 'wizard') { + return ( + { + fetchConfig().then(({ values, sources }) => { + setConfig(values) + setEnvSources(sources || {}) + setView('settings') + }) + }} + /> + ) + } + + return setView('wizard')} /> +} diff --git a/src/web/frontend/src/components/Settings.jsx b/src/web/frontend/src/components/Settings.jsx new file mode 100644 index 0000000..62c78a8 --- /dev/null +++ b/src/web/frontend/src/components/Settings.jsx @@ -0,0 +1,518 @@ +/** + * Settings.jsx + * + * Main app view after initial setup. Three tabs: Home, Settings, Logs. + * Each section fetches its own data directly from the API β€” no prop-drilling. + * + * Sections: + * HomeSection β€” scheduled playlists, manual run, live output + * ConfigSection β€” raw .env editor, wizard re-run, reset + * LogsSection β€” full server log viewer + */ + +import { useState, useEffect, useCallback, useRef } from 'react' +import { + fetchConfig, fetchConfigRaw, saveConfig, resetConfig, + saveSchedule, startRun, stopRun, fetchRunStatus, fetchLogs, +} from '../lib/api' +import { parseSlogLine, cronToFields, highlightEnv } from '../lib/utils' +import { Toggle } from './ui/Toggle' +import { Button, SectionLabel, Panel, LogRow } from './ui/common' + +const tabBtnCls = active => + `bg-transparent border-none border-b-2 pb-2 px-3.5 text-[13px] cursor-pointer transition-colors relative top-px + ${active ? 'text-white border-accent' : 'text-muted border-transparent hover:text-white'}` + +// ── Home Tab ────────────────────────────────────────────────────────────────── +// Manages scheduled playlists, manual runs, and live run output. +// Fetches its own config on mount to initialise schedule state and locked keys. + +// Streams live run output from /api/run/events +function useSSE({ onLine, onDone }) { + const abortRef = useRef(null) + + const connect = useCallback(async () => { + if (abortRef.current) abortRef.current.abort() + const controller = new AbortController() + abortRef.current = controller + try { + const res = await fetch('/api/run/events', { signal: controller.signal }) + if (!res.ok) { onDone(null); return } + const reader = res.body.getReader() + const dec = new TextDecoder() + let buf = '' + while (true) { + const { done, value } = await reader.read() + if (done) break + buf += dec.decode(value, { stream: true }) + const parts = buf.split('\n\n') + buf = parts.pop() + for (const part of parts) { + let ev = '', data = '' + for (const l of part.split('\n')) { + if (l.startsWith('event: ')) ev = l.slice(7).trim() + if (l.startsWith('data: ')) data = l.slice(6) + } + if (ev === 'done') { onDone(parseInt(data)); return } + else if (data) onLine(data) + } + } + } catch (e) { + if (e.name !== 'AbortError') onDone(null) + } finally { + if (abortRef.current === controller) abortRef.current = null + } + }, [onLine, onDone]) + + const disconnect = useCallback(() => { + abortRef.current?.abort() + abortRef.current = null + }, []) + + return { connect, disconnect } +} + +const PLAYLISTS = [ + { value: 'weekly-exploration', name: 'Weekly Exploration' }, + { value: 'weekly-jams', name: 'Weekly Jams' }, + { value: 'daily-jams', name: 'Daily Jams' }, +] + +const SCHEDULE_KEYS = { + 'weekly-exploration': 'WEEKLY_EXPLORATION_SCHEDULE', + 'weekly-jams': 'WEEKLY_JAMS_SCHEDULE', + 'daily-jams': 'DAILY_JAMS_SCHEDULE', +} + +const SCHEDULE_DAYS = [ + { value: -1, label: 'Every day', summary: 'Daily' }, + { value: 0, label: 'Sunday', summary: 'Every Sunday' }, + { value: 1, label: 'Monday', summary: 'Every Monday' }, + { value: 2, label: 'Tuesday', summary: 'Every Tuesday' }, + { value: 3, label: 'Wednesday', summary: 'Every Wednesday' }, + { value: 4, label: 'Thursday', summary: 'Every Thursday' }, + { value: 5, label: 'Friday', summary: 'Every Friday' }, + { value: 6, label: 'Saturday', summary: 'Every Saturday' }, +] + +const selectCls = 'bg-surface border border-ui-border text-white rounded-[6px] px-2.5 py-1.5 text-[13px] cursor-pointer outline-none focus:border-accent' + +function initSchedules(config) { + const defaults = { + 'weekly-exploration': { enabled: false, day: 2, hour: 0, minute: 15, editing: false }, + 'weekly-jams': { enabled: false, day: 1, hour: 0, minute: 30, editing: false }, + 'daily-jams': { enabled: false, day: -1, hour: 1, minute: 15, editing: false }, + } + const out = {} + for (const [name, def] of Object.entries(defaults)) { + const cron = config[SCHEDULE_KEYS[name]] + out[name] = cron ? { enabled: true, editing: false, ...cronToFields(cron) } : def + } + return out +} + +function HomeSection() { + const [schedules, setSchedules] = useState(null) + const [envSources, setEnvSources] = useState({}) + const [scheduleSaveStatus, setScheduleSaveStatus] = useState({}) + + const [playlist, setPlaylist] = useState('weekly-exploration') + const [dlmode, setDlmode] = useState('normal') + const [noPersist, setNoPersist] = useState(false) + const [excludeLocal, setExcludeLocal] = useState(false) + + const [running, setRunning] = useState(false) + const [status, setStatus] = useState('') + const [logEntries, setLogEntries] = useState([]) + const [rawLog, setRawLog] = useState(false) + const [recentTracks, setRecentTracks] = useState([]) + const logRef = useRef(null) + + useEffect(() => { + fetchConfig().then(({ values, sources }) => { + setSchedules(initSchedules(values)) + setEnvSources(sources || {}) + }) + fetchLogs().then(text => { + const entries = text.split('\n').filter(l => l.trim()).map(l => ({ raw: l, ...parseSlogLine(l) })) + setRecentTracks(entries.filter(e => e.track && e.level === 'INFO').reverse()) + }) + }, []) + + const onLine = useCallback(data => { + setLogEntries(prev => [...prev, { raw: data, ...parseSlogLine(data) }]) + requestAnimationFrame(() => { + if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight + }) + }, []) + + const onDone = useCallback(code => { + setStatus(code === 0 ? 'done βœ“' : code === null ? 'error' : `failed (exit ${code})`) + setRunning(false) + }, []) + + const { connect, disconnect } = useSSE({ onLine, onDone }) + + useEffect(() => { + fetchRunStatus().then(s => { + if (s.running) { + setRunning(true) + setStatus('running…') + setLogEntries([]) + connect() + } + }) + return () => disconnect() + }, [connect, disconnect]) + + const isScheduleLocked = name => envSources[SCHEDULE_KEYS[name]] === 'env' + + const scheduleTime = name => { + const s = schedules[name] + return `${String(s.hour).padStart(2, '0')}:${String(s.minute).padStart(2, '0')}` + } + + const scheduleSummary = day => SCHEDULE_DAYS.find(d => d.value === day)?.summary || 'Daily' + + const nextRunText = name => { + const s = schedules[name] + if (!s.enabled) return 'Disabled' + return `${scheduleSummary(s.day)} at ${String(s.hour).padStart(2, '0')}:${String(s.minute).padStart(2, '0')}` + } + + const updateScheduleTime = (name, val) => { + const [h = '00', m = '00'] = val.split(':') + setSchedules(prev => ({ + ...prev, + [name]: { ...prev[name], hour: parseInt(h) || 0, minute: parseInt(m) || 0 }, + })) + } + + const handleSaveSchedule = async name => { + if (isScheduleLocked(name)) return + const s = schedules[name] + try { + await saveSchedule(name, s.enabled, s.day, s.hour, s.minute) + setScheduleSaveStatus(prev => ({ ...prev, [name]: 'Saved.' })) + setTimeout(() => setScheduleSaveStatus(prev => ({ ...prev, [name]: '' })), 2000) + } catch { + setScheduleSaveStatus(prev => ({ ...prev, [name]: 'Error saving.' })) + } + } + + const handleRun = async () => { + setRunning(true) + setLogEntries([]) + setStatus('running…') + try { + await startRun(playlist, dlmode, !noPersist, excludeLocal) + connect() + } catch (e) { + if (e.conflict) { setStatus('already running'); setRunning(false); return } + setStatus('error') + setRunning(false) + } + } + + const handleStop = async () => { + setStatus('stopping…') + try { await stopRun() } + catch { setStatus('error stopping run') } + } + + if (!schedules) return null + + return ( +
+ {/* Scheduled Playlists */} +
+ Scheduled Playlists + {PLAYLISTS.map(p => { + const s = schedules[p.value] + const locked = isScheduleLocked(p.value) + return ( +
+
+ + + {locked ? 'Set via Docker' : (scheduleSaveStatus[p.value] || '')} +
+ + {s.editing && s.enabled && !locked && ( +
+
+ Runs + + at + updateScheduleTime(p.value, e.target.value)} + className="bg-surface border border-ui-border text-white rounded-[6px] px-2 py-1.5 text-[13px] outline-none focus:border-accent" + /> +
+
+ + +
+
+ )} +
+ ) + })} +

Schedule changes take effect after restarting the container.

+
+ + {/* Manual Run */} +
+ Manual run +
+ + + + + + +
+
+ + {running && ( + + )} + {status} +
+
+ + {/* Recent Tracks */} + {recentTracks.length > 0 && ( +
+ Recent tracks + + {recentTracks.slice(0, 50).map((e, i) => ( +
+ {e.time} + {e.track} +
+ ))} +
+
+ )} + + {/* Output */} +
+
+ Output + +
+ + {logEntries.map((e, i) => ( + rawLog + ?
{e.raw}
+ : + ))} +
+
+
+ ) +} + +// ── Config Tab ──────────────────────────────────────────────────────────────── +// Raw .env file viewer/editor, plus wizard re-run and full reset actions. +// Fetches its own raw config text from the API. + +function ConfigSection({ onWizard }) { + const [rawConfig, setRawConfig] = useState('') + const [editing, setEditing] = useState(false) + const [saveStatus, setSaveStatus] = useState('') + + useEffect(() => { + fetchConfigRaw().then(text => setRawConfig(text)) + }, []) + + const handleSave = async () => { + try { + await saveConfig(rawConfig) + setEditing(false) + setSaveStatus('Saved.') + setTimeout(() => setSaveStatus(''), 2500) + } catch { + setSaveStatus('Error saving.') + } + } + + const handleReset = async () => { + if (!confirm('Reset all settings? This will restart the container and take you back to setup.')) return + try { + await resetConfig() + const poll = async () => { + try { await fetch('/api/config'); location.reload() } + catch { setTimeout(poll, 1500) } + } + setTimeout(poll, 3000) + } catch (e) { + alert('Reset failed: ' + e.message) + } + } + + return ( +
+
+
+ Config file + {!editing ? ( + + ) : ( +
+ {saveStatus} + + +
+ )} +
+ + {!editing ? ( +
+        ) : (
+