feat(web): move from react-router to @tanstack/router (#1338)

* fix(auth): invalid cookie handling and wrongful basic auth invalidation

* fix(auth): fix test to reflect new HTTP status code

* fix(auth/web): do not throw on error

* fix(http): replace http codes in middleware to prevent basic auth invalidation
fix typo in comment

* fix test

* fix(web): api client handle 403

* refactor(http): auth_test use testify.assert

* refactor(http): set session opts after valid login

* refactor(http): send more client headers

* fix(http): test

* refactor(web): move router to tanstack/router

* refactor(web): use route loaders and suspense

* refactor(web): useSuspense for settings

* refactor(web): invalidate cookie in middleware

* fix: loclfile

* fix: load filter/id

* fix(web): login, onboard, types, imports

* fix(web): filter load

* fix(web): build errors

* fix(web): ts-expect-error

* fix(tests): filter_test.go

* fix(filters): tests

* refactor: remove duplicate spinner components
refactor: ReleaseTable.tsx loading animation
refactor: remove dedicated `pendingComponent` for `settingsRoute`

* fix: refactor missed SectionLoader to RingResizeSpinner

* fix: substitute divides with borders to account for unloaded elements

* fix(api): action status URL param

* revert: action status URL param
add comment

* fix(routing): notfound handling and split files

* fix(filters): notfound get params

* fix(queries): colon

* fix(queries): comments ts-ignore

* fix(queries): extract queryKeys

* fix(queries): remove err

* fix(routes): move zob schema inline

* fix(auth): middleware and redirect to login

* fix(auth): failing test

* fix(logs): invalidate correct key

* fix(logs): invalidate correct key

* fix(logs): invalidate correct key

* fix: JSX element stealing focus from searchbar

* reimplement empty release table state text

* fix(context): use deep-copy

* fix(releases): empty state and filter input warnings

* fix(releases): empty states

* fix(auth): onboarding

* fix(cache): invalidate queries

---------

Co-authored-by: ze0s <43699394+zze0s@users.noreply.github.com>
This commit is contained in:
martylukyy 2024-02-12 13:07:00 +01:00 committed by GitHub
parent cc9656cd41
commit 1a23b69bcf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
64 changed files with 2543 additions and 2091 deletions

View file

@ -12,13 +12,11 @@ import (
"github.com/autobrr/autobrr/internal/domain"
"github.com/autobrr/autobrr/internal/logger"
"github.com/autobrr/autobrr/pkg/cmp"
"github.com/autobrr/autobrr/pkg/errors"
sq "github.com/Masterminds/squirrel"
"github.com/lib/pq"
"github.com/rs/zerolog"
"golang.org/x/exp/slices"
)
type FilterRepo struct {
@ -245,25 +243,8 @@ func (r *FilterRepo) FindByID(ctx context.Context, filterID int) (*domain.Filter
"f.max_leechers",
"f.created_at",
"f.updated_at",
"fe.id as external_id",
"fe.name",
"fe.idx",
"fe.type",
"fe.enabled",
"fe.exec_cmd",
"fe.exec_args",
"fe.exec_expect_status",
"fe.webhook_host",
"fe.webhook_method",
"fe.webhook_data",
"fe.webhook_headers",
"fe.webhook_expect_status",
"fe.webhook_retry_status",
"fe.webhook_retry_attempts",
"fe.webhook_retry_delay_seconds",
).
From("filter f").
LeftJoin("filter_external fe ON f.id = fe.filter_id").
Where(sq.Eq{"f.id": filterID})
query, args, err := queryBuilder.ToSql()
@ -271,176 +252,132 @@ func (r *FilterRepo) FindByID(ctx context.Context, filterID int) (*domain.Filter
return nil, errors.Wrap(err, "error building query")
}
rows, err := r.db.handler.QueryContext(ctx, query, args...)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
row := r.db.handler.QueryRowContext(ctx, query, args...)
if row.Err() != nil {
if errors.Is(row.Err(), sql.ErrNoRows) {
return nil, domain.ErrRecordNotFound
}
return nil, errors.Wrap(err, "error executing query")
return nil, errors.Wrap(row.Err(), "error row")
}
var f domain.Filter
externalMap := make(map[int]domain.FilterExternal)
// filter
var minSize, maxSize, maxDownloadsUnit, matchReleases, exceptReleases, matchReleaseGroups, exceptReleaseGroups, matchReleaseTags, exceptReleaseTags, matchDescription, exceptDescription, freeleechPercent, shows, seasons, episodes, years, artists, albums, matchCategories, exceptCategories, matchUploaders, exceptUploaders, tags, exceptTags, tagsMatchLogic, exceptTagsMatchLogic sql.NullString
var useRegex, scene, freeleech, hasLog, hasCue, perfectFlac sql.NullBool
var delay, maxDownloads, logScore sql.NullInt32
for rows.Next() {
// filter
var minSize, maxSize, maxDownloadsUnit, matchReleases, exceptReleases, matchReleaseGroups, exceptReleaseGroups, matchReleaseTags, exceptReleaseTags, matchDescription, exceptDescription, freeleechPercent, shows, seasons, episodes, years, artists, albums, matchCategories, exceptCategories, matchUploaders, exceptUploaders, tags, exceptTags, tagsMatchLogic, exceptTagsMatchLogic sql.NullString
var useRegex, scene, freeleech, hasLog, hasCue, perfectFlac sql.NullBool
var delay, maxDownloads, logScore sql.NullInt32
// filter external
var extName, extType, extExecCmd, extExecArgs, extWebhookHost, extWebhookMethod, extWebhookHeaders, extWebhookData, extWebhookRetryStatus sql.NullString
var extId, extIndex, extWebhookStatus, extWebhookRetryAttempts, extWebhookDelaySeconds, extExecStatus sql.NullInt32
var extEnabled sql.NullBool
if err := rows.Scan(
&f.ID,
&f.Enabled,
&f.Name,
&minSize,
&maxSize,
&delay,
&f.Priority,
&maxDownloads,
&maxDownloadsUnit,
&matchReleases,
&exceptReleases,
&useRegex,
&matchReleaseGroups,
&exceptReleaseGroups,
&matchReleaseTags,
&exceptReleaseTags,
&f.UseRegexReleaseTags,
&matchDescription,
&exceptDescription,
&f.UseRegexDescription,
&scene,
&freeleech,
&freeleechPercent,
&f.SmartEpisode,
&shows,
&seasons,
&episodes,
pq.Array(&f.Resolutions),
pq.Array(&f.Codecs),
pq.Array(&f.Sources),
pq.Array(&f.Containers),
pq.Array(&f.MatchHDR),
pq.Array(&f.ExceptHDR),
pq.Array(&f.MatchOther),
pq.Array(&f.ExceptOther),
&years,
&artists,
&albums,
pq.Array(&f.MatchReleaseTypes),
pq.Array(&f.Formats),
pq.Array(&f.Quality),
pq.Array(&f.Media),
&logScore,
&hasLog,
&hasCue,
&perfectFlac,
&matchCategories,
&exceptCategories,
&matchUploaders,
&exceptUploaders,
pq.Array(&f.MatchLanguage),
pq.Array(&f.ExceptLanguage),
&tags,
&exceptTags,
&tagsMatchLogic,
&exceptTagsMatchLogic,
pq.Array(&f.Origins),
pq.Array(&f.ExceptOrigins),
&f.MinSeeders,
&f.MaxSeeders,
&f.MinLeechers,
&f.MaxLeechers,
&f.CreatedAt,
&f.UpdatedAt,
&extId,
&extName,
&extIndex,
&extType,
&extEnabled,
&extExecCmd,
&extExecArgs,
&extExecStatus,
&extWebhookHost,
&extWebhookMethod,
&extWebhookData,
&extWebhookHeaders,
&extWebhookStatus,
&extWebhookRetryStatus,
&extWebhookRetryAttempts,
&extWebhookDelaySeconds,
); err != nil {
return nil, errors.Wrap(err, "error scanning row")
err = row.Scan(
&f.ID,
&f.Enabled,
&f.Name,
&minSize,
&maxSize,
&delay,
&f.Priority,
&maxDownloads,
&maxDownloadsUnit,
&matchReleases,
&exceptReleases,
&useRegex,
&matchReleaseGroups,
&exceptReleaseGroups,
&matchReleaseTags,
&exceptReleaseTags,
&f.UseRegexReleaseTags,
&matchDescription,
&exceptDescription,
&f.UseRegexDescription,
&scene,
&freeleech,
&freeleechPercent,
&f.SmartEpisode,
&shows,
&seasons,
&episodes,
pq.Array(&f.Resolutions),
pq.Array(&f.Codecs),
pq.Array(&f.Sources),
pq.Array(&f.Containers),
pq.Array(&f.MatchHDR),
pq.Array(&f.ExceptHDR),
pq.Array(&f.MatchOther),
pq.Array(&f.ExceptOther),
&years,
&artists,
&albums,
pq.Array(&f.MatchReleaseTypes),
pq.Array(&f.Formats),
pq.Array(&f.Quality),
pq.Array(&f.Media),
&logScore,
&hasLog,
&hasCue,
&perfectFlac,
&matchCategories,
&exceptCategories,
&matchUploaders,
&exceptUploaders,
pq.Array(&f.MatchLanguage),
pq.Array(&f.ExceptLanguage),
&tags,
&exceptTags,
&tagsMatchLogic,
&exceptTagsMatchLogic,
pq.Array(&f.Origins),
pq.Array(&f.ExceptOrigins),
&f.MinSeeders,
&f.MaxSeeders,
&f.MinLeechers,
&f.MaxLeechers,
&f.CreatedAt,
&f.UpdatedAt,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrRecordNotFound
}
f.MinSize = minSize.String
f.MaxSize = maxSize.String
f.Delay = int(delay.Int32)
f.MaxDownloads = int(maxDownloads.Int32)
f.MaxDownloadsUnit = domain.FilterMaxDownloadsUnit(maxDownloadsUnit.String)
f.MatchReleases = matchReleases.String
f.ExceptReleases = exceptReleases.String
f.MatchReleaseGroups = matchReleaseGroups.String
f.ExceptReleaseGroups = exceptReleaseGroups.String
f.MatchReleaseTags = matchReleaseTags.String
f.ExceptReleaseTags = exceptReleaseTags.String
f.MatchDescription = matchDescription.String
f.ExceptDescription = exceptDescription.String
f.FreeleechPercent = freeleechPercent.String
f.Shows = shows.String
f.Seasons = seasons.String
f.Episodes = episodes.String
f.Years = years.String
f.Artists = artists.String
f.Albums = albums.String
f.LogScore = int(logScore.Int32)
f.Log = hasLog.Bool
f.Cue = hasCue.Bool
f.PerfectFlac = perfectFlac.Bool
f.MatchCategories = matchCategories.String
f.ExceptCategories = exceptCategories.String
f.MatchUploaders = matchUploaders.String
f.ExceptUploaders = exceptUploaders.String
f.Tags = tags.String
f.ExceptTags = exceptTags.String
f.TagsMatchLogic = tagsMatchLogic.String
f.ExceptTagsMatchLogic = exceptTagsMatchLogic.String
f.UseRegex = useRegex.Bool
f.Scene = scene.Bool
f.Freeleech = freeleech.Bool
if extId.Valid {
external := domain.FilterExternal{
ID: int(extId.Int32),
Name: extName.String,
Index: int(extIndex.Int32),
Type: domain.FilterExternalType(extType.String),
Enabled: extEnabled.Bool,
ExecCmd: extExecCmd.String,
ExecArgs: extExecArgs.String,
ExecExpectStatus: int(extExecStatus.Int32),
WebhookHost: extWebhookHost.String,
WebhookMethod: extWebhookMethod.String,
WebhookData: extWebhookData.String,
WebhookHeaders: extWebhookHeaders.String,
WebhookExpectStatus: int(extWebhookStatus.Int32),
WebhookRetryStatus: extWebhookRetryStatus.String,
WebhookRetryAttempts: int(extWebhookRetryAttempts.Int32),
WebhookRetryDelaySeconds: int(extWebhookDelaySeconds.Int32),
}
externalMap[external.ID] = external
}
return nil, errors.Wrap(err, "error scanning row")
}
for _, external := range externalMap {
f.External = append(f.External, external)
}
f.MinSize = minSize.String
f.MaxSize = maxSize.String
f.Delay = int(delay.Int32)
f.MaxDownloads = int(maxDownloads.Int32)
f.MaxDownloadsUnit = domain.FilterMaxDownloadsUnit(maxDownloadsUnit.String)
f.MatchReleases = matchReleases.String
f.ExceptReleases = exceptReleases.String
f.MatchReleaseGroups = matchReleaseGroups.String
f.ExceptReleaseGroups = exceptReleaseGroups.String
f.MatchReleaseTags = matchReleaseTags.String
f.ExceptReleaseTags = exceptReleaseTags.String
f.MatchDescription = matchDescription.String
f.ExceptDescription = exceptDescription.String
f.FreeleechPercent = freeleechPercent.String
f.Shows = shows.String
f.Seasons = seasons.String
f.Episodes = episodes.String
f.Years = years.String
f.Artists = artists.String
f.Albums = albums.String
f.LogScore = int(logScore.Int32)
f.Log = hasLog.Bool
f.Cue = hasCue.Bool
f.PerfectFlac = perfectFlac.Bool
f.MatchCategories = matchCategories.String
f.ExceptCategories = exceptCategories.String
f.MatchUploaders = matchUploaders.String
f.ExceptUploaders = exceptUploaders.String
f.Tags = tags.String
f.ExceptTags = exceptTags.String
f.TagsMatchLogic = tagsMatchLogic.String
f.ExceptTagsMatchLogic = exceptTagsMatchLogic.String
f.UseRegex = useRegex.Bool
f.Scene = scene.Bool
f.Freeleech = freeleech.Bool
return &f, nil
}
@ -517,28 +454,10 @@ func (r *FilterRepo) findByIndexerIdentifier(ctx context.Context, indexer string
"f.max_leechers",
"f.created_at",
"f.updated_at",
"fe.id as external_id",
"fe.name",
"fe.idx",
"fe.type",
"fe.enabled",
"fe.exec_cmd",
"fe.exec_args",
"fe.exec_expect_status",
"fe.webhook_host",
"fe.webhook_method",
"fe.webhook_data",
"fe.webhook_headers",
"fe.webhook_expect_status",
"fe.webhook_retry_status",
"fe.webhook_retry_attempts",
"fe.webhook_retry_delay_seconds",
"fe.filter_id",
).
From("filter f").
Join("filter_indexer fi ON f.id = fi.filter_id").
Join("indexer i ON i.id = fi.indexer_id").
LeftJoin("filter_external fe ON f.id = fe.filter_id").
Where(sq.Eq{"i.identifier": indexer}).
Where(sq.Eq{"i.enabled": true}).
Where(sq.Eq{"f.enabled": true}).
@ -556,7 +475,7 @@ func (r *FilterRepo) findByIndexerIdentifier(ctx context.Context, indexer string
defer rows.Close()
filtersMap := make(map[int]*domain.Filter)
var filters []*domain.Filter
for rows.Next() {
var f domain.Filter
@ -565,12 +484,7 @@ func (r *FilterRepo) findByIndexerIdentifier(ctx context.Context, indexer string
var useRegex, scene, freeleech, hasLog, hasCue, perfectFlac sql.NullBool
var delay, maxDownloads, logScore sql.NullInt32
// filter external
var extName, extType, extExecCmd, extExecArgs, extWebhookHost, extWebhookMethod, extWebhookHeaders, extWebhookData, extWebhookRetryStatus sql.NullString
var extId, extIndex, extWebhookStatus, extWebhookRetryAttempts, extWebhookDelaySeconds, extExecStatus, extFilterId sql.NullInt32
var extEnabled sql.NullBool
if err := rows.Scan(
err := rows.Scan(
&f.ID,
&f.Enabled,
&f.Name,
@ -635,108 +549,52 @@ func (r *FilterRepo) findByIndexerIdentifier(ctx context.Context, indexer string
&f.MaxLeechers,
&f.CreatedAt,
&f.UpdatedAt,
&extId,
&extName,
&extIndex,
&extType,
&extEnabled,
&extExecCmd,
&extExecArgs,
&extExecStatus,
&extWebhookHost,
&extWebhookMethod,
&extWebhookData,
&extWebhookHeaders,
&extWebhookStatus,
&extWebhookRetryStatus,
&extWebhookRetryAttempts,
&extWebhookDelaySeconds,
&extFilterId,
); err != nil {
)
if err != nil {
return nil, errors.Wrap(err, "error scanning row")
}
filter, ok := filtersMap[f.ID]
if !ok {
f.MinSize = minSize.String
f.MaxSize = maxSize.String
f.Delay = int(delay.Int32)
f.MaxDownloads = int(maxDownloads.Int32)
f.MaxDownloadsUnit = domain.FilterMaxDownloadsUnit(maxDownloadsUnit.String)
f.MatchReleases = matchReleases.String
f.ExceptReleases = exceptReleases.String
f.MatchReleaseGroups = matchReleaseGroups.String
f.ExceptReleaseGroups = exceptReleaseGroups.String
f.MatchReleaseTags = matchReleaseTags.String
f.ExceptReleaseTags = exceptReleaseTags.String
f.MatchDescription = matchDescription.String
f.ExceptDescription = exceptDescription.String
f.FreeleechPercent = freeleechPercent.String
f.Shows = shows.String
f.Seasons = seasons.String
f.Episodes = episodes.String
f.Years = years.String
f.Artists = artists.String
f.Albums = albums.String
f.LogScore = int(logScore.Int32)
f.Log = hasLog.Bool
f.Cue = hasCue.Bool
f.PerfectFlac = perfectFlac.Bool
f.MatchCategories = matchCategories.String
f.ExceptCategories = exceptCategories.String
f.MatchUploaders = matchUploaders.String
f.ExceptUploaders = exceptUploaders.String
f.Tags = tags.String
f.ExceptTags = exceptTags.String
f.TagsMatchLogic = tagsMatchLogic.String
f.ExceptTagsMatchLogic = exceptTagsMatchLogic.String
f.UseRegex = useRegex.Bool
f.Scene = scene.Bool
f.Freeleech = freeleech.Bool
f.MinSize = minSize.String
f.MaxSize = maxSize.String
f.Delay = int(delay.Int32)
f.MaxDownloads = int(maxDownloads.Int32)
f.MaxDownloadsUnit = domain.FilterMaxDownloadsUnit(maxDownloadsUnit.String)
f.MatchReleases = matchReleases.String
f.ExceptReleases = exceptReleases.String
f.MatchReleaseGroups = matchReleaseGroups.String
f.ExceptReleaseGroups = exceptReleaseGroups.String
f.MatchReleaseTags = matchReleaseTags.String
f.ExceptReleaseTags = exceptReleaseTags.String
f.MatchDescription = matchDescription.String
f.ExceptDescription = exceptDescription.String
f.FreeleechPercent = freeleechPercent.String
f.Shows = shows.String
f.Seasons = seasons.String
f.Episodes = episodes.String
f.Years = years.String
f.Artists = artists.String
f.Albums = albums.String
f.LogScore = int(logScore.Int32)
f.Log = hasLog.Bool
f.Cue = hasCue.Bool
f.PerfectFlac = perfectFlac.Bool
f.MatchCategories = matchCategories.String
f.ExceptCategories = exceptCategories.String
f.MatchUploaders = matchUploaders.String
f.ExceptUploaders = exceptUploaders.String
f.Tags = tags.String
f.ExceptTags = exceptTags.String
f.TagsMatchLogic = tagsMatchLogic.String
f.ExceptTagsMatchLogic = exceptTagsMatchLogic.String
f.UseRegex = useRegex.Bool
f.Scene = scene.Bool
f.Freeleech = freeleech.Bool
f.Rejections = []string{}
f.Rejections = []string{}
filter = &f
filtersMap[f.ID] = filter
}
if extId.Valid {
external := domain.FilterExternal{
ID: int(extId.Int32),
Name: extName.String,
Index: int(extIndex.Int32),
Type: domain.FilterExternalType(extType.String),
Enabled: extEnabled.Bool,
ExecCmd: extExecCmd.String,
ExecArgs: extExecArgs.String,
ExecExpectStatus: int(extExecStatus.Int32),
WebhookHost: extWebhookHost.String,
WebhookMethod: extWebhookMethod.String,
WebhookData: extWebhookData.String,
WebhookHeaders: extWebhookHeaders.String,
WebhookExpectStatus: int(extWebhookStatus.Int32),
WebhookRetryStatus: extWebhookRetryStatus.String,
WebhookRetryAttempts: int(extWebhookRetryAttempts.Int32),
WebhookRetryDelaySeconds: int(extWebhookDelaySeconds.Int32),
FilterId: int(extFilterId.Int32),
}
filter.External = append(filter.External, external)
}
filters = append(filters, &f)
}
var filters []*domain.Filter
for _, filter := range filtersMap {
filter := filter
filters = append(filters, filter)
}
// the filterMap messes up the order, so we need to sort the filters slice
slices.SortStableFunc(filters, func(a, b *domain.Filter) int {
// TODO remove with Go 1.21 and use std lib cmp
return cmp.Compare(b.Priority, a.Priority)
})
return filters, nil
}
@ -1232,39 +1090,6 @@ func (r *FilterRepo) UpdatePartial(ctx context.Context, filter domain.FilterUpda
if filter.ExceptOrigins != nil {
q = q.Set("except_origins", pq.Array(filter.ExceptOrigins))
}
if filter.ExternalScriptEnabled != nil {
q = q.Set("external_script_enabled", filter.ExternalScriptEnabled)
}
if filter.ExternalScriptCmd != nil {
q = q.Set("external_script_cmd", filter.ExternalScriptCmd)
}
if filter.ExternalScriptArgs != nil {
q = q.Set("external_script_args", filter.ExternalScriptArgs)
}
if filter.ExternalScriptExpectStatus != nil {
q = q.Set("external_script_expect_status", filter.ExternalScriptExpectStatus)
}
if filter.ExternalWebhookEnabled != nil {
q = q.Set("external_webhook_enabled", filter.ExternalWebhookEnabled)
}
if filter.ExternalWebhookHost != nil {
q = q.Set("external_webhook_host", filter.ExternalWebhookHost)
}
if filter.ExternalWebhookData != nil {
q = q.Set("external_webhook_data", filter.ExternalWebhookData)
}
if filter.ExternalWebhookExpectStatus != nil {
q = q.Set("external_webhook_expect_status", filter.ExternalWebhookExpectStatus)
}
if filter.ExternalWebhookRetryStatus != nil {
q = q.Set("external_webhook_retry_status", filter.ExternalWebhookRetryStatus)
}
if filter.ExternalWebhookRetryAttempts != nil {
q = q.Set("external_webhook_retry_attempts", filter.ExternalWebhookRetryAttempts)
}
if filter.ExternalWebhookRetryDelaySeconds != nil {
q = q.Set("external_webhook_retry_delay_seconds", filter.ExternalWebhookRetryDelaySeconds)
}
if filter.MinSeeders != nil {
q = q.Set("min_seeders", filter.MinSeeders)
}

View file

@ -205,11 +205,10 @@ func TestFilterRepo_Delete(t *testing.T) {
err = repo.Delete(context.Background(), createdFilters[0].ID)
assert.NoError(t, err)
// Verify that the filter is deleted
// Verify that the filter is deleted and return error ErrRecordNotFound
filter, err := repo.FindByID(context.Background(), createdFilters[0].ID)
assert.NoError(t, err)
assert.NotNil(t, filter)
assert.Equal(t, 0, filter.ID)
assert.ErrorIs(t, err, domain.ErrRecordNotFound)
assert.Nil(t, filter)
})
t.Run(fmt.Sprintf("Delete_Fails_No_Record [%s]", dbType), func(t *testing.T) {
@ -451,12 +450,11 @@ func TestFilterRepo_FindByID(t *testing.T) {
_ = repo.Delete(context.Background(), createdFilters[0].ID)
})
// TODO: This should succeed, but it fails because we are not handling the error correctly. Fix this.
t.Run(fmt.Sprintf("FindByID_Fails_Invalid_ID [%s]", dbType), func(t *testing.T) {
// Test using an invalid ID
filter, err := repo.FindByID(context.Background(), -1)
assert.NoError(t, err) // should return an error
assert.NotNil(t, filter) // should be nil
assert.ErrorIs(t, err, domain.ErrRecordNotFound) // should return an error
assert.Nil(t, filter) // should be nil
})
}

View file

@ -174,86 +174,75 @@ const (
)
type FilterUpdate struct {
ID int `json:"id"`
Name *string `json:"name,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
MinSize *string `json:"min_size,omitempty"`
MaxSize *string `json:"max_size,omitempty"`
Delay *int `json:"delay,omitempty"`
Priority *int32 `json:"priority,omitempty"`
MaxDownloads *int `json:"max_downloads,omitempty"`
MaxDownloadsUnit *FilterMaxDownloadsUnit `json:"max_downloads_unit,omitempty"`
MatchReleases *string `json:"match_releases,omitempty"`
ExceptReleases *string `json:"except_releases,omitempty"`
UseRegex *bool `json:"use_regex,omitempty"`
MatchReleaseGroups *string `json:"match_release_groups,omitempty"`
ExceptReleaseGroups *string `json:"except_release_groups,omitempty"`
MatchReleaseTags *string `json:"match_release_tags,omitempty"`
ExceptReleaseTags *string `json:"except_release_tags,omitempty"`
UseRegexReleaseTags *bool `json:"use_regex_release_tags,omitempty"`
MatchDescription *string `json:"match_description,omitempty"`
ExceptDescription *string `json:"except_description,omitempty"`
UseRegexDescription *bool `json:"use_regex_description,omitempty"`
Scene *bool `json:"scene,omitempty"`
Origins *[]string `json:"origins,omitempty"`
ExceptOrigins *[]string `json:"except_origins,omitempty"`
Bonus *[]string `json:"bonus,omitempty"`
Freeleech *bool `json:"freeleech,omitempty"`
FreeleechPercent *string `json:"freeleech_percent,omitempty"`
SmartEpisode *bool `json:"smart_episode,omitempty"`
Shows *string `json:"shows,omitempty"`
Seasons *string `json:"seasons,omitempty"`
Episodes *string `json:"episodes,omitempty"`
Resolutions *[]string `json:"resolutions,omitempty"` // SD, 480i, 480p, 576p, 720p, 810p, 1080i, 1080p.
Codecs *[]string `json:"codecs,omitempty"` // XviD, DivX, x264, h.264 (or h264), mpeg2 (or mpeg-2), VC-1 (or VC1), WMV, Remux, h.264 Remux (or h264 Remux), VC-1 Remux (or VC1 Remux).
Sources *[]string `json:"sources,omitempty"` // DSR, PDTV, HDTV, HR.PDTV, HR.HDTV, DVDRip, DVDScr, BDr, BD5, BD9, BDRip, BRRip, DVDR, MDVDR, HDDVD, HDDVDRip, BluRay, WEB-DL, TVRip, CAM, R5, TELESYNC, TS, TELECINE, TC. TELESYNC and TS are synonyms (you don't need both). Same for TELECINE and TC
Containers *[]string `json:"containers,omitempty"`
MatchHDR *[]string `json:"match_hdr,omitempty"`
ExceptHDR *[]string `json:"except_hdr,omitempty"`
MatchOther *[]string `json:"match_other,omitempty"`
ExceptOther *[]string `json:"except_other,omitempty"`
Years *string `json:"years,omitempty"`
Artists *string `json:"artists,omitempty"`
Albums *string `json:"albums,omitempty"`
MatchReleaseTypes *[]string `json:"match_release_types,omitempty"` // Album,Single,EP
ExceptReleaseTypes *string `json:"except_release_types,omitempty"`
Formats *[]string `json:"formats,omitempty"` // MP3, FLAC, Ogg, AAC, AC3, DTS
Quality *[]string `json:"quality,omitempty"` // 192, 320, APS (VBR), V2 (VBR), V1 (VBR), APX (VBR), V0 (VBR), q8.x (VBR), Lossless, 24bit Lossless, Other
Media *[]string `json:"media,omitempty"` // CD, DVD, Vinyl, Soundboard, SACD, DAT, Cassette, WEB, Other
PerfectFlac *bool `json:"perfect_flac,omitempty"`
Cue *bool `json:"cue,omitempty"`
Log *bool `json:"log,omitempty"`
LogScore *int `json:"log_score,omitempty"`
MatchCategories *string `json:"match_categories,omitempty"`
ExceptCategories *string `json:"except_categories,omitempty"`
MatchUploaders *string `json:"match_uploaders,omitempty"`
ExceptUploaders *string `json:"except_uploaders,omitempty"`
MatchLanguage *[]string `json:"match_language,omitempty"`
ExceptLanguage *[]string `json:"except_language,omitempty"`
Tags *string `json:"tags,omitempty"`
ExceptTags *string `json:"except_tags,omitempty"`
TagsAny *string `json:"tags_any,omitempty"`
ExceptTagsAny *string `json:"except_tags_any,omitempty"`
TagsMatchLogic *string `json:"tags_match_logic,omitempty"`
ExceptTagsMatchLogic *string `json:"except_tags_match_logic,omitempty"`
MinSeeders *int `json:"min_seeders,omitempty"`
MaxSeeders *int `json:"max_seeders,omitempty"`
MinLeechers *int `json:"min_leechers,omitempty"`
MaxLeechers *int `json:"max_leechers,omitempty"`
ExternalScriptEnabled *bool `json:"external_script_enabled,omitempty"`
ExternalScriptCmd *string `json:"external_script_cmd,omitempty"`
ExternalScriptArgs *string `json:"external_script_args,omitempty"`
ExternalScriptExpectStatus *int `json:"external_script_expect_status,omitempty"`
ExternalWebhookEnabled *bool `json:"external_webhook_enabled,omitempty"`
ExternalWebhookHost *string `json:"external_webhook_host,omitempty"`
ExternalWebhookData *string `json:"external_webhook_data,omitempty"`
ExternalWebhookExpectStatus *int `json:"external_webhook_expect_status,omitempty"`
ExternalWebhookRetryStatus *string `json:"external_webhook_retry_status,omitempty"`
ExternalWebhookRetryAttempts *int `json:"external_webhook_retry_attempts,omitempty"`
ExternalWebhookRetryDelaySeconds *int `json:"external_webhook_retry_delay_seconds,omitempty"`
Actions []*Action `json:"actions,omitempty"`
External []FilterExternal `json:"external,omitempty"`
Indexers []Indexer `json:"indexers,omitempty"`
ID int `json:"id"`
Name *string `json:"name,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
MinSize *string `json:"min_size,omitempty"`
MaxSize *string `json:"max_size,omitempty"`
Delay *int `json:"delay,omitempty"`
Priority *int32 `json:"priority,omitempty"`
MaxDownloads *int `json:"max_downloads,omitempty"`
MaxDownloadsUnit *FilterMaxDownloadsUnit `json:"max_downloads_unit,omitempty"`
MatchReleases *string `json:"match_releases,omitempty"`
ExceptReleases *string `json:"except_releases,omitempty"`
UseRegex *bool `json:"use_regex,omitempty"`
MatchReleaseGroups *string `json:"match_release_groups,omitempty"`
ExceptReleaseGroups *string `json:"except_release_groups,omitempty"`
MatchReleaseTags *string `json:"match_release_tags,omitempty"`
ExceptReleaseTags *string `json:"except_release_tags,omitempty"`
UseRegexReleaseTags *bool `json:"use_regex_release_tags,omitempty"`
MatchDescription *string `json:"match_description,omitempty"`
ExceptDescription *string `json:"except_description,omitempty"`
UseRegexDescription *bool `json:"use_regex_description,omitempty"`
Scene *bool `json:"scene,omitempty"`
Origins *[]string `json:"origins,omitempty"`
ExceptOrigins *[]string `json:"except_origins,omitempty"`
Bonus *[]string `json:"bonus,omitempty"`
Freeleech *bool `json:"freeleech,omitempty"`
FreeleechPercent *string `json:"freeleech_percent,omitempty"`
SmartEpisode *bool `json:"smart_episode,omitempty"`
Shows *string `json:"shows,omitempty"`
Seasons *string `json:"seasons,omitempty"`
Episodes *string `json:"episodes,omitempty"`
Resolutions *[]string `json:"resolutions,omitempty"` // SD, 480i, 480p, 576p, 720p, 810p, 1080i, 1080p.
Codecs *[]string `json:"codecs,omitempty"` // XviD, DivX, x264, h.264 (or h264), mpeg2 (or mpeg-2), VC-1 (or VC1), WMV, Remux, h.264 Remux (or h264 Remux), VC-1 Remux (or VC1 Remux).
Sources *[]string `json:"sources,omitempty"` // DSR, PDTV, HDTV, HR.PDTV, HR.HDTV, DVDRip, DVDScr, BDr, BD5, BD9, BDRip, BRRip, DVDR, MDVDR, HDDVD, HDDVDRip, BluRay, WEB-DL, TVRip, CAM, R5, TELESYNC, TS, TELECINE, TC. TELESYNC and TS are synonyms (you don't need both). Same for TELECINE and TC
Containers *[]string `json:"containers,omitempty"`
MatchHDR *[]string `json:"match_hdr,omitempty"`
ExceptHDR *[]string `json:"except_hdr,omitempty"`
MatchOther *[]string `json:"match_other,omitempty"`
ExceptOther *[]string `json:"except_other,omitempty"`
Years *string `json:"years,omitempty"`
Artists *string `json:"artists,omitempty"`
Albums *string `json:"albums,omitempty"`
MatchReleaseTypes *[]string `json:"match_release_types,omitempty"` // Album,Single,EP
ExceptReleaseTypes *string `json:"except_release_types,omitempty"`
Formats *[]string `json:"formats,omitempty"` // MP3, FLAC, Ogg, AAC, AC3, DTS
Quality *[]string `json:"quality,omitempty"` // 192, 320, APS (VBR), V2 (VBR), V1 (VBR), APX (VBR), V0 (VBR), q8.x (VBR), Lossless, 24bit Lossless, Other
Media *[]string `json:"media,omitempty"` // CD, DVD, Vinyl, Soundboard, SACD, DAT, Cassette, WEB, Other
PerfectFlac *bool `json:"perfect_flac,omitempty"`
Cue *bool `json:"cue,omitempty"`
Log *bool `json:"log,omitempty"`
LogScore *int `json:"log_score,omitempty"`
MatchCategories *string `json:"match_categories,omitempty"`
ExceptCategories *string `json:"except_categories,omitempty"`
MatchUploaders *string `json:"match_uploaders,omitempty"`
ExceptUploaders *string `json:"except_uploaders,omitempty"`
MatchLanguage *[]string `json:"match_language,omitempty"`
ExceptLanguage *[]string `json:"except_language,omitempty"`
Tags *string `json:"tags,omitempty"`
ExceptTags *string `json:"except_tags,omitempty"`
TagsAny *string `json:"tags_any,omitempty"`
ExceptTagsAny *string `json:"except_tags_any,omitempty"`
TagsMatchLogic *string `json:"tags_match_logic,omitempty"`
ExceptTagsMatchLogic *string `json:"except_tags_match_logic,omitempty"`
MinSeeders *int `json:"min_seeders,omitempty"`
MaxSeeders *int `json:"max_seeders,omitempty"`
MinLeechers *int `json:"min_leechers,omitempty"`
MaxLeechers *int `json:"max_leechers,omitempty"`
Actions []*Action `json:"actions,omitempty"`
External []FilterExternal `json:"external,omitempty"`
Indexers []Indexer `json:"indexers,omitempty"`
}
func (f *Filter) Validate() error {

View file

@ -124,6 +124,12 @@ func (s *service) FindByID(ctx context.Context, filterID int) (*domain.Filter, e
return nil, err
}
externalFilters, err := s.repo.FindExternalFiltersByID(ctx, filter.ID)
if err != nil {
s.log.Error().Err(err).Msgf("could not find external filters for filter id: %v", filter.ID)
}
filter.External = externalFilters
actions, err := s.actionRepo.FindByFilterID(ctx, filter.ID, nil)
if err != nil {
s.log.Error().Err(err).Msgf("could not find filter actions for filter id: %v", filter.ID)
@ -142,9 +148,25 @@ func (s *service) FindByID(ctx context.Context, filterID int) (*domain.Filter, e
func (s *service) FindByIndexerIdentifier(ctx context.Context, indexer string) ([]*domain.Filter, error) {
// get filters for indexer
filters, err := s.repo.FindByIndexerIdentifier(ctx, indexer)
if err != nil {
return nil, err
}
// we do not load actions here since we do not need it at this stage
// only load those after filter has matched
return s.repo.FindByIndexerIdentifier(ctx, indexer)
for _, filter := range filters {
filter := filter
externalFilters, err := s.repo.FindExternalFiltersByID(ctx, filter.ID)
if err != nil {
s.log.Error().Err(err).Msgf("could not find external filters for filter id: %v", filter.ID)
}
filter.External = externalFilters
}
return filters, nil
}
func (s *service) GetDownloadsByFilterId(ctx context.Context, filterID int) (*domain.FilterDownloads, error) {

View file

@ -21,6 +21,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/gorilla/sessions"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
)
type authServiceMock struct {
@ -144,9 +145,7 @@ func TestAuthHandlerLogin(t *testing.T) {
defer resp.Body.Close()
// check for response, here we'll just check for 204 NoContent
if status := resp.StatusCode; status != http.StatusNoContent {
t.Errorf("login: handler returned wrong status code: got %v want %v", status, http.StatusNoContent)
}
assert.Equalf(t, http.StatusNoContent, resp.StatusCode, "login handler: unexpected http status")
if v := resp.Header.Get("Set-Cookie"); v == "" {
t.Errorf("handler returned no cookie")
@ -207,12 +206,10 @@ func TestAuthHandlerValidateOK(t *testing.T) {
defer resp.Body.Close()
// check for response, here we'll just check for 204 NoContent
if status := resp.StatusCode; status != http.StatusNoContent {
t.Errorf("login: handler returned wrong status code: got %v want %v", status, http.StatusNoContent)
}
assert.Equalf(t, http.StatusNoContent, resp.StatusCode, "login handler: bad response")
if v := resp.Header.Get("Set-Cookie"); v == "" {
t.Errorf("handler returned no cookie")
assert.Equalf(t, "", v, "login handler: expected Set-Cookie header")
}
// validate token
@ -223,9 +220,7 @@ func TestAuthHandlerValidateOK(t *testing.T) {
defer resp.Body.Close()
if status := resp.StatusCode; status != http.StatusNoContent {
t.Errorf("validate: handler returned wrong status code: got %v want %v", status, http.StatusNoContent)
}
assert.Equalf(t, http.StatusNoContent, resp.StatusCode, "validate handler: unexpected http status")
}
func TestAuthHandlerValidateBad(t *testing.T) {
@ -272,9 +267,7 @@ func TestAuthHandlerValidateBad(t *testing.T) {
defer resp.Body.Close()
if status := resp.StatusCode; status != http.StatusNoContent {
t.Errorf("validate: handler returned wrong status code: got %v want %v", status, http.StatusNoContent)
}
assert.Equalf(t, http.StatusForbidden, resp.StatusCode, "validate handler: unexpected http status")
}
func TestAuthHandlerLoginBad(t *testing.T) {
@ -321,9 +314,7 @@ func TestAuthHandlerLoginBad(t *testing.T) {
defer resp.Body.Close()
// check for response, here we'll just check for 403 Forbidden
if status := resp.StatusCode; status != http.StatusForbidden {
t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusForbidden)
}
assert.Equalf(t, http.StatusForbidden, resp.StatusCode, "login handler: unexpected http status")
}
func TestAuthHandlerLogout(t *testing.T) {
@ -384,6 +375,8 @@ func TestAuthHandlerLogout(t *testing.T) {
t.Errorf("login: handler returned wrong status code: got %v want %v", status, http.StatusNoContent)
}
assert.Equalf(t, http.StatusNoContent, resp.StatusCode, "login handler: unexpected http status")
if v := resp.Header.Get("Set-Cookie"); v == "" {
t.Errorf("handler returned no cookie")
}
@ -396,9 +389,7 @@ func TestAuthHandlerLogout(t *testing.T) {
defer resp.Body.Close()
if status := resp.StatusCode; status != http.StatusNoContent {
t.Errorf("validate: handler returned wrong status code: got %v want %v", status, http.StatusNoContent)
}
assert.Equalf(t, http.StatusNoContent, resp.StatusCode, "validate handler: unexpected http status")
// logout
resp, err = client.Post(testServer.URL+"/auth/logout", "application/json", nil)
@ -408,9 +399,7 @@ func TestAuthHandlerLogout(t *testing.T) {
defer resp.Body.Close()
if status := resp.StatusCode; status != http.StatusNoContent {
t.Errorf("logout: handler returned wrong status code: got %v want %v", status, http.StatusNoContent)
}
assert.Equalf(t, http.StatusNoContent, resp.StatusCode, "logout handler: unexpected http status")
//if v := resp.Header.Get("Set-Cookie"); v != "" {
// t.Errorf("logout handler returned cookie")

View file

@ -67,6 +67,16 @@ func (e encoder) StatusNotFound(w http.ResponseWriter) {
w.WriteHeader(http.StatusNotFound)
}
func (e encoder) NotFoundErr(w http.ResponseWriter, err error) {
res := errorResponse{
Message: err.Error(),
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(res)
}
func (e encoder) StatusInternalError(w http.ResponseWriter) {
w.WriteHeader(http.StatusInternalServerError)
}

View file

@ -119,7 +119,7 @@ func (h filterHandler) getByID(w http.ResponseWriter, r *http.Request) {
filter, err := h.service.FindByID(ctx, id)
if err != nil {
if errors.Is(err, domain.ErrRecordNotFound) {
h.encoder.StatusNotFound(w)
h.encoder.NotFoundErr(w, errors.New("filter with id %d not found", id))
return
}

View file

@ -38,7 +38,6 @@ func (s Server) IsAuthenticated(next http.Handler) http.Handler {
// MaxAge<0 means delete cookie immediately
session.Options.MaxAge = -1
session.Options.Path = s.config.Config.BaseURL
if err := session.Save(r, w); err != nil {
@ -50,13 +49,10 @@ func (s Server) IsAuthenticated(next http.Handler) http.Handler {
return
}
if session.IsNew {
http.Error(w, http.StatusText(http.StatusNoContent), http.StatusNoContent)
return
}
// Check if user is authenticated
if auth, ok := session.Values["authenticated"].(bool); !ok || !auth {
s.log.Warn().Msg("session not authenticated")
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}

View file

@ -39,11 +39,11 @@
"@tailwindcss/forms": "^0.5.7",
"@tanstack/react-query": "^5.17.19",
"@tanstack/react-query-devtools": "^5.8.4",
"@tanstack/react-router": "^1.16.0",
"@types/node": "^20.11.6",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/react-portal": "^4.0.7",
"@types/react-router-dom": "^5.3.3",
"@types/react-table": "^7.7.19",
"@typescript-eslint/eslint-plugin": "^6.19.1",
"@typescript-eslint/parser": "^6.19.1",
@ -64,7 +64,6 @@
"react-popper-tooltip": "^4.4.2",
"react-portal": "^4.2.2",
"react-ridge-state": "4.2.9",
"react-router-dom": "6.21.3",
"react-select": "^5.8.0",
"react-table": "^7.8.0",
"react-textarea-autosize": "^8.5.3",
@ -78,6 +77,7 @@
"devDependencies": {
"@microsoft/eslint-formatter-sarif": "^3.0.0",
"@rollup/wasm-node": "^4.9.6",
"@tanstack/router-devtools": "^1.1.4",
"@types/node": "^20.11.6",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",

209
web/pnpm-lock.yaml generated
View file

@ -30,6 +30,9 @@ dependencies:
'@tanstack/react-query-devtools':
specifier: ^5.8.4
version: 5.8.4(@tanstack/react-query@5.17.19)(react-dom@18.2.0)(react@18.2.0)
'@tanstack/react-router':
specifier: ^1.16.0
version: 1.16.0(react-dom@18.2.0)(react@18.2.0)
'@types/node':
specifier: ^20.11.6
version: 20.11.6
@ -42,9 +45,6 @@ dependencies:
'@types/react-portal':
specifier: ^4.0.7
version: 4.0.7
'@types/react-router-dom':
specifier: ^5.3.3
version: 5.3.3
'@types/react-table':
specifier: ^7.7.19
version: 7.7.19
@ -105,9 +105,6 @@ dependencies:
react-ridge-state:
specifier: 4.2.9
version: 4.2.9(react@18.2.0)
react-router-dom:
specifier: 6.21.3
version: 6.21.3(react-dom@18.2.0)(react@18.2.0)
react-select:
specifier: ^5.8.0
version: 5.8.0(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0)
@ -143,6 +140,9 @@ devDependencies:
'@rollup/wasm-node':
specifier: ^4.9.6
version: 4.9.6
'@tanstack/router-devtools':
specifier: ^1.1.4
version: 1.1.4(react-dom@18.2.0)(react@18.2.0)
eslint:
specifier: ^8.56.0
version: 8.56.0
@ -175,7 +175,7 @@ devDependencies:
version: 0.17.5(vite@5.0.12)(workbox-build@7.0.0)(workbox-window@7.0.0)
vite-plugin-svgr:
specifier: ^4.2.0
version: 4.2.0(@rollup/wasm-node@4.9.6)(typescript@5.3.3)(vite@5.0.12)
version: 4.2.0(@rollup/wasm-node@4.10.0)(typescript@5.3.3)(vite@5.0.12)
packages:
@ -1432,7 +1432,6 @@ packages:
engines: {node: '>=6.9.0'}
dependencies:
regenerator-runtime: 0.14.1
dev: false
/@babel/runtime@7.23.9:
resolution: {integrity: sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==}
@ -1982,12 +1981,7 @@ packages:
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
dev: false
/@remix-run/router@1.14.2:
resolution: {integrity: sha512-ACXpdMM9hmKZww21yEqWwiLws/UPLhNKvimN8RrYSqPSvB3ov7sLvAcfvaxePeLvccTQKGdkDIhLYApZVDFuKg==}
engines: {node: '>=14.0.0'}
dev: false
/@rollup/plugin-babel@5.3.1(@babel/core@7.23.9)(@rollup/wasm-node@4.9.6):
/@rollup/plugin-babel@5.3.1(@babel/core@7.23.9)(@rollup/wasm-node@4.10.0):
resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==}
engines: {node: '>= 10.0.0'}
peerDependencies:
@ -2000,36 +1994,36 @@ packages:
dependencies:
'@babel/core': 7.23.9
'@babel/helper-module-imports': 7.22.15
'@rollup/pluginutils': 3.1.0(@rollup/wasm-node@4.9.6)
rollup: /@rollup/wasm-node@4.9.6
'@rollup/pluginutils': 3.1.0(@rollup/wasm-node@4.10.0)
rollup: /@rollup/wasm-node@4.10.0
dev: true
/@rollup/plugin-node-resolve@11.2.1(@rollup/wasm-node@4.9.6):
/@rollup/plugin-node-resolve@11.2.1(@rollup/wasm-node@4.10.0):
resolution: {integrity: sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==}
engines: {node: '>= 10.0.0'}
peerDependencies:
rollup: npm:@rollup/wasm-node
dependencies:
'@rollup/pluginutils': 3.1.0(@rollup/wasm-node@4.9.6)
'@rollup/pluginutils': 3.1.0(@rollup/wasm-node@4.10.0)
'@types/resolve': 1.17.1
builtin-modules: 3.3.0
deepmerge: 4.3.1
is-module: 1.0.0
resolve: 1.22.8
rollup: /@rollup/wasm-node@4.9.6
rollup: /@rollup/wasm-node@4.10.0
dev: true
/@rollup/plugin-replace@2.4.2(@rollup/wasm-node@4.9.6):
/@rollup/plugin-replace@2.4.2(@rollup/wasm-node@4.10.0):
resolution: {integrity: sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==}
peerDependencies:
rollup: npm:@rollup/wasm-node
dependencies:
'@rollup/pluginutils': 3.1.0(@rollup/wasm-node@4.9.6)
'@rollup/pluginutils': 3.1.0(@rollup/wasm-node@4.10.0)
magic-string: 0.25.9
rollup: /@rollup/wasm-node@4.9.6
rollup: /@rollup/wasm-node@4.10.0
dev: true
/@rollup/pluginutils@3.1.0(@rollup/wasm-node@4.9.6):
/@rollup/pluginutils@3.1.0(@rollup/wasm-node@4.10.0):
resolution: {integrity: sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==}
engines: {node: '>= 8.0.0'}
peerDependencies:
@ -2038,10 +2032,10 @@ packages:
'@types/estree': 0.0.39
estree-walker: 1.0.1
picomatch: 2.3.1
rollup: /@rollup/wasm-node@4.9.6
rollup: /@rollup/wasm-node@4.10.0
dev: true
/@rollup/pluginutils@5.0.5(@rollup/wasm-node@4.9.6):
/@rollup/pluginutils@5.0.5(@rollup/wasm-node@4.10.0):
resolution: {integrity: sha512-6aEYR910NyP73oHiJglti74iRyOwgFU4x3meH/H8OJx6Ry0j6cOVZ5X/wTvub7G7Ao6qaHBEaNsV3GLJkSsF+Q==}
engines: {node: '>=14.0.0'}
peerDependencies:
@ -2053,9 +2047,18 @@ packages:
'@types/estree': 1.0.5
estree-walker: 2.0.2
picomatch: 2.3.1
rollup: /@rollup/wasm-node@4.9.6
rollup: /@rollup/wasm-node@4.10.0
dev: true
/@rollup/wasm-node@4.10.0:
resolution: {integrity: sha512-wH/ih4T/iP2PUyTrkyioZqDoFY/gmu63LPLTOM5Q21gSB/D3Ejw3UBpUOMLt86fIbN3mV+wL45MyA71XAj1ytg==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
dependencies:
'@types/estree': 1.0.5
optionalDependencies:
fsevents: 2.3.3
/@rollup/wasm-node@4.9.6:
resolution: {integrity: sha512-B3FpAkroTE6q+MRHzv8XLBgPbxdjJiy5UnduZNQ/4lxeF1JT2O/OAr0JPpXeRG/7zpKm/kdqU/4m6AULhmnSqw==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@ -2064,6 +2067,7 @@ packages:
'@types/estree': 1.0.5
optionalDependencies:
fsevents: 2.3.3
dev: true
/@surma/rollup-plugin-off-main-thread@2.2.3:
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
@ -2332,6 +2336,16 @@ packages:
tailwindcss: 3.4.1(ts-node@10.9.2)
dev: false
/@tanstack/history@1.1.4:
resolution: {integrity: sha512-H80reryZP3Ib5HzAo9zp1B8nbGzd+zOxe0Xt6bLYY2qtgCb+iIrVadDDt5ZnaFsrMBGbFTkEsS2ITVrAUao54A==}
engines: {node: '>=12'}
dev: true
/@tanstack/history@1.15.13:
resolution: {integrity: sha512-ToaeMtK5S4YaxCywAlYexc7KPFN0esjyTZ4vXzJhXEWAkro9iHgh7m/4ozPJb7oTo65WkHWX0W9GjcZbInSD8w==}
engines: {node: '>=12'}
dev: false
/@tanstack/query-core@5.17.19:
resolution: {integrity: sha512-Lzw8FUtnLCc9Jwz0sw9xOjZB+/mCCmJev38v2wHMUl/ioXNIhnNWeMxu0NKUjIhAd62IRB3eAtvxAGDJ55UkyA==}
dev: false
@ -2362,6 +2376,49 @@ packages:
react: 18.2.0
dev: false
/@tanstack/react-router@1.1.4(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-X+Nak7IxZfCHpH2GIZU9vDSpzpfDUmC30QzuYgwNRhWxGmmkDRF49d07CSc/CW5FQ9RvjECO/3dqw6X519E7HQ==}
engines: {node: '>=12'}
peerDependencies:
react: ^18.2.0
react-dom: '>=16'
dependencies:
'@babel/runtime': 7.23.7
'@tanstack/history': 1.1.4
'@tanstack/react-store': 0.2.1(react-dom@18.2.0)(react@18.2.0)
'@tanstack/store': 0.1.3
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
tiny-invariant: 1.3.1
tiny-warning: 1.0.3
dev: true
/@tanstack/react-router@1.16.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-jY/mbRsdtIcaj56Jys+pr1Z17rFKIGcOwDTI5V6615e/ZzNUaPRxEvz3dAk3mWDqKNTxBUiCU5UOz5dJKx2UOg==}
engines: {node: '>=12'}
peerDependencies:
react: ^18.2.0
react-dom: '>=16'
dependencies:
'@tanstack/history': 1.15.13
'@tanstack/react-store': 0.2.1(react-dom@18.2.0)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
tiny-invariant: 1.3.1
tiny-warning: 1.0.3
dev: false
/@tanstack/react-store@0.2.1(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-tEbMCQjbeVw9KOP/202LfqZMSNAVi6zYkkp1kBom8nFuMx/965Hzes3+6G6b/comCwVxoJU8Gg9IrcF8yRPthw==}
peerDependencies:
react: ^18.2.0
react-dom: '>=16'
dependencies:
'@tanstack/store': 0.1.3
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
use-sync-external-store: 1.2.0(react@18.2.0)
/@tanstack/react-virtual@3.0.2(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-9XbRLPKgnhMwwmuQMnJMv+5a9sitGNCSEtf/AZXzmJdesYk7XsjYHaEDny+IrJzvPNwZliIIDwCRiaUqR3zzCA==}
peerDependencies:
@ -2373,6 +2430,23 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@tanstack/router-devtools@1.1.4(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-/chCH/ty386podf2vwON55pAJ9MQ+94vSv35tsF6LgUlTXCw8fYOL4WR1Fp6PgBsUXrPfDt3TMAueVqSitVpeA==}
engines: {node: '>=12'}
peerDependencies:
react: ^18.2.0
react-dom: '>=16'
dependencies:
'@babel/runtime': 7.23.7
'@tanstack/react-router': 1.1.4(react-dom@18.2.0)(react@18.2.0)
date-fns: 2.30.0
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: true
/@tanstack/store@0.1.3:
resolution: {integrity: sha512-GnolmC8Fr4mvsHE1fGQmR3Nm0eBO3KnZjDU0a+P3TeQNM/dDscFGxtA7p31NplQNW3KwBw4t1RVFmz0VeKLxcw==}
/@tanstack/virtual-core@3.0.0:
resolution: {integrity: sha512-SYXOBTjJb05rXa2vl55TTwO40A6wKu0R5i1qQwhJYNDIqaIGF7D0HsLw+pJAyi2OvntlEIVusx3xtbbgSUi6zg==}
dev: false
@ -2396,10 +2470,6 @@ packages:
/@types/estree@1.0.5:
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
/@types/history@4.7.11:
resolution: {integrity: sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==}
dev: false
/@types/hoist-non-react-statics@3.3.5:
resolution: {integrity: sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==}
dependencies:
@ -2446,21 +2516,6 @@ packages:
'@types/react': 18.2.48
dev: false
/@types/react-router-dom@5.3.3:
resolution: {integrity: sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==}
dependencies:
'@types/history': 4.7.11
'@types/react': 18.2.48
'@types/react-router': 5.1.20
dev: false
/@types/react-router@5.1.20:
resolution: {integrity: sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==}
dependencies:
'@types/history': 4.7.11
'@types/react': 18.2.48
dev: false
/@types/react-table@7.7.19:
resolution: {integrity: sha512-47jMa1Pai7ily6BXJCW33IL5ghqmCWs2VM9s+h1D4mCaK5P4uNkZOW3RMMg8MCXBvAJ0v9+sPqKjhid0PaJPQA==}
dependencies:
@ -3108,6 +3163,13 @@ packages:
resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==}
dev: false
/date-fns@2.30.0:
resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
engines: {node: '>=0.11'}
dependencies:
'@babel/runtime': 7.23.9
dev: true
/date-fns@3.3.1:
resolution: {integrity: sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==}
dev: false
@ -4838,7 +4900,6 @@ packages:
loose-envify: 1.4.0
react: 18.2.0
scheduler: 0.23.0
dev: false
/react-error-boundary@4.0.12(react@18.2.0):
resolution: {integrity: sha512-kJdxdEYlb7CPC1A0SeUY38cHpjuu6UkvzKiAmqmOFL21VRfMhOcWxTCBgLVCO0VEMh9JhFNcVaXlV4/BTpiwOA==}
@ -4939,29 +5000,6 @@ packages:
react: 18.2.0
dev: false
/react-router-dom@6.21.3(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-kNzubk7n4YHSrErzjLK72j0B5i969GsuCGazRl3G6j1zqZBLjuSlYBdVdkDOgzGdPIffUOc9nmgiadTEVoq91g==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: ^18.2.0
react-dom: '>=16.8'
dependencies:
'@remix-run/router': 1.14.2
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-router: 6.21.3(react@18.2.0)
dev: false
/react-router@6.21.3(react@18.2.0):
resolution: {integrity: sha512-a0H638ZXULv1OdkmiK6s6itNhoy33ywxmUFT/xtSoVyf9VnC7n7+VT4LjVzdIHSaF5TIh9ylUgxMXksHTgGrKg==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: ^18.2.0
dependencies:
'@remix-run/router': 1.14.2
react: 18.2.0
dev: false
/react-select@5.8.0(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-TfjLDo58XrhP6VG5M/Mi56Us0Yt8X7xD6cDybC7yoRMUNm7BGO7qk8J0TLQOua/prb8vUOtsfnXZwfm30HGsAA==}
peerDependencies:
@ -5024,7 +5062,6 @@ packages:
engines: {node: '>=0.10.0'}
dependencies:
loose-envify: 1.4.0
dev: false
/read-cache@1.0.0:
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
@ -5138,7 +5175,7 @@ packages:
dependencies:
glob: 7.2.3
/rollup-plugin-terser@7.0.2(@rollup/wasm-node@4.9.6):
/rollup-plugin-terser@7.0.2(@rollup/wasm-node@4.10.0):
resolution: {integrity: sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==}
deprecated: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-terser
peerDependencies:
@ -5146,7 +5183,7 @@ packages:
dependencies:
'@babel/code-frame': 7.23.5
jest-worker: 26.6.2
rollup: /@rollup/wasm-node@4.9.6
rollup: /@rollup/wasm-node@4.10.0
serialize-javascript: 4.0.0
terser: 5.27.0
dev: true
@ -5182,7 +5219,6 @@ packages:
resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==}
dependencies:
loose-envify: 1.4.0
dev: false
/semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
@ -5516,9 +5552,11 @@ packages:
any-promise: 1.3.0
dev: false
/tiny-invariant@1.3.1:
resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==}
/tiny-warning@1.0.3:
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
dev: false
/to-fast-properties@2.0.0:
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
@ -5750,6 +5788,13 @@ packages:
use-isomorphic-layout-effect: 1.1.2(@types/react@18.2.48)(react@18.2.0)
dev: false
/use-sync-external-store@1.2.0(react@18.2.0):
resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==}
peerDependencies:
react: ^18.2.0
dependencies:
react: 18.2.0
/utf8@3.0.0:
resolution: {integrity: sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ==}
dev: true
@ -5779,12 +5824,12 @@ packages:
- supports-color
dev: true
/vite-plugin-svgr@4.2.0(@rollup/wasm-node@4.9.6)(typescript@5.3.3)(vite@5.0.12):
/vite-plugin-svgr@4.2.0(@rollup/wasm-node@4.10.0)(typescript@5.3.3)(vite@5.0.12):
resolution: {integrity: sha512-SC7+FfVtNQk7So0XMjrrtLAbEC8qjFPifyD7+fs/E6aaNdVde6umlVVh0QuwDLdOMu7vp5RiGFsB70nj5yo0XA==}
peerDependencies:
vite: ^2.6.0 || 3 || 4 || 5
dependencies:
'@rollup/pluginutils': 5.0.5(@rollup/wasm-node@4.9.6)
'@rollup/pluginutils': 5.0.5(@rollup/wasm-node@4.10.0)
'@svgr/core': 8.1.0(typescript@5.3.3)
'@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0)
vite: 5.0.12(@types/node@20.11.6)
@ -5825,7 +5870,7 @@ packages:
'@types/node': 20.11.6
esbuild: 0.19.12
postcss: 8.4.33
rollup: /@rollup/wasm-node@4.9.6
rollup: /@rollup/wasm-node@4.10.0
optionalDependencies:
fsevents: 2.3.3
@ -5923,9 +5968,9 @@ packages:
'@babel/core': 7.23.9
'@babel/preset-env': 7.23.9(@babel/core@7.23.9)
'@babel/runtime': 7.23.9
'@rollup/plugin-babel': 5.3.1(@babel/core@7.23.9)(@rollup/wasm-node@4.9.6)
'@rollup/plugin-node-resolve': 11.2.1(@rollup/wasm-node@4.9.6)
'@rollup/plugin-replace': 2.4.2(@rollup/wasm-node@4.9.6)
'@rollup/plugin-babel': 5.3.1(@babel/core@7.23.9)(@rollup/wasm-node@4.10.0)
'@rollup/plugin-node-resolve': 11.2.1(@rollup/wasm-node@4.10.0)
'@rollup/plugin-replace': 2.4.2(@rollup/wasm-node@4.10.0)
'@surma/rollup-plugin-off-main-thread': 2.2.3
ajv: 8.12.0
common-tags: 1.8.2
@ -5934,8 +5979,8 @@ packages:
glob: 7.2.3
lodash: 4.17.21
pretty-bytes: 5.6.0
rollup: /@rollup/wasm-node@4.9.6
rollup-plugin-terser: 7.0.2(@rollup/wasm-node@4.9.6)
rollup: /@rollup/wasm-node@4.10.0
rollup-plugin-terser: 7.0.2(@rollup/wasm-node@4.10.0)
source-map: 0.8.0-beta.0
stringify-object: 3.3.0
strip-comments: 2.0.1

View file

@ -3,60 +3,34 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { QueryClient, QueryClientProvider, useQueryErrorResetBoundary } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ErrorBoundary } from "react-error-boundary";
import { toast, Toaster } from "react-hot-toast";
import { LocalRouter } from "./domain/routes";
import { AuthContext, SettingsContext } from "./utils/Context";
import { ErrorPage } from "./components/alerts";
import Toast from "./components/notifications/Toast";
import { RouterProvider } from "@tanstack/react-router"
import { QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "react-hot-toast";
import { Portal } from "react-portal";
import { Router } from "@app/routes";
import { routerBasePath } from "@utils";
import { queryClient } from "@api/QueryClient";
import { AuthContext } from "@utils/Context";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// The retries will have exponential delay.
// See https://tanstack.com/query/v4/docs/guides/query-retries#retry-delay
// delay = Math.min(1000 * 2 ** attemptIndex, 30000)
retry: true,
throwOnError: true,
},
mutations: {
onError: (error) => {
// Use a format string to convert the error object to a proper string without much hassle.
const message = (
typeof (error) === "object" && typeof ((error as Error).message) ?
(error as Error).message :
`${error}`
);
toast.custom((t) => <Toast type="error" body={message} t={t} />);
}
}
declare module '@tanstack/react-router' {
interface Register {
router: typeof Router
}
});
}
export function App() {
const { reset } = useQueryErrorResetBoundary();
const authContext = AuthContext.useValue();
const settings = SettingsContext.useValue();
return (
<ErrorBoundary
onReset={reset}
FallbackComponent={ErrorPage}
>
<QueryClientProvider client={queryClient}>
<Portal>
<Toaster position="top-right" />
</Portal>
<LocalRouter isLoggedIn={authContext.isLoggedIn} />
{settings.debug ? (
<ReactQueryDevtools initialIsOpen={false} />
) : null}
<RouterProvider
basepath={routerBasePath()}
router={Router}
context={{
auth: AuthContext,
}}
/>
</QueryClientProvider>
</ErrorBoundary>
);
}

View file

@ -4,7 +4,6 @@
*/
import { baseUrl, sseBaseUrl } from "@utils";
import { AuthContext } from "@utils/Context";
import { GithubRelease } from "@app/types/Update";
type RequestBody = BodyInit | object | Record<string, unknown> | null;
@ -30,7 +29,8 @@ export async function HttpClient<T = unknown>(
): Promise<T> {
const init: RequestInit = {
method: config.method,
headers: { "Accept": "*/*" }
headers: { "Accept": "*/*", 'x-requested-with': 'XMLHttpRequest' },
credentials: "include",
};
if (config.body) {
@ -87,22 +87,17 @@ export async function HttpClient<T = unknown>(
return Promise.resolve<T>({} as T);
}
case 401: {
// Remove auth info from localStorage
AuthContext.reset();
// Show an error toast to notify the user what occurred
// return Promise.reject(new Error(`[401] Unauthorized: "${endpoint}"`));
return Promise.reject(response);
// return Promise.reject(new Error(`[401] Unauthorized: "${endpoint}"`));
}
case 403: {
// Remove auth info from localStorage
AuthContext.reset();
// Show an error toast to notify the user what occurred
return Promise.reject(response);
}
case 404: {
return Promise.reject(new Error(`[404] Not found: "${endpoint}"`));
const isJson = response.headers.get("Content-Type")?.includes("application/json");
const json = isJson ? await response.json() : null;
return Promise.reject<T>(json as T);
// return Promise.reject(new Error(`[404] Not Found: "${endpoint}"`));
}
case 500: {
const health = await window.fetch(`${baseUrl()}api/healthz/liveness`);
@ -326,6 +321,8 @@ export const APIClient = {
if (filter.id == "indexer") {
params["indexer"].push(filter.value);
} else if (filter.id === "action_status") {
params["push_status"].push(filter.value); // push_status is the correct value here otherwise the releases table won't load when filtered by push status
} else if (filter.id === "push_status") {
params["push_status"].push(filter.value);
} else if (filter.id == "name") {
params["q"].push(filter.value);

View file

@ -0,0 +1,64 @@
/*
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { QueryCache, QueryClient } from "@tanstack/react-query";
import { toast } from "react-hot-toast";
import Toast from "@components/notifications/Toast";
const MAX_RETRIES = 6;
const HTTP_STATUS_TO_NOT_RETRY = [400, 401, 403, 404];
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error ) => {
console.error("query client error: ", error);
toast.custom((t) => <Toast type="error" body={error?.message} t={t}/>);
// @ts-expect-error TS2339: Property status does not exist on type Error
if (error?.status === 401 || error?.status === 403) {
// @ts-expect-error TS2339: Property status does not exist on type Error
console.error("bad status, redirect to login", error?.status)
// Redirect to login page
window.location.href = "/login";
return
}
}
}),
defaultOptions: {
queries: {
// The retries will have exponential delay.
// See https://tanstack.com/query/v4/docs/guides/query-retries#retry-delay
// delay = Math.min(1000 * 2 ** attemptIndex, 30000)
// retry: false,
throwOnError: true,
retry: (failureCount, error) => {
console.debug("retry count:", failureCount)
console.error("retry err: ", error)
// @ts-expect-error TS2339: ignore
if (HTTP_STATUS_TO_NOT_RETRY.includes(error.status)) {
// @ts-expect-error TS2339: ignore
console.log(`retry: Aborting retry due to ${error.status} status`);
return false;
}
return failureCount <= MAX_RETRIES;
},
},
mutations: {
onError: (error) => {
// Use a format string to convert the error object to a proper string without much hassle.
const message = (
typeof (error) === "object" && typeof ((error as Error).message) ?
(error as Error).message :
`${error}`
);
toast.custom((t) => <Toast type="error" body={message} t={t}/>);
}
}
}
});

135
web/src/api/queries.ts Normal file
View file

@ -0,0 +1,135 @@
/*
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
import { APIClient } from "@api/APIClient";
import {
ApiKeys,
DownloadClientKeys,
FeedKeys,
FilterKeys,
IndexerKeys,
IrcKeys, NotificationKeys,
ReleaseKeys,
SettingsKeys
} from "@api/query_keys";
export const FiltersQueryOptions = (indexers: string[], sortOrder: string) =>
queryOptions({
queryKey: FilterKeys.list(indexers, sortOrder),
queryFn: () => APIClient.filters.find(indexers, sortOrder),
refetchOnWindowFocus: false
});
export const FilterByIdQueryOptions = (filterId: number) =>
queryOptions({
queryKey: FilterKeys.detail(filterId),
queryFn: async ({queryKey}) => await APIClient.filters.getByID(queryKey[2]),
retry: false,
});
export const ConfigQueryOptions = (enabled: boolean = true) =>
queryOptions({
queryKey: SettingsKeys.config(),
queryFn: () => APIClient.config.get(),
retry: false,
refetchOnWindowFocus: false,
enabled: enabled,
});
export const UpdatesQueryOptions = (enabled: boolean) =>
queryOptions({
queryKey: SettingsKeys.updates(),
queryFn: () => APIClient.updates.getLatestRelease(),
retry: false,
refetchOnWindowFocus: false,
enabled: enabled,
});
export const IndexersQueryOptions = () =>
queryOptions({
queryKey: IndexerKeys.lists(),
queryFn: () => APIClient.indexers.getAll()
});
export const IndexersOptionsQueryOptions = () =>
queryOptions({
queryKey: IndexerKeys.options(),
queryFn: () => APIClient.indexers.getOptions(),
refetchOnWindowFocus: false,
staleTime: Infinity
});
export const IndexersSchemaQueryOptions = (enabled: boolean) =>
queryOptions({
queryKey: IndexerKeys.schema(),
queryFn: () => APIClient.indexers.getSchema(),
refetchOnWindowFocus: false,
staleTime: Infinity,
enabled: enabled
});
export const IrcQueryOptions = () =>
queryOptions({
queryKey: IrcKeys.lists(),
queryFn: () => APIClient.irc.getNetworks(),
refetchOnWindowFocus: false,
refetchInterval: 3000 // Refetch every 3 seconds
});
export const FeedsQueryOptions = () =>
queryOptions({
queryKey: FeedKeys.lists(),
queryFn: () => APIClient.feeds.find(),
});
export const DownloadClientsQueryOptions = () =>
queryOptions({
queryKey: DownloadClientKeys.lists(),
queryFn: () => APIClient.download_clients.getAll(),
});
export const NotificationsQueryOptions = () =>
queryOptions({
queryKey: NotificationKeys.lists(),
queryFn: () => APIClient.notifications.getAll()
});
export const ApikeysQueryOptions = () =>
queryOptions({
queryKey: ApiKeys.lists(),
queryFn: () => APIClient.apikeys.getAll(),
refetchOnWindowFocus: false,
});
export const ReleasesListQueryOptions = (offset: number, limit: number, filters: ReleaseFilter[]) =>
queryOptions({
queryKey: ReleaseKeys.list(offset, limit, filters),
queryFn: () => APIClient.release.findQuery(offset, limit, filters),
staleTime: 5000
});
export const ReleasesLatestQueryOptions = () =>
queryOptions({
queryKey: ReleaseKeys.latestActivity(),
queryFn: () => APIClient.release.findRecent(),
refetchOnWindowFocus: false
});
export const ReleasesStatsQueryOptions = () =>
queryOptions({
queryKey: ReleaseKeys.stats(),
queryFn: () => APIClient.release.stats(),
refetchOnWindowFocus: false
});
// ReleasesIndexersQueryOptions get basic list of used indexers by identifier
export const ReleasesIndexersQueryOptions = () =>
queryOptions({
queryKey: ReleaseKeys.indexers(),
queryFn: () => APIClient.release.indexerOptions(),
placeholderData: keepPreviousData,
staleTime: Infinity
});

77
web/src/api/query_keys.ts Normal file
View file

@ -0,0 +1,77 @@
export const SettingsKeys = {
all: ["settings"] as const,
updates: () => [...SettingsKeys.all, "updates"] as const,
config: () => [...SettingsKeys.all, "config"] as const,
lists: () => [...SettingsKeys.all, "list"] as const,
};
export const FilterKeys = {
all: ["filters"] as const,
lists: () => [...FilterKeys.all, "list"] as const,
list: (indexers: string[], sortOrder: string) => [...FilterKeys.lists(), {indexers, sortOrder}] as const,
details: () => [...FilterKeys.all, "detail"] as const,
detail: (id: number) => [...FilterKeys.details(), id] as const
};
export const ReleaseKeys = {
all: ["releases"] as const,
lists: () => [...ReleaseKeys.all, "list"] as const,
list: (pageIndex: number, pageSize: number, filters: ReleaseFilter[]) => [...ReleaseKeys.lists(), {
pageIndex,
pageSize,
filters
}] as const,
details: () => [...ReleaseKeys.all, "detail"] as const,
detail: (id: number) => [...ReleaseKeys.details(), id] as const,
indexers: () => [...ReleaseKeys.all, "indexers"] as const,
stats: () => [...ReleaseKeys.all, "stats"] as const,
latestActivity: () => [...ReleaseKeys.all, "latest-activity"] as const,
};
export const ApiKeys = {
all: ["api_keys"] as const,
lists: () => [...ApiKeys.all, "list"] as const,
details: () => [...ApiKeys.all, "detail"] as const,
detail: (id: string) => [...ApiKeys.details(), id] as const
};
export const DownloadClientKeys = {
all: ["download_clients"] as const,
lists: () => [...DownloadClientKeys.all, "list"] as const,
// list: (indexers: string[], sortOrder: string) => [...clientKeys.lists(), { indexers, sortOrder }] as const,
details: () => [...DownloadClientKeys.all, "detail"] as const,
detail: (id: number) => [...DownloadClientKeys.details(), id] as const
};
export const FeedKeys = {
all: ["feeds"] as const,
lists: () => [...FeedKeys.all, "list"] as const,
// list: (indexers: string[], sortOrder: string) => [...feedKeys.lists(), { indexers, sortOrder }] as const,
details: () => [...FeedKeys.all, "detail"] as const,
detail: (id: number) => [...FeedKeys.details(), id] as const
};
export const IndexerKeys = {
all: ["indexers"] as const,
schema: () => [...IndexerKeys.all, "indexer-definitions"] as const,
options: () => [...IndexerKeys.all, "options"] as const,
lists: () => [...IndexerKeys.all, "list"] as const,
// list: (indexers: string[], sortOrder: string) => [...indexerKeys.lists(), { indexers, sortOrder }] as const,
details: () => [...IndexerKeys.all, "detail"] as const,
detail: (id: number) => [...IndexerKeys.details(), id] as const
};
export const IrcKeys = {
all: ["irc_networks"] as const,
lists: () => [...IrcKeys.all, "list"] as const,
// list: (indexers: string[], sortOrder: string) => [...ircKeys.lists(), { indexers, sortOrder }] as const,
details: () => [...IrcKeys.all, "detail"] as const,
detail: (id: number) => [...IrcKeys.details(), id] as const
};
export const NotificationKeys = {
all: ["notifications"] as const,
lists: () => [...NotificationKeys.all, "list"] as const,
details: () => [...NotificationKeys.all, "detail"] as const,
detail: (id: number) => [...NotificationKeys.details(), id] as const
};

View file

@ -1,32 +0,0 @@
/*
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { RingResizeSpinner } from "@components/Icons";
import { classNames } from "@utils";
const SIZE = {
small: "w-6 h-6",
medium: "w-8 h-8",
large: "w-12 h-12",
xlarge: "w-24 h-24"
} as const;
interface SectionLoaderProps {
$size: keyof typeof SIZE;
}
export const SectionLoader = ({ $size }: SectionLoaderProps) => {
if ($size === "xlarge") {
return (
<div className="max-w-screen-xl mx-auto pb-6 px-4 sm:px-6 lg:pb-16 lg:px-8">
<RingResizeSpinner className={classNames(SIZE[$size], "mx-auto my-36 text-blue-500")} />
</div>
);
} else {
return (
<RingResizeSpinner className={classNames(SIZE[$size], "text-blue-500")} />
);
}
};

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Link } from "react-router-dom";
import { Link } from "@tanstack/react-router";
import { ExternalLink } from "@components/ExternalLink";
import Logo from "@app/logo.svg?react";
@ -12,8 +12,11 @@ export const NotFound = () => {
return (
<div className="min-h-screen flex flex-col justify-center ">
<div className="flex justify-center">
<Logo className="h-24 sm:h-48" />
<Logo className="h-24 sm:h-48"/>
</div>
<h2 className="text-2xl text-center font-bold text-gray-900 dark:text-gray-200 my-8 px-2">
404 Page not found
</h2>
<h1 className="text-3xl text-center font-bold text-gray-900 dark:text-gray-200 my-8 px-2">
Oops, looks like there was a little too much brr!
</h1>

View file

@ -9,7 +9,6 @@ import { formatDistanceToNowStrict } from "date-fns";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CellProps } from "react-table";
import { ArrowPathIcon, CheckIcon } from "@heroicons/react/24/solid";
import { ExternalLink } from "../ExternalLink";
import {
ClockIcon,
XMarkIcon,
@ -19,8 +18,9 @@ import {
} from "@heroicons/react/24/outline";
import { APIClient } from "@api/APIClient";
import {classNames, humanFileSize, simplifyDate} from "@utils";
import { filterKeys } from "@screens/filters/List";
import { FilterKeys } from "@api/query_keys";
import { classNames, humanFileSize, simplifyDate } from "@utils";
import { ExternalLink } from "../ExternalLink";
import Toast from "@components/notifications/Toast";
import { RingResizeSpinner } from "@components/Icons";
import { Tooltip } from "@components/tooltips/Tooltip";
@ -164,7 +164,7 @@ const RetryActionButton = ({ status }: RetryActionButtonProps) => {
mutationFn: (vars: RetryAction) => APIClient.release.replayAction(vars.releaseId, vars.actionId),
onSuccess: () => {
// Invalidate filters just in case, most likely not necessary but can't hurt.
queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
queryClient.invalidateQueries({ queryKey: FilterKeys.lists() });
toast.custom((t) => (
<Toast type="success" body={`${status?.action} replayed`} t={t} />

View file

@ -23,3 +23,11 @@ export const DEBUG: FC<DebugProps> = ({ values }) => {
</div>
);
};
export function LogDebug(...data: any[]): void {
if (process.env.NODE_ENV !== "development") {
return;
}
console.log(...data)
}

View file

@ -5,11 +5,11 @@
import toast from "react-hot-toast";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useRouter } from "@tanstack/react-router";
import { Disclosure } from "@headlessui/react";
import { Bars3Icon, XMarkIcon, MegaphoneIcon } from "@heroicons/react/24/outline";
import { APIClient } from "@api/APIClient";
import { AuthContext } from "@utils/Context";
import Toast from "@components/notifications/Toast";
import { LeftNav } from "./LeftNav";
@ -17,37 +17,35 @@ import { RightNav } from "./RightNav";
import { MobileNav } from "./MobileNav";
import { ExternalLink } from "@components/ExternalLink";
export const Header = () => {
const { isError:isConfigError, error: configError, data: config } = useQuery({
queryKey: ["config"],
queryFn: () => APIClient.config.get(),
retry: false,
refetchOnWindowFocus: false
});
import { AuthIndexRoute } from "@app/routes";
import { ConfigQueryOptions, UpdatesQueryOptions } from "@api/queries";
export const Header = () => {
const router = useRouter()
const { auth } = AuthIndexRoute.useRouteContext()
const { isError:isConfigError, error: configError, data: config } = useQuery(ConfigQueryOptions(true));
if (isConfigError) {
console.log(configError);
}
const { isError, error, data } = useQuery({
queryKey: ["updates"],
queryFn: () => APIClient.updates.getLatestRelease(),
retry: false,
refetchOnWindowFocus: false,
enabled: config?.check_for_updates === true
});
if (isError) {
console.log(error);
const { isError: isUpdateError, error, data } = useQuery(UpdatesQueryOptions(config?.check_for_updates === true));
if (isUpdateError) {
console.log("update error", error);
}
const logoutMutation = useMutation({
mutationFn: APIClient.auth.logout,
onSuccess: () => {
AuthContext.reset();
toast.custom((t) => (
<Toast type="success" body="You have been logged out. Goodbye!" t={t} />
));
auth.logout()
router.history.push("/")
},
onError: (err) => {
console.error("logout error", err)
}
});
@ -62,7 +60,7 @@ export const Header = () => {
<div className="border-b border-gray-300 dark:border-gray-775">
<div className="flex items-center justify-between h-16 px-4 sm:px-0">
<LeftNav />
<RightNav logoutMutation={logoutMutation.mutate} />
<RightNav logoutMutation={logoutMutation.mutate} auth={auth} />
<div className="-mr-2 flex sm:hidden">
{/* Mobile menu button */}
<Disclosure.Button className="bg-gray-200 dark:bg-gray-800 inline-flex items-center justify-center p-2 rounded-md text-gray-600 dark:text-gray-400 hover:text-white hover:bg-gray-700">
@ -94,7 +92,7 @@ export const Header = () => {
)}
</div>
<MobileNav logoutMutation={logoutMutation.mutate} />
<MobileNav logoutMutation={logoutMutation.mutate} auth={auth} />
</>
)}
</Disclosure>

View file

@ -3,7 +3,10 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Link, NavLink } from "react-router-dom";
// import { Link, NavLink } from "react-router-dom";
import { Link } from '@tanstack/react-router'
import { ArrowTopRightOnSquareIcon } from "@heroicons/react/24/solid";
import { classNames } from "@utils";
@ -23,22 +26,27 @@ export const LeftNav = () => (
<div className="sm:ml-3 hidden sm:block">
<div className="flex items-baseline space-x-4">
{NAV_ROUTES.map((item, itemIdx) => (
<NavLink
<Link
key={item.name + itemIdx}
to={item.path}
className={({ isActive }) =>
classNames(
"hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white px-3 py-2 rounded-2xl text-sm font-medium",
"transition-colors duration-200",
isActive
? "text-black dark:text-gray-50 font-bold"
: "text-gray-600 dark:text-gray-500"
)
}
end={item.path === "/"}
params={{}}
>
{item.name}
</NavLink>
{({ isActive }) => {
return (
<>
<span className={
classNames(
"hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white px-3 py-2 rounded-2xl text-sm font-medium",
"transition-colors duration-200",
isActive
? "text-black dark:text-gray-50 font-bold"
: "text-gray-600 dark:text-gray-500"
)
}>{item.name}</span>
</>
)
}}
</Link>
))}
<ExternalLink
href="https://autobrr.com"

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { NavLink } from "react-router-dom";
import {Link} from "@tanstack/react-router";
import { Disclosure } from "@headlessui/react";
import { classNames } from "@utils";
@ -15,21 +15,28 @@ export const MobileNav = (props: RightNavProps) => (
<Disclosure.Panel className="border-b border-gray-300 dark:border-gray-700 md:hidden">
<div className="px-2 py-3 space-y-1 sm:px-3">
{NAV_ROUTES.map((item) => (
<NavLink
<Link
key={item.path}
activeOptions={{ exact: item.exact }}
to={item.path}
className={({ isActive }) =>
classNames(
"shadow-sm border bg-gray-100 border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-white block px-3 py-2 rounded-md text-base",
isActive
search={{}}
params={{}}
>
{({ isActive }) => {
return (
<span className={
classNames(
"shadow-sm border bg-gray-100 border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-white block px-3 py-2 rounded-md text-base",
isActive
? "underline underline-offset-2 decoration-2 decoration-sky-500 font-bold text-black"
: "font-medium"
)
}
end={item.path === "/"}
>
{item.name}
</NavLink>
)
}>
{item.name}
</span>
)
}}
</Link>
))}
<button
onClick={(e) => {

View file

@ -4,18 +4,16 @@
*/
import { Fragment } from "react";
import { Link } from "react-router-dom";
import { UserIcon } from "@heroicons/react/24/solid";
import { Menu, Transition } from "@headlessui/react";
import { classNames } from "@utils";
import { AuthContext } from "@utils/Context";
import { RightNavProps } from "./_shared";
import { Cog6ToothIcon, ArrowLeftOnRectangleIcon } from "@heroicons/react/24/outline";
import {Link} from "@tanstack/react-router";
export const RightNav = (props: RightNavProps) => {
const authContext = AuthContext.useValue();
return (
<div className="hidden sm:block">
<div className="ml-4 flex items-center sm:ml-6">
@ -34,7 +32,7 @@ export const RightNav = (props: RightNavProps) => {
<span className="sr-only">
Open user menu for{" "}
</span>
{authContext.username}
{props.auth.username}
</span>
<UserIcon
className="inline ml-1 h-5 w-5"

View file

@ -3,17 +3,21 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { AuthCtx } from "@utils/Context";
interface NavItem {
name: string;
path: string;
exact?: boolean;
}
export interface RightNavProps {
logoutMutation: () => void;
auth: AuthCtx
}
export const NAV_ROUTES: Array<NavItem> = [
{ name: "Dashboard", path: "/" },
{ name: "Dashboard", path: "/", exact: true },
{ name: "Filters", path: "/filters" },
{ name: "Releases", path: "/releases" },
{ name: "Settings", path: "/settings" },

View file

@ -8,7 +8,7 @@ import { FC, Fragment, MutableRefObject, useState } from "react";
import { Dialog, Transition } from "@headlessui/react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { SectionLoader } from "@components/SectionLoader";
import { RingResizeSpinner } from "@components/Icons";
interface ModalUpperProps {
title: string;
@ -58,7 +58,7 @@ const ModalUpper = ({ title, text }: ModalUpperProps) => (
const ModalLower = ({ isOpen, isLoading, toggle, deleteAction }: ModalLowerProps) => (
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
{isLoading ? (
<SectionLoader $size="small" />
<RingResizeSpinner className="text-blue-500 size-6" />
) : (
<>
<button
@ -221,7 +221,7 @@ export const ForceRunModal: FC<ForceRunModalProps> = (props: ForceRunModalProps)
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
{props.isLoading ? (
<SectionLoader $size="small" />
<RingResizeSpinner className="text-blue-500 size-6" />
) : (
<>
<button

View file

@ -1,67 +0,0 @@
/*
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Suspense } from "react";
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";
import { baseUrl } from "@utils";
import { Header } from "@components/header";
import { SectionLoader } from "@components/SectionLoader";
import { NotFound } from "@components/alerts/NotFound";
import { Logs } from "@screens/Logs";
import { Releases } from "@screens/Releases";
import { Settings } from "@screens/Settings";
import { Dashboard } from "@screens/Dashboard";
import { Login, Onboarding } from "@screens/auth";
import { Filters, FilterDetails } from "@screens/filters";
import * as SettingsSubPage from "@screens/settings/index";
const BaseLayout = () => (
<div className="min-h-screen">
<Header />
<Suspense fallback={<SectionLoader $size="xlarge" />}>
<Outlet />
</Suspense>
</div>
);
export const LocalRouter = ({ isLoggedIn }: { isLoggedIn: boolean }) => (
<BrowserRouter basename={baseUrl()}>
{isLoggedIn ? (
<Routes>
<Route path="*" element={<NotFound />} />
<Route element={<BaseLayout />}>
<Route index element={<Dashboard />} />
<Route path="logs" element={<Logs />} />
<Route path="releases" element={<Releases />} />
<Route path="filters">
<Route index element={<Filters />} />
<Route path=":filterId/*" element={<FilterDetails />} />
</Route>
<Route path="settings" element={<Settings />}>
<Route index element={<SettingsSubPage.Application />} />
<Route path="logs" element={<SettingsSubPage.Logs />} />
<Route path="api-keys" element={<SettingsSubPage.Api />} />
<Route path="indexers" element={<SettingsSubPage.Indexer />} />
<Route path="feeds" element={<SettingsSubPage.Feed />} />
<Route path="irc" element={<SettingsSubPage.Irc />} />
<Route path="clients" element={<SettingsSubPage.DownloadClient />} />
<Route path="notifications" element={<SettingsSubPage.Notification />} />
<Route path="releases" element={<SettingsSubPage.Release />} />
<Route path="regex-playground" element={<SettingsSubPage.RegexPlayground />} />
<Route path="account" element={<SettingsSubPage.Account />} />
</Route>
</Route>
</Routes>
) : (
<Routes>
<Route path="/onboard" element={<Onboarding />} />
<Route path="*" element={<Login />} />
</Routes>
)}
</BrowserRouter>
);

View file

@ -5,17 +5,18 @@
import { Fragment } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { toast } from "react-hot-toast";
import { XMarkIcon } from "@heroicons/react/24/solid";
import { Dialog, Transition } from "@headlessui/react";
import type { FieldProps } from "formik";
import { Field, Form, Formik, FormikErrors, FormikValues } from "formik";
import { useNavigate } from "react-router-dom";
import { APIClient } from "@api/APIClient";
import { FilterKeys } from "@api/query_keys";
import { DEBUG } from "@components/debug";
import Toast from "@components/notifications/Toast";
import { filterKeys } from "@screens/filters/List";
interface filterAddFormProps {
isOpen: boolean;
@ -28,13 +29,12 @@ export function FilterAddForm({ isOpen, toggle }: filterAddFormProps) {
const mutation = useMutation({
mutationFn: (filter: Filter) => APIClient.filters.create(filter),
onSuccess: (filter) => {
queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
queryClient.invalidateQueries({ queryKey: FilterKeys.lists() });
toast.custom((t) => <Toast type="success" body={`Filter ${filter.name} was added`} t={t} />);
toggle();
if (filter.id) {
navigate(filter.id.toString());
navigate({ to: "/filters/$filterId", params: { filterId: filter.id }})
}
}
});

View file

@ -12,9 +12,9 @@ import type { FieldProps } from "formik";
import { Field, Form, Formik, FormikErrors, FormikValues } from "formik";
import { APIClient } from "@api/APIClient";
import { ApiKeys } from "@api/query_keys";
import { DEBUG } from "@components/debug";
import Toast from "@components/notifications/Toast";
import { apiKeys } from "@screens/settings/Api";
interface apiKeyAddFormProps {
isOpen: boolean;
@ -27,7 +27,7 @@ export function APIKeyAddForm({ isOpen, toggle }: apiKeyAddFormProps) {
const mutation = useMutation({
mutationFn: (apikey: APIKey) => APIClient.apikeys.create(apikey),
onSuccess: (_, key) => {
queryClient.invalidateQueries({ queryKey: apiKeys.lists() });
queryClient.invalidateQueries({ queryKey: ApiKeys.lists() });
toast.custom((t) => <Toast type="success" body={`API key ${key.name} was added`} t={t}/>);

View file

@ -13,6 +13,7 @@ import { toast } from "react-hot-toast";
import { classNames, sleep } from "@utils";
import { DEBUG } from "@components/debug";
import { APIClient } from "@api/APIClient";
import { DownloadClientKeys } from "@api/query_keys";
import { DownloadClientTypeOptions, DownloadRuleConditionOptions } from "@domain/constants";
import Toast from "@components/notifications/Toast";
import { useToggle } from "@hooks/hooks";
@ -24,7 +25,6 @@ import {
SwitchGroupWide,
TextFieldWide
} from "@components/inputs";
import { clientKeys } from "@screens/settings/DownloadClient";
import { DocsLink, ExternalLink } from "@components/ExternalLink";
import { SelectFieldBasic } from "@components/inputs/select_wide";
@ -693,7 +693,7 @@ export function DownloadClientAddForm({ isOpen, toggle }: formProps) {
const addMutation = useMutation({
mutationFn: (client: DownloadClient) => APIClient.download_clients.create(client),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: clientKeys.lists() });
queryClient.invalidateQueries({ queryKey: DownloadClientKeys.lists() });
toast.custom((t) => <Toast type="success" body="Client was added" t={t} />);
toggle();
@ -865,8 +865,8 @@ export function DownloadClientUpdateForm({ client, isOpen, toggle }: updateFormP
const mutation = useMutation({
mutationFn: (client: DownloadClient) => APIClient.download_clients.update(client),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: clientKeys.lists() });
queryClient.invalidateQueries({ queryKey: clientKeys.detail(client.id) });
queryClient.invalidateQueries({ queryKey: DownloadClientKeys.lists() });
queryClient.invalidateQueries({ queryKey: DownloadClientKeys.detail(client.id) });
toast.custom((t) => <Toast type="success" body={`${client.name} was updated successfully`} t={t} />);
toggle();
@ -878,8 +878,8 @@ export function DownloadClientUpdateForm({ client, isOpen, toggle }: updateFormP
const deleteMutation = useMutation({
mutationFn: (clientID: number) => APIClient.download_clients.delete(clientID),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: clientKeys.lists() });
queryClient.invalidateQueries({ queryKey: clientKeys.detail(client.id) });
queryClient.invalidateQueries({ queryKey: DownloadClientKeys.lists() });
queryClient.invalidateQueries({ queryKey: DownloadClientKeys.detail(client.id) });
toast.custom((t) => <Toast type="success" body={`${client.name} was deleted.`} t={t} />);
toggleDeleteModal();

View file

@ -9,6 +9,7 @@ import { toast } from "react-hot-toast";
import { useFormikContext } from "formik";
import { APIClient } from "@api/APIClient";
import { FeedKeys } from "@api/query_keys";
import Toast from "@components/notifications/Toast";
import { SlideOver } from "@components/panels";
import { NumberFieldWide, PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs";
@ -17,7 +18,7 @@ import { componentMapType } from "./DownloadClientForms";
import { sleep } from "@utils";
import { ImplementationBadges } from "@screens/settings/Indexer";
import { FeedDownloadTypeOptions } from "@domain/constants";
import { feedKeys } from "@screens/settings/Feed";
interface UpdateProps {
isOpen: boolean;
@ -50,7 +51,7 @@ export function FeedUpdateForm({ isOpen, toggle, feed }: UpdateProps) {
const mutation = useMutation({
mutationFn: (feed: Feed) => APIClient.feeds.update(feed),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: feedKeys.lists() });
queryClient.invalidateQueries({ queryKey: FeedKeys.lists() });
toast.custom((t) => <Toast type="success" body={`${feed.name} was updated successfully`} t={t} />);
toggle();
@ -62,7 +63,7 @@ export function FeedUpdateForm({ isOpen, toggle, feed }: UpdateProps) {
const deleteMutation = useMutation({
mutationFn: (feedID: number) => APIClient.feeds.delete(feedID),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: feedKeys.lists() });
queryClient.invalidateQueries({ queryKey: FeedKeys.lists() });
toast.custom((t) => <Toast type="success" body={`${feed.name} was deleted.`} t={t} />);
}

View file

@ -15,13 +15,13 @@ import { Dialog, Transition } from "@headlessui/react";
import { classNames, sleep } from "@utils";
import { DEBUG } from "@components/debug";
import { APIClient } from "@api/APIClient";
import { FeedKeys, IndexerKeys, ReleaseKeys } from "@api/query_keys";
import { IndexersSchemaQueryOptions } from "@api/queries";
import { SlideOver } from "@components/panels";
import Toast from "@components/notifications/Toast";
import { PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs";
import { SelectFieldBasic, SelectFieldCreatable } from "@components/inputs/select_wide";
import { FeedDownloadTypeOptions } from "@domain/constants";
import { feedKeys } from "@screens/settings/Feed";
import { indexerKeys } from "@screens/settings/Indexer";
import { DocsLink } from "@components/ExternalLink";
import * as common from "@components/inputs/common";
@ -263,17 +263,14 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
const [indexer, setIndexer] = useState<IndexerDefinition>({} as IndexerDefinition);
const queryClient = useQueryClient();
const { data } = useQuery({
queryKey: ["indexerDefinition"],
queryFn: APIClient.indexers.getSchema,
enabled: isOpen,
refetchOnWindowFocus: false
});
const { data } = useQuery(IndexersSchemaQueryOptions(isOpen));
const mutation = useMutation({
mutationFn: (indexer: Indexer) => APIClient.indexers.create(indexer),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: indexerKeys.lists() });
queryClient.invalidateQueries({ queryKey: IndexerKeys.lists() });
queryClient.invalidateQueries({ queryKey: IndexerKeys.options() });
queryClient.invalidateQueries({ queryKey: ReleaseKeys.indexers() });
toast.custom((t) => <Toast type="success" body="Indexer was added" t={t} />);
sleep(1500);
@ -291,7 +288,7 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
const feedMutation = useMutation({
mutationFn: (feed: FeedCreate) => APIClient.feeds.create(feed),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: feedKeys.lists() });
queryClient.invalidateQueries({ queryKey: FeedKeys.lists() });
}
});
@ -738,7 +735,7 @@ export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) {
const mutation = useMutation({
mutationFn: (indexer: Indexer) => APIClient.indexers.update(indexer),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: indexerKeys.lists() });
queryClient.invalidateQueries({ queryKey: IndexerKeys.lists() });
toast.custom((t) => <Toast type="success" body={`${indexer.name} was updated successfully`} t={t} />);
sleep(1500);
@ -755,7 +752,9 @@ export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) {
const deleteMutation = useMutation({
mutationFn: (id: number) => APIClient.indexers.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: indexerKeys.lists() });
queryClient.invalidateQueries({ queryKey: IndexerKeys.lists() });
queryClient.invalidateQueries({ queryKey: IndexerKeys.options() });
queryClient.invalidateQueries({ queryKey: ReleaseKeys.indexers() });
toast.custom((t) => <Toast type="success" body={`${indexer.name} was deleted.`} t={t} />);

View file

@ -14,8 +14,8 @@ import Select from "react-select";
import { Dialog } from "@headlessui/react";
import { IrcAuthMechanismTypeOptions, OptionBasicTyped } from "@domain/constants";
import { ircKeys } from "@screens/settings/Irc";
import { APIClient } from "@api/APIClient";
import { IrcKeys } from "@api/query_keys";
import { NumberFieldWide, PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs";
import { SlideOver } from "@components/panels";
import Toast from "@components/notifications/Toast";
@ -132,7 +132,7 @@ export function IrcNetworkAddForm({ isOpen, toggle }: AddFormProps) {
const mutation = useMutation({
mutationFn: (network: IrcNetwork) => APIClient.irc.createNetwork(network),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ircKeys.lists() });
queryClient.invalidateQueries({ queryKey: IrcKeys.lists() });
toast.custom((t) => <Toast type="success" body="IRC Network added. Please allow up to 30 seconds for the network to come online." t={t} />);
toggle();
@ -288,7 +288,7 @@ export function IrcNetworkUpdateForm({
const updateMutation = useMutation({
mutationFn: (network: IrcNetwork) => APIClient.irc.updateNetwork(network),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ircKeys.lists() });
queryClient.invalidateQueries({ queryKey: IrcKeys.lists() });
toast.custom((t) => <Toast type="success" body={`${network.name} was updated successfully`} t={t} />);
@ -301,7 +301,7 @@ export function IrcNetworkUpdateForm({
const deleteMutation = useMutation({
mutationFn: (id: number) => APIClient.irc.deleteNetwork(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ircKeys.lists() });
queryClient.invalidateQueries({ queryKey: IrcKeys.lists() });
toast.custom((t) => <Toast type="success" body={`${network.name} was deleted.`} t={t} />);

View file

@ -13,7 +13,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "react-hot-toast";
import { APIClient } from "@api/APIClient";
import { notificationKeys } from "@screens/settings/Notifications";
import { NotificationKeys } from "@api/query_keys";
import { EventOptions, NotificationTypeOptions, SelectOption } from "@domain/constants";
import { DEBUG } from "@components/debug";
import { SlideOver } from "@components/panels";
@ -294,7 +294,7 @@ export function NotificationAddForm({ isOpen, toggle }: AddProps) {
const createMutation = useMutation({
mutationFn: (notification: ServiceNotification) => APIClient.notifications.create(notification),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: notificationKeys.lists() });
queryClient.invalidateQueries({ queryKey: NotificationKeys.lists() });
toast.custom((t) => <Toast type="success" body="Notification added!" t={t} />);
toggle();
@ -565,7 +565,7 @@ export function NotificationUpdateForm({ isOpen, toggle, notification }: UpdateP
const mutation = useMutation({
mutationFn: (notification: ServiceNotification) => APIClient.notifications.update(notification),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: notificationKeys.lists() });
queryClient.invalidateQueries({ queryKey: NotificationKeys.lists() });
toast.custom((t) => <Toast type="success" body={`${notification.name} was updated successfully`} t={t}/>);
toggle();
@ -577,7 +577,7 @@ export function NotificationUpdateForm({ isOpen, toggle, notification }: UpdateP
const deleteMutation = useMutation({
mutationFn: (notificationID: number) => APIClient.notifications.delete(notificationID),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: notificationKeys.lists() });
queryClient.invalidateQueries({ queryKey: NotificationKeys.lists() });
toast.custom((t) => <Toast type="success" body={`${notification.name} was deleted.`} t={t}/>);
}

377
web/src/routes.tsx Normal file
View file

@ -0,0 +1,377 @@
/*
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import {
createRootRouteWithContext,
createRoute,
createRouter,
ErrorComponent,
notFound,
Outlet,
redirect,
} from "@tanstack/react-router";
import { z } from "zod";
import { QueryClient } from "@tanstack/react-query";
import { Actions, Advanced, External, General, MoviesTv, Music } from "@screens/filters/sections";
import { APIClient } from "@api/APIClient";
import { Login, Onboarding } from "@screens/auth";
import ReleaseSettings from "@screens/settings/Releases";
import { NotFound } from "@components/alerts/NotFound";
import { FilterDetails, FilterNotFound, Filters } from "@screens/filters";
import { Settings } from "@screens/Settings";
import {
ApikeysQueryOptions,
ConfigQueryOptions,
DownloadClientsQueryOptions,
FeedsQueryOptions,
FilterByIdQueryOptions,
IndexersQueryOptions,
IrcQueryOptions,
NotificationsQueryOptions
} from "@api/queries";
import LogSettings from "@screens/settings/Logs";
import NotificationSettings from "@screens/settings/Notifications";
import ApplicationSettings from "@screens/settings/Application";
import { Logs } from "@screens/Logs";
import IrcSettings from "@screens/settings/Irc";
import { Header } from "@components/header";
import { RingResizeSpinner } from "@components/Icons";
import APISettings from "@screens/settings/Api";
import { Releases } from "@screens/Releases";
import IndexerSettings from "@screens/settings/Indexer";
import DownloadClientSettings from "@screens/settings/DownloadClient";
import FeedSettings from "@screens/settings/Feed";
import { Dashboard } from "@screens/Dashboard";
import AccountSettings from "@screens/settings/Account";
import { AuthContext, AuthCtx, localStorageUserKey, SettingsContext } from "@utils/Context";
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { queryClient } from "@api/QueryClient";
const DashboardRoute = createRoute({
getParentRoute: () => AuthIndexRoute,
path: '/',
loader: () => {
// https://tanstack.com/router/v1/docs/guide/deferred-data-loading#deferred-data-loading-with-defer-and-await
// TODO load stats
// TODO load recent releases
return {}
},
component: Dashboard,
});
const FiltersRoute = createRoute({
getParentRoute: () => AuthIndexRoute,
path: 'filters'
});
const FilterIndexRoute = createRoute({
getParentRoute: () => FiltersRoute,
path: '/',
component: Filters,
});
export const FilterGetByIdRoute = createRoute({
getParentRoute: () => FiltersRoute,
path: '$filterId',
parseParams: (params) => ({
filterId: z.number().int().parse(Number(params.filterId)),
}),
stringifyParams: ({filterId}) => ({filterId: `${filterId}`}),
loader: async ({context, params}) => {
try {
const filter = await context.queryClient.ensureQueryData(FilterByIdQueryOptions(params.filterId))
return { filter }
} catch (e) {
throw notFound()
}
},
component: FilterDetails,
notFoundComponent: () => {
return <FilterNotFound />
},
});
export const FilterGeneralRoute = createRoute({
getParentRoute: () => FilterGetByIdRoute,
path: '/',
component: General
});
export const FilterMoviesTvRoute = createRoute({
getParentRoute: () => FilterGetByIdRoute,
path: 'movies-tv',
component: MoviesTv
});
export const FilterMusicRoute = createRoute({
getParentRoute: () => FilterGetByIdRoute,
path: 'music',
component: Music
});
export const FilterAdvancedRoute = createRoute({
getParentRoute: () => FilterGetByIdRoute,
path: 'advanced',
component: Advanced
});
export const FilterExternalRoute = createRoute({
getParentRoute: () => FilterGetByIdRoute,
path: 'external',
component: External
});
export const FilterActionsRoute = createRoute({
getParentRoute: () => FilterGetByIdRoute,
path: 'actions',
component: Actions
});
const ReleasesRoute = createRoute({
getParentRoute: () => AuthIndexRoute,
path: 'releases'
});
// type ReleasesSearch = z.infer<typeof releasesSearchSchema>
export const ReleasesIndexRoute = createRoute({
getParentRoute: () => ReleasesRoute,
path: '/',
component: Releases,
validateSearch: (search) => z.object({
offset: z.number().optional(),
limit: z.number().optional(),
filter: z.string().optional(),
q: z.string().optional(),
action_status: z.enum(['PUSH_APPROVED', 'PUSH_REJECTED', 'PUSH_ERROR', '']).optional(),
// filters: z.array().catch(''),
// sort: z.enum(['newest', 'oldest', 'price']).catch('newest'),
}).parse(search),
});
export const SettingsRoute = createRoute({
getParentRoute: () => AuthIndexRoute,
path: 'settings',
pendingMs: 3000,
component: Settings
});
export const SettingsIndexRoute = createRoute({
getParentRoute: () => SettingsRoute,
path: '/',
component: ApplicationSettings
});
export const SettingsLogRoute = createRoute({
getParentRoute: () => SettingsRoute,
path: 'logs',
loader: (opts) => opts.context.queryClient.ensureQueryData(ConfigQueryOptions()),
component: LogSettings
});
export const SettingsIndexersRoute = createRoute({
getParentRoute: () => SettingsRoute,
path: 'indexers',
loader: (opts) => opts.context.queryClient.ensureQueryData(IndexersQueryOptions()),
component: IndexerSettings
});
export const SettingsIrcRoute = createRoute({
getParentRoute: () => SettingsRoute,
path: 'irc',
loader: (opts) => opts.context.queryClient.ensureQueryData(IrcQueryOptions()),
component: IrcSettings
});
export const SettingsFeedsRoute = createRoute({
getParentRoute: () => SettingsRoute,
path: 'feeds',
loader: (opts) => opts.context.queryClient.ensureQueryData(FeedsQueryOptions()),
component: FeedSettings
});
export const SettingsClientsRoute = createRoute({
getParentRoute: () => SettingsRoute,
path: 'clients',
loader: (opts) => opts.context.queryClient.ensureQueryData(DownloadClientsQueryOptions()),
component: DownloadClientSettings
});
export const SettingsNotificationsRoute = createRoute({
getParentRoute: () => SettingsRoute,
path: 'notifications',
loader: (opts) => opts.context.queryClient.ensureQueryData(NotificationsQueryOptions()),
component: NotificationSettings
});
export const SettingsApiRoute = createRoute({
getParentRoute: () => SettingsRoute,
path: 'api',
loader: (opts) => opts.context.queryClient.ensureQueryData(ApikeysQueryOptions()),
component: APISettings
});
export const SettingsReleasesRoute = createRoute({
getParentRoute: () => SettingsRoute,
path: 'releases',
component: ReleaseSettings
});
export const SettingsAccountRoute = createRoute({
getParentRoute: () => SettingsRoute,
path: 'account',
component: AccountSettings
});
export const LogsRoute = createRoute({
getParentRoute: () => AuthIndexRoute,
path: 'logs',
component: Logs
});
export const OnboardRoute = createRoute({
getParentRoute: () => RootRoute,
path: 'onboard',
beforeLoad: async () => {
// Check if onboarding is available for this instance
// and redirect if needed
try {
await APIClient.auth.canOnboard()
} catch (e) {
console.error("onboarding not available, redirect to login")
throw redirect({
to: LoginRoute.to,
})
}
},
component: Onboarding
});
export const LoginRoute = createRoute({
getParentRoute: () => RootRoute,
path: 'login',
validateSearch: z.object({
redirect: z.string().optional(),
}),
beforeLoad: ({ navigate}) => {
// handle canOnboard
APIClient.auth.canOnboard().then(() => {
console.info("onboarding available, redirecting")
navigate({ to: OnboardRoute.to })
}).catch(() => {
console.info("onboarding not available, please login")
})
},
}).update({component: Login});
export const AuthRoute = createRoute({
getParentRoute: () => RootRoute,
id: 'auth',
// Before loading, authenticate the user via our auth context
// This will also happen during prefetching (e.g. hovering over links, etc.)
beforeLoad: ({context, location}) => {
// If the user is not logged in, check for item in localStorage
if (!context.auth.isLoggedIn) {
const storage = localStorage.getItem(localStorageUserKey);
if (storage) {
try {
const json = JSON.parse(storage);
if (json === null) {
console.warn(`JSON localStorage value for '${localStorageUserKey}' context state is null`);
} else {
context.auth.isLoggedIn = json.isLoggedIn
context.auth.username = json.username
}
} catch (e) {
console.error(`auth Failed to merge ${localStorageUserKey} context state: ${e}`);
}
} else {
// If the user is logged out, redirect them to the login page
throw redirect({
to: LoginRoute.to,
search: {
// Use the current location to power a redirect after login
// (Do not use `router.state.resolvedLocation` as it can
// potentially lag behind the actual current location)
redirect: location.href,
},
})
}
}
// Otherwise, return the user in context
return {
username: AuthContext.username,
}
},
})
function AuthenticatedLayout() {
return (
<div className="min-h-screen">
<Header/>
<Outlet/>
</div>
)
}
export const AuthIndexRoute = createRoute({
getParentRoute: () => AuthRoute,
component: AuthenticatedLayout,
id: 'authenticated-routes',
});
export const RootComponent = () => {
const settings = SettingsContext.useValue();
return (
<div className="min-h-screen">
<Outlet/>
{settings.debug ? (
<>
<TanStackRouterDevtools/>
<ReactQueryDevtools initialIsOpen={false}/>
</>
) : null}
</div>
)
}
export const RootRoute = createRootRouteWithContext<{
auth: AuthCtx,
queryClient: QueryClient
}>()({
component: RootComponent,
notFoundComponent: NotFound,
});
const filterRouteTree = FiltersRoute.addChildren([FilterIndexRoute, FilterGetByIdRoute.addChildren([FilterGeneralRoute, FilterMoviesTvRoute, FilterMusicRoute, FilterAdvancedRoute, FilterExternalRoute, FilterActionsRoute])])
const settingsRouteTree = SettingsRoute.addChildren([SettingsIndexRoute, SettingsLogRoute, SettingsIndexersRoute, SettingsIrcRoute, SettingsFeedsRoute, SettingsClientsRoute, SettingsNotificationsRoute, SettingsApiRoute, SettingsReleasesRoute, SettingsAccountRoute])
const authenticatedTree = AuthRoute.addChildren([AuthIndexRoute.addChildren([DashboardRoute, filterRouteTree, ReleasesRoute.addChildren([ReleasesIndexRoute]), settingsRouteTree, LogsRoute])])
const routeTree = RootRoute.addChildren([
authenticatedTree,
LoginRoute,
OnboardRoute
]);
export const Router = createRouter({
routeTree,
defaultPendingComponent: () => (
<div className="absolute top-1/4 left-1/2 !border-0">
<RingResizeSpinner className="text-blue-500 size-24"/>
</div>
),
defaultErrorComponent: ({error}) => <ErrorComponent error={error}/>,
context: {
auth: undefined!, // We'll inject this when we render
queryClient
},
});

View file

@ -23,7 +23,6 @@ import { EmptySimple } from "@components/emptystates";
import { RingResizeSpinner } from "@components/Icons";
import Toast from "@components/notifications/Toast";
type LogEvent = {
time: string;
level: string;
@ -182,7 +181,7 @@ export const LogFiles = () => {
});
if (isError) {
console.log(error);
console.log("could not load log files", error);
}
return (
@ -194,7 +193,7 @@ export const LogFiles = () => {
</p>
</div>
{data && data.files.length > 0 ? (
{data && data.files && data.files.length > 0 ? (
<ul className="py-3 min-w-full relative">
<li className="grid grid-cols-12 mb-2 border-b border-gray-200 dark:border-gray-700">
<div className="hidden sm:block col-span-5 px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">

View file

@ -3,8 +3,6 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Suspense } from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import {
BellIcon,
ChatBubbleLeftRightIcon,
@ -16,25 +14,26 @@ import {
Square3Stack3DIcon,
UserCircleIcon
} from "@heroicons/react/24/outline";
import { Link, Outlet } from "@tanstack/react-router";
import { classNames } from "@utils";
import { SectionLoader } from "@components/SectionLoader";
interface NavTabType {
name: string;
href: string;
icon: typeof CogIcon;
exact?: boolean;
}
const subNavigation: NavTabType[] = [
{ name: "Application", href: "", icon: CogIcon },
{ name: "Application", href: ".", icon: CogIcon, exact: true },
{ name: "Logs", href: "logs", icon: Square3Stack3DIcon },
{ name: "Indexers", href: "indexers", icon: KeyIcon },
{ name: "IRC", href: "irc", icon: ChatBubbleLeftRightIcon },
{ name: "Feeds", href: "feeds", icon: RssIcon },
{ name: "Clients", href: "clients", icon: FolderArrowDownIcon },
{ name: "Notifications", href: "notifications", icon: BellIcon },
{ name: "API keys", href: "api-keys", icon: KeyIcon },
{ name: "API keys", href: "api", icon: KeyIcon },
{ name: "Releases", href: "releases", icon: RectangleStackIcon },
{ name: "Account", href: "account", icon: UserCircleIcon }
// {name: 'Regex Playground', href: 'regex-playground', icon: CogIcon, current: false}
@ -46,29 +45,38 @@ interface NavLinkProps {
}
function SubNavLink({ item }: NavLinkProps) {
const { pathname } = useLocation();
const splitLocation = pathname.split("/");
// const { pathname } = useLocation();
// const splitLocation = pathname.split("/");
// we need to clean the / if it's a base root path
return (
<NavLink
key={item.name}
<Link
key={item.href}
to={item.href}
end
className={({ isActive }) => classNames(
"transition group border-l-4 px-3 py-2 flex items-center text-sm font-medium",
isActive
? "font-bold bg-blue-100 dark:bg-gray-700 border-sky-500 dark:border-blue-500 text-sky-700 dark:text-gray-200 hover:bg-blue-200 dark:hover:bg-gray-600 hover:text-sky-900 dark:hover:text-white"
: "border-transparent text-gray-900 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 hover:text-gray-900 dark:hover:text-gray-300"
)}
aria-current={splitLocation[2] === item.href ? "page" : undefined}
activeOptions={{ exact: item.exact }}
search={{}}
params={{}}
// aria-current={splitLocation[2] === item.href ? "page" : undefined}
>
<item.icon
className="text-gray-500 dark:text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300 flex-shrink-0 -ml-1 mr-3 h-6 w-6"
aria-hidden="true"
/>
<span className="truncate">{item.name}</span>
</NavLink>
{({ isActive }) => {
return (
<span className={
classNames(
"transition group border-l-4 px-3 py-2 flex items-center text-sm font-medium",
isActive
? "font-bold bg-blue-100 dark:bg-gray-700 border-sky-500 dark:border-blue-500 text-sky-700 dark:text-gray-200 hover:bg-blue-200 dark:hover:bg-gray-600 hover:text-sky-900 dark:hover:text-white"
: "border-transparent text-gray-900 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 hover:text-gray-900 dark:hover:text-gray-300"
)
}>
<item.icon
className="text-gray-500 dark:text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300 flex-shrink-0 -ml-1 mr-3 h-6 w-6"
aria-hidden="true"
/>
<span className="truncate">{item.name}</span>
</span>
)
}}
</Link>
);
}
@ -78,10 +86,10 @@ interface SidebarNavProps {
function SidebarNav({ subNavigation }: SidebarNavProps) {
return (
<aside className="py-2 lg:col-span-3">
<aside className="py-2 lg:col-span-3 border-b lg:border-b-0 lg:border-r border-gray-150 dark:border-gray-725">
<nav className="space-y-1">
{subNavigation.map((item) => (
<SubNavLink item={item} key={item.href} />
<SubNavLink key={item.href} item={item} />
))}
</nav>
</aside>
@ -97,17 +105,9 @@ export function Settings() {
<div className="max-w-screen-xl mx-auto pb-6 px-2 sm:px-6 lg:pb-16 lg:px-8">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-table border border-gray-250 dark:border-gray-775">
<div className="divide-y divide-gray-150 dark:divide-gray-725 lg:grid lg:grid-cols-12 lg:divide-y-0 lg:divide-x">
<div className="lg:grid lg:grid-cols-12">
<SidebarNav subNavigation={subNavigation}/>
<Suspense
fallback={
<div className="flex items-center justify-center lg:col-span-9">
<SectionLoader $size="large" />
</div>
}
>
<Outlet />
</Suspense>
</div>
</div>
</div>

View file

@ -3,19 +3,19 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { useEffect } from "react";
import React, { useEffect } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { useMutation } from "@tanstack/react-query";
import { useRouter, useSearch } from "@tanstack/react-router";
import toast from "react-hot-toast";
import { RocketLaunchIcon } from "@heroicons/react/24/outline";
import { APIClient } from "@api/APIClient";
import { AuthContext } from "@utils/Context";
import Toast from "@components/notifications/Toast";
import { Tooltip } from "@components/tooltips/Tooltip";
import { PasswordInput, TextInput } from "@components/inputs/text";
import { LoginRoute } from "@app/routes";
import Logo from "@app/logo.svg?react";
@ -25,35 +25,25 @@ type LoginFormFields = {
};
export const Login = () => {
const router = useRouter()
const { auth } = LoginRoute.useRouteContext()
const search = useSearch({ from: LoginRoute.id })
const { handleSubmit, register, formState } = useForm<LoginFormFields>({
defaultValues: { username: "", password: "" },
mode: "onBlur"
});
const navigate = useNavigate();
const [, setAuthContext] = AuthContext.use();
useEffect(() => {
// remove user session when visiting login page'
APIClient.auth.logout()
.then(() => {
AuthContext.reset();
});
// Check if onboarding is available for this instance
// and redirect if needed
APIClient.auth.canOnboard()
.then(() => navigate("/onboard"))
.catch(() => { /*don't log to console PAHLLEEEASSSE*/ });
}, [navigate]);
// remove user session when visiting login page
auth.logout()
}, []);
const loginMutation = useMutation({
mutationFn: (data: LoginFormFields) => APIClient.auth.login(data.username, data.password),
onSuccess: (_, variables: LoginFormFields) => {
setAuthContext({
username: variables.username,
isLoggedIn: true
});
navigate("/");
auth.login(variables.username)
router.invalidate()
},
onError: () => {
toast.custom((t) => (
@ -64,6 +54,14 @@ export const Login = () => {
const onSubmit = (data: LoginFormFields) => loginMutation.mutate(data);
React.useLayoutEffect(() => {
if (auth.isLoggedIn && search.redirect) {
router.history.push(search.redirect)
} else if (auth.isLoggedIn) {
router.history.push("/")
}
}, [auth.isLoggedIn, search.redirect])
return (
<div className="min-h-screen flex flex-col justify-center px-3">
<div className="mx-auto w-full max-w-md mb-6">

View file

@ -5,7 +5,7 @@
import { Form, Formik } from "formik";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import {useNavigate} from "@tanstack/react-router";
import { APIClient } from "@api/APIClient";
import { TextField, PasswordField } from "@components/inputs";
@ -43,7 +43,7 @@ export const Onboarding = () => {
const mutation = useMutation({
mutationFn: (data: InputValues) => APIClient.auth.onboard(data.username, data.password1),
onSuccess: () => navigate("/")
onSuccess: () => navigate({ to: "/" })
});
return (

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import React, { useState } from "react";
import React, { Suspense, useState } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import {
useTable,
@ -12,13 +12,14 @@ import {
useSortBy,
usePagination, FilterProps, Column
} from "react-table";
import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/solid";
import { APIClient } from "@api/APIClient";
import { EmptyListState } from "@components/emptystates";
import * as Icons from "@components/Icons";
import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/solid";
import * as DataTable from "@components/data-table";
import { RandomLinuxIsos } from "@utils";
import { RingResizeSpinner } from "@components/Icons";
import { ReleasesLatestQueryOptions } from "@api/queries";
// This is a custom filter UI for selecting
// a unique option from a list
@ -80,8 +81,14 @@ function Table({ columns, data }: TableProps) {
usePagination
);
if (!page.length) {
return <EmptyListState text="No recent activity" />;
if (data.length === 0) {
return (
<div className="mt-4 mb-2 bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-table rounded-md overflow-auto">
<div className="flex items-center justify-center py-16">
<EmptyListState text="No recent activity"/>
</div>
</div>
)
}
// Render the UI for your table
@ -159,6 +166,28 @@ function Table({ columns, data }: TableProps) {
);
}
export const RecentActivityTable = () => {
return (
<div className="flex flex-col mt-12">
<h3 className="text-2xl font-medium leading-6 text-gray-900 dark:text-gray-200">
Recent activity
</h3>
<div className="animate-pulse text-black dark:text-white">
<Suspense
fallback={
<div className="flex items-center justify-center lg:col-span-9">
<RingResizeSpinner className="text-blue-500 size-12" />
</div>
}
>
{/*<EmptyListState text="Loading..."/>*/}
<ActivityTableContent />
</Suspense>
</div>
</div>
)
}
export const ActivityTable = () => {
const columns = React.useMemo(() => [
{
@ -185,11 +214,7 @@ export const ActivityTable = () => {
}
] as Column[], []);
const { isLoading, data } = useSuspenseQuery({
queryKey: ["dash_recent_releases"],
queryFn: APIClient.release.findRecent,
refetchOnWindowFocus: false
});
const { isLoading, data } = useSuspenseQuery(ReleasesLatestQueryOptions());
const [modifiedData, setModifiedData] = useState<Release[]>([]);
const [showLinuxIsos, setShowLinuxIsos] = useState(false);
@ -198,7 +223,7 @@ export const ActivityTable = () => {
return (
<div className="flex flex-col mt-12">
<h3 className="text-2xl font-medium leading-6 text-gray-900 dark:text-gray-200">
&nbsp;
Recent activity
</h3>
<div className="animate-pulse text-black dark:text-white">
<EmptyListState text="Loading..."/>
@ -245,3 +270,75 @@ export const ActivityTable = () => {
</div>
);
};
export const ActivityTableContent = () => {
const columns = React.useMemo(() => [
{
Header: "Age",
accessor: "timestamp",
Cell: DataTable.AgeCell
},
{
Header: "Release",
accessor: "name",
Cell: DataTable.TitleCell
},
{
Header: "Actions",
accessor: "action_status",
Cell: DataTable.ReleaseStatusCell
},
{
Header: "Indexer",
accessor: "indexer",
Cell: DataTable.TitleCell,
Filter: SelectColumnFilter,
filter: "includes"
}
] as Column[], []);
const { isLoading, data } = useSuspenseQuery(ReleasesLatestQueryOptions());
const [modifiedData, setModifiedData] = useState<Release[]>([]);
const [showLinuxIsos, setShowLinuxIsos] = useState(false);
if (isLoading) {
return (
<EmptyListState text="Loading..."/>
);
}
const toggleReleaseNames = () => {
setShowLinuxIsos(!showLinuxIsos);
if (!showLinuxIsos && data && data.data) {
const randomNames = RandomLinuxIsos(data.data.length);
const newData: Release[] = data.data.map((item, index) => ({
...item,
name: `${randomNames[index]}.iso`,
indexer: index % 2 === 0 ? "distrowatch" : "linuxtracker"
}));
setModifiedData(newData);
}
};
const displayData = showLinuxIsos ? modifiedData : (data?.data ?? []);
return (
<>
<Table columns={columns} data={displayData} />
<button
onClick={toggleReleaseNames}
className="p-2 absolute -bottom-8 right-0 bg-gray-750 text-white rounded-full opacity-10 hover:opacity-100 transition-opacity duration-300"
aria-label="Toggle view"
title="Go incognito"
>
{showLinuxIsos ? (
<EyeIcon className="h-4 w-4" />
) : (
<EyeSlashIcon className="h-4 w-4" />
)}
</button>
</>
);
};

View file

@ -3,23 +3,28 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { useSuspenseQuery } from "@tanstack/react-query";
import { APIClient } from "@api/APIClient";
import { useQuery} from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { classNames } from "@utils";
import { useNavigate } from "react-router-dom";
import { LinkIcon } from "@heroicons/react/24/solid";
import { ReleasesStatsQueryOptions } from "@api/queries";
interface StatsItemProps {
name: string;
value?: number;
placeholder?: string;
onClick?: () => void;
name: string;
value?: number;
placeholder?: string;
to?: string;
eventType?: string;
}
const StatsItem = ({ name, placeholder, value, onClick }: StatsItemProps) => (
<div
const StatsItem = ({ name, placeholder, value, to, eventType }: StatsItemProps) => (
<Link
className="group relative px-4 py-3 cursor-pointer overflow-hidden rounded-lg shadow-lg bg-white dark:bg-gray-800 hover:scale-110 hover:shadow-xl transition-all duration-200 ease-in-out"
onClick={onClick}
to={to}
search={{
action_status: eventType
}}
params={{}}
>
<dt>
<div className="flex items-center text-sm font-medium text-gray-500 group-hover:dark:text-gray-475 group-hover:text-gray-600 transition-colors duration-200 ease-in-out">
@ -36,24 +41,11 @@ const StatsItem = ({ name, placeholder, value, onClick }: StatsItemProps) => (
<p>{value}</p>
</dd>
</div>
</div>
</Link>
);
export const Stats = () => {
const navigate = useNavigate();
const handleStatClick = (filterType: string) => {
if (filterType) {
navigate(`/releases?filter=${filterType}`);
} else {
navigate("/releases");
}
};
const { isLoading, data } = useSuspenseQuery({
queryKey: ["dash_release_stats"],
queryFn: APIClient.release.stats,
refetchOnWindowFocus: false
});
const { isLoading, data } = useQuery(ReleasesStatsQueryOptions());
return (
<div>
@ -62,11 +54,11 @@ export const Stats = () => {
</h1>
<dl className={classNames("grid grid-cols-2 gap-2 sm:gap-5 mt-5 sm:grid-cols-2 lg:grid-cols-4", isLoading ? "animate-pulse" : "")}>
<StatsItem name="Filtered Releases" onClick={() => handleStatClick("")} value={data?.filtered_count ?? 0} />
<StatsItem name="Filtered Releases" to="/releases" value={data?.filtered_count ?? 0} />
{/* <StatsItem name="Filter Rejected Releases" stat={data?.filter_rejected_count} /> */}
<StatsItem name="Approved Pushes" onClick={() => handleStatClick("PUSH_APPROVED")} value={data?.push_approved_count ?? 0} />
<StatsItem name="Rejected Pushes" onClick={() => handleStatClick("PUSH_REJECTED")} value={data?.push_rejected_count ?? 0 } />
<StatsItem name="Errored Pushes" onClick={() => handleStatClick("PUSH_ERROR")} value={data?.push_error_count ?? 0} />
<StatsItem name="Approved Pushes" to="/releases" eventType="PUSH_APPROVED" value={data?.push_approved_count ?? 0} />
<StatsItem name="Rejected Pushes" to="/releases" eventType="PUSH_REJECTED" value={data?.push_rejected_count ?? 0 } />
<StatsItem name="Errored Pushes" to="/releases" eventType="PUSH_ERROR" value={data?.push_error_count ?? 0} />
</dl>
</div>
);

View file

@ -3,17 +3,18 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Suspense, useEffect, useRef } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { Form, Formik, useFormikContext } from "formik";
import type { FormikErrors, FormikValues } from "formik";
import { z } from "zod";
import { toast } from "react-hot-toast";
import { toFormikValidationSchema } from "zod-formik-adapter";
import { ChevronRightIcon } from "@heroicons/react/24/solid";
import { NavLink, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
import { APIClient } from "@api/APIClient";
import { FilterByIdQueryOptions } from "@api/queries";
import { FilterKeys } from "@api/query_keys";
import { useToggle } from "@hooks/hooks";
import { classNames } from "@utils";
import { DOWNLOAD_CLIENTS } from "@domain/constants";
@ -21,18 +22,18 @@ import { DOWNLOAD_CLIENTS } from "@domain/constants";
import { DEBUG } from "@components/debug";
import Toast from "@components/notifications/Toast";
import { DeleteModal } from "@components/modals";
import { SectionLoader } from "@components/SectionLoader";
import { filterKeys } from "./List";
import * as Section from "./sections";
import { Link, Outlet, useNavigate } from "@tanstack/react-router";
import { FilterGetByIdRoute } from "@app/routes";
interface tabType {
name: string;
href: string;
exact?: boolean;
}
const tabs: tabType[] = [
{ name: "General", href: "" },
{ name: "General", href: ".", exact: true },
{ name: "Movies and TV", href: "movies-tv" },
{ name: "Music", href: "music" },
{ name: "Advanced", href: "advanced" },
@ -45,25 +46,35 @@ export interface NavLinkProps {
}
function TabNavLink({ item }: NavLinkProps) {
const location = useLocation();
const splitLocation = location.pathname.split("/");
// const location = useLocation();
// const splitLocation = location.pathname.split("/");
// we need to clean the / if it's a base root path
return (
<NavLink
key={item.name}
<Link
to={item.href}
end
className={({ isActive }) => classNames(
"transition border-b-2 whitespace-nowrap py-4 duration-3000 px-1 font-medium text-sm first:rounded-tl-lg last:rounded-tr-lg",
isActive
? "text-blue-600 dark:text-white border-blue-600 dark:border-blue-500"
: "text-gray-550 hover:text-blue-500 dark:hover:text-white border-transparent"
)}
aria-current={splitLocation[2] === item.href ? "page" : undefined}
activeOptions={{ exact: item.exact }}
search={{}}
params={{}}
// aria-current={splitLocation[2] === item.href ? "page" : undefined}
// className="transition border-b-2 whitespace-nowrap py-4 duration-3000 px-1 font-medium text-sm first:rounded-tl-lg last:rounded-tr-lg"
>
{item.name}
</NavLink>
{({ isActive }) => {
return (
<span
className={
classNames(
"transition border-b-2 whitespace-nowrap py-4 duration-3000 px-1 font-medium text-sm first:rounded-tl-lg last:rounded-tr-lg",
isActive
? "text-blue-600 dark:text-white border-blue-600 dark:border-blue-500"
: "text-gray-550 hover:text-blue-500 dark:hover:text-white border-transparent"
)
}>
{item.name}
</span>
)
}}
</Link>
);
}
@ -281,32 +292,20 @@ const schema = z.object({
});
export const FilterDetails = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { filterId } = useParams<{ filterId: string }>();
const ctx = FilterGetByIdRoute.useRouteContext()
const queryClient = ctx.queryClient
if (filterId === "0" || filterId === undefined) {
navigate("/filters");
}
const id = parseInt(filterId!);
const { isLoading, isError, data: filter } = useSuspenseQuery({
queryKey: filterKeys.detail(id),
queryFn: ({ queryKey }) => APIClient.filters.getByID(queryKey[2]),
refetchOnWindowFocus: false
});
if (isError) {
navigate("/filters");
}
const params = FilterGetByIdRoute.useParams()
const filterQuery = useSuspenseQuery(FilterByIdQueryOptions(params.filterId))
const filter = filterQuery.data
const updateMutation = useMutation({
mutationFn: (filter: Filter) => APIClient.filters.update(filter),
onSuccess: (newFilter, variables) => {
queryClient.setQueryData(filterKeys.detail(variables.id), newFilter);
queryClient.setQueryData(FilterKeys.detail(variables.id), newFilter);
queryClient.setQueryData<Filter[]>(filterKeys.lists(), (previous) => {
queryClient.setQueryData<Filter[]>(FilterKeys.lists(), (previous) => {
if (previous) {
return previous.map((filter: Filter) => (filter.id === variables.id ? newFilter : filter));
}
@ -322,22 +321,18 @@ export const FilterDetails = () => {
mutationFn: (id: number) => APIClient.filters.delete(id),
onSuccess: () => {
// Invalidate filters just in case, most likely not necessary but can't hurt.
queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
queryClient.invalidateQueries({ queryKey: filterKeys.detail(id) });
queryClient.invalidateQueries({ queryKey: FilterKeys.lists() });
queryClient.removeQueries({ queryKey: FilterKeys.detail(params.filterId) });
toast.custom((t) => (
<Toast type="success" body={`${filter?.name} was deleted`} t={t} />
));
// redirect
navigate("/filters");
navigate({ to: "/filters" });
}
});
if (!filter) {
return null;
}
const handleSubmit = (data: Filter) => {
// force set method and type on webhook actions
// TODO add options for these
@ -362,9 +357,9 @@ export const FilterDetails = () => {
<main>
<div className="my-6 max-w-screen-xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center text-black dark:text-white">
<h1 className="text-3xl font-bold">
<NavLink to="/filters">
<Link to="/filters">
Filters
</NavLink>
</Link>
</h1>
<ChevronRightIcon className="h-6 w-4 shrink-0 sm:shrink sm:h-6 sm:w-6 mx-1" aria-hidden="true" />
<h1 className="text-3xl font-bold truncate" title={filter.name}>{filter.name}</h1>
@ -372,9 +367,9 @@ export const FilterDetails = () => {
<div className="max-w-screen-xl mx-auto pb-12 px-2 sm:px-6 lg:px-8">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-250 dark:border-gray-775">
<div className="rounded-t-lg bg-gray-125 dark:bg-gray-850 border-b border-gray-200 dark:border-gray-750">
<nav className="px-4 -mb-px flex space-x-6 sm:space-x-8 overflow-x-auto">
<nav className="px-4 py-4 -mb-px flex space-x-6 sm:space-x-8 overflow-x-auto">
{tabs.map((tab) => (
<TabNavLink item={tab} key={tab.href} />
<TabNavLink key={tab.href} item={tab} />
))}
</nav>
</div>
@ -452,22 +447,13 @@ export const FilterDetails = () => {
{({ values, dirty, resetForm }) => (
<Form className="pt-1 pb-4 px-5">
<FormErrorNotification />
<Suspense fallback={<SectionLoader $size="large" />}>
<Routes>
<Route index element={<Section.General />} />
<Route path="movies-tv" element={<Section.MoviesTv />} />
<Route path="music" element={<Section.Music values={values} />} />
<Route path="advanced" element={<Section.Advanced values={values} />} />
<Route path="external" element={<Section.External />} />
<Route path="actions" element={<Section.Actions filter={filter} values={values} />} />
</Routes>
</Suspense>
<Outlet />
<FormButtonsGroup
values={values}
deleteAction={deleteAction}
dirty={dirty}
reset={resetForm}
isLoading={isLoading}
isLoading={false}
/>
<DEBUG values={values} />
</Form>

View file

@ -4,9 +4,9 @@ import { useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { APIClient } from "@api/APIClient";
import { FilterKeys } from "@api/query_keys";
import Toast from "@components/notifications/Toast";
import { filterKeys } from "./List";
import { AutodlIrssiConfigParser } from "./_configParser";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
@ -211,7 +211,7 @@ export const Importer = ({
} finally {
setIsOpen(false);
// Invalidate filter cache, and trigger refresh request
await queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
await queryClient.invalidateQueries({ queryKey: FilterKeys.lists() });
}
};

View file

@ -3,24 +3,23 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Dispatch, FC, Fragment, MouseEventHandler, useReducer, useRef, useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { Dispatch, FC, Fragment, MouseEventHandler, useCallback, useEffect, useReducer, useRef, useState } from "react";
import { Link } from '@tanstack/react-router'
import { toast } from "react-hot-toast";
import { Listbox, Menu, Transition } from "@headlessui/react";
import { useMutation, useQuery, useQueryClient, keepPreviousData, useSuspenseQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FormikValues } from "formik";
import { useCallback } from "react";
import {
ArrowsRightLeftIcon,
ArrowUpOnSquareIcon,
ChatBubbleBottomCenterTextIcon,
CheckIcon,
ChevronDownIcon,
PlusIcon,
DocumentDuplicateIcon,
EllipsisHorizontalIcon,
PencilSquareIcon,
ChatBubbleBottomCenterTextIcon,
TrashIcon,
ArrowUpOnSquareIcon
PlusIcon,
TrashIcon
} from "@heroicons/react/24/outline";
import { ArrowDownTrayIcon } from "@heroicons/react/24/solid";
@ -29,6 +28,8 @@ import { classNames } from "@utils";
import { FilterAddForm } from "@forms";
import { useToggle } from "@hooks/hooks";
import { APIClient } from "@api/APIClient";
import { FilterKeys } from "@api/query_keys";
import { FiltersQueryOptions, IndexersOptionsQueryOptions } from "@api/queries";
import Toast from "@components/notifications/Toast";
import { EmptyListState } from "@components/emptystates";
import { DeleteModal } from "@components/modals";
@ -37,14 +38,6 @@ import { Importer } from "./Importer";
import { Tooltip } from "@components/tooltips/Tooltip";
import { Checkbox } from "@components/Checkbox";
export const filterKeys = {
all: ["filters"] as const,
lists: () => [...filterKeys.all, "list"] as const,
list: (indexers: string[], sortOrder: string) => [...filterKeys.lists(), { indexers, sortOrder }] as const,
details: () => [...filterKeys.all, "detail"] as const,
detail: (id: number) => [...filterKeys.details(), id] as const
};
enum ActionType {
INDEXER_FILTER_CHANGE = "INDEXER_FILTER_CHANGE",
INDEXER_FILTER_RESET = "INDEXER_FILTER_RESET",
@ -192,11 +185,7 @@ function FilterList({ toggleCreateFilter }: any) {
filterListState
);
const { data, error } = useSuspenseQuery({
queryKey: filterKeys.list(indexerFilter, sortOrder),
queryFn: ({ queryKey }) => APIClient.filters.find(queryKey[2].indexers, queryKey[2].sortOrder),
refetchOnWindowFocus: false
});
const { data, error } = useQuery(FiltersQueryOptions(indexerFilter, sortOrder));
useEffect(() => {
FilterListContext.set({ indexerFilter, sortOrder, status });
@ -407,8 +396,8 @@ const FilterItemDropdown = ({ filter, onToggle }: FilterItemDropdownProps) => {
const deleteMutation = useMutation({
mutationFn: (id: number) => APIClient.filters.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
queryClient.invalidateQueries({ queryKey: filterKeys.detail(filter.id) });
queryClient.invalidateQueries({ queryKey: FilterKeys.lists() });
queryClient.invalidateQueries({ queryKey: FilterKeys.detail(filter.id) });
toast.custom((t) => <Toast type="success" body={`Filter ${filter?.name} was deleted`} t={t} />);
}
@ -417,7 +406,7 @@ const FilterItemDropdown = ({ filter, onToggle }: FilterItemDropdownProps) => {
const duplicateMutation = useMutation({
mutationFn: (id: number) => APIClient.filters.duplicate(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
queryClient.invalidateQueries({ queryKey: FilterKeys.lists() });
toast.custom((t) => <Toast type="success" body={`Filter ${filter?.name} duplicated`} t={t} />);
}
@ -459,7 +448,11 @@ const FilterItemDropdown = ({ filter, onToggle }: FilterItemDropdownProps) => {
<Menu.Item>
{({ active }) => (
<Link
to={filter.id.toString()}
// to={filter.id.toString()}
to="/filters/$filterId"
params={{
filterId: filter.id
}}
className={classNames(
active ? "bg-blue-600 text-white" : "text-gray-900 dark:text-gray-300",
"font-medium group flex rounded-md items-center w-full px-2 py-2 text-sm"
@ -600,8 +593,8 @@ function FilterListItem({ filter, values, idx }: FilterListItemProps) {
// We need to invalidate both keys here.
// The filters key is used on the /filters page,
// while the ["filter", filter.id] key is used on the details page.
queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
queryClient.invalidateQueries({ queryKey: filterKeys.detail(filter.id) });
queryClient.invalidateQueries({ queryKey: FilterKeys.lists() });
queryClient.invalidateQueries({ queryKey: FilterKeys.detail(filter.id) });
}
});
@ -629,7 +622,10 @@ function FilterListItem({ filter, values, idx }: FilterListItemProps) {
</span>
<div className="py-2 flex flex-col overflow-hidden w-full justify-center">
<Link
to={filter.id.toString()}
to="/filters/$filterId"
params={{
filterId: filter.id
}}
className="transition w-full break-words whitespace-wrap text-sm font-bold text-gray-800 dark:text-gray-100 hover:text-black dark:hover:text-gray-350"
>
{filter.name}
@ -645,7 +641,10 @@ function FilterListItem({ filter, values, idx }: FilterListItemProps) {
<Tooltip
label={
<Link
to={`${filter.id.toString()}/actions`}
to="/filters/$filterId/actions"
params={{
filterId: filter.id
}}
className="flex items-center cursor-pointer hover:text-black dark:hover:text-gray-300"
>
<span className={filter.actions_count === 0 || filter.actions_enabled_count === 0 ? "text-red-500 hover:text-red-400 dark:hover:text-red-400" : ""}>
@ -666,7 +665,10 @@ function FilterListItem({ filter, values, idx }: FilterListItemProps) {
</Tooltip>
) : (
<Link
to={`${filter.id.toString()}/actions`}
to="/filters/$filterId/actions"
params={{
filterId: filter.id
}}
className="flex items-center cursor-pointer hover:text-black dark:hover:text-gray-300"
>
<span>
@ -784,12 +786,9 @@ const ListboxFilter = ({
// a unique option from a list
const IndexerSelectFilter = ({ dispatch }: any) => {
const { data, isSuccess } = useQuery({
queryKey: ["filters", "indexers_options"],
queryFn: () => APIClient.indexers.getOptions(),
placeholderData: keepPreviousData,
staleTime: Infinity
});
const filterListState = FilterListContext.useValue();
const { data, isSuccess } = useQuery(IndexersOptionsQueryOptions());
const setFilter = (value: string) => {
if (value == undefined || value == "") {
@ -804,11 +803,11 @@ const IndexerSelectFilter = ({ dispatch }: any) => {
<ListboxFilter
id="1"
key="indexer-select"
label="Indexer"
currentValue={""}
label={data && filterListState.indexerFilter[0] ? `Indexer: ${data.find(i => i.identifier == filterListState.indexerFilter[0])?.name}` : "Indexer"}
currentValue={filterListState.indexerFilter[0] ?? ""}
onChange={setFilter}
>
<FilterOption label="All" />
<FilterOption label="All" value="" />
{isSuccess && data?.map((indexer, idx) => (
<FilterOption key={idx} label={indexer.name} value={indexer.identifier} />
))}
@ -830,7 +829,7 @@ const FilterOption = ({ label, value }: FilterOptionProps) => (
value={value}
>
{({ selected }) => (
<>
<div className="flex justify-between">
<span
className={classNames(
"block truncate",
@ -840,16 +839,18 @@ const FilterOption = ({ label, value }: FilterOptionProps) => (
{label}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500 dark:text-gray-400">
<span className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 dark:text-gray-400">
<CheckIcon className="w-5 h-5" aria-hidden="true" />
</span>
) : null}
</>
</div>
)}
</Listbox.Option>
);
export const SortSelectFilter = ({ dispatch }: any) => {
const filterListState = FilterListContext.useValue();
const setFilter = (value: string) => {
if (value == undefined || value == "") {
dispatch({ type: ActionType.SORT_ORDER_RESET, payload: "" });
@ -870,8 +871,8 @@ export const SortSelectFilter = ({ dispatch }: any) => {
<ListboxFilter
id="sort"
key="sort-select"
label="Sort"
currentValue={""}
label={filterListState.sortOrder ? `Sort: ${options.find(o => o.value == filterListState.sortOrder)?.label}` : "Sort"}
currentValue={filterListState.sortOrder ?? ""}
onChange={setFilter}
>
<>

View file

@ -0,0 +1,61 @@
/*
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Link } from "@tanstack/react-router";
import { FilterGetByIdRoute } from "@app/routes";
import { ExternalLink } from "@components/ExternalLink";
import Logo from "@app/logo.svg?react";
export const FilterNotFound = () => {
const { filterId } = FilterGetByIdRoute.useParams()
return (
<div className="mt-20 flex flex-col justify-center">
<div className="flex justify-center">
<Logo className="h-24 sm:h-48"/>
</div>
<h2 className="text-2xl text-center font-bold text-gray-900 dark:text-gray-200 my-8 px-2">
Status 404
</h2>
<h1 className="text-3xl text-center font-bold text-gray-900 dark:text-gray-200 my-8 px-2">
Filter with id <span className="text-blue-600 dark:text-blue-500">{filterId}</span> not found!
</h1>
<h3 className="text-xl text-center text-gray-700 dark:text-gray-400 mb-1 px-2">
In case you think this is a bug rather than too much brr,
</h3>
<h3 className="text-xl text-center text-gray-700 dark:text-gray-400 mb-1 px-2">
feel free to report this to our
{" "}
<ExternalLink
href="https://github.com/autobrr/autobrr"
className="text-gray-700 dark:text-gray-200 underline font-semibold underline-offset-2 decoration-sky-500 hover:decoration-2 hover:text-black hover:dark:text-gray-100"
>
GitHub page
</ExternalLink>
{" or to "}
<ExternalLink
href="https://discord.gg/WQ2eUycxyT"
className="text-gray-700 dark:text-gray-200 underline font-semibold underline-offset-2 decoration-purple-500 hover:decoration-2 hover:text-black hover:dark:text-gray-100"
>
our official Discord channel
</ExternalLink>
.
</h3>
<h3 className="text-xl text-center leading-6 text-gray-700 dark:text-gray-400 mb-8 px-2">
Otherwise, let us help you to get you back on track for more brr!
</h3>
<div className="flex justify-center">
<Link to="/filters">
<button
className="w-48 flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
>
Back to filters
</button>
</Link>
</div>
</div>
);
};

View file

@ -5,3 +5,4 @@
export { Filters } from "./List";
export { FilterDetails } from "./Details";
export { FilterNotFound } from "./NotFound";

View file

@ -7,7 +7,7 @@ import { useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Field, FieldArray, useFormikContext } from "formik";
import type { FieldProps, FieldArrayRenderProps, FormikValues } from "formik";
import type { FieldProps, FieldArrayRenderProps } from "formik";
import { ChevronRightIcon, BoltIcon } from "@heroicons/react/24/solid";
import { classNames } from "@utils";
@ -25,18 +25,17 @@ import { TitleSubtitle } from "@components/headings";
import * as FilterSection from "./_components";
import * as FilterActions from "./action_components";
import { DownloadClientsQueryOptions } from "@api/queries";
interface FilterActionsProps {
filter: Filter;
values: FormikValues;
}
// interface FilterActionsProps {
// filter: Filter;
// values: FormikValues;
// }
export function Actions({ filter, values }: FilterActionsProps) {
const { data } = useQuery({
queryKey: ["filters", "download_clients"],
queryFn: () => APIClient.download_clients.getAll(),
refetchOnWindowFocus: false
});
export function Actions() {
const { values } = useFormikContext<Filter>();
const { data } = useQuery(DownloadClientsQueryOptions());
const newAction: Action = {
id: 0,
@ -63,7 +62,7 @@ export function Actions({ filter, values }: FilterActionsProps) {
reannounce_delete: false,
reannounce_interval: 7,
reannounce_max_attempts: 25,
filter_id: filter.id,
filter_id: values.id,
webhook_host: "",
webhook_type: "",
webhook_method: "",

View file

@ -1,4 +1,4 @@
import type { FormikValues } from "formik";
import { useFormikContext } from "formik";
import { DocsLink } from "@components/ExternalLink";
import { WarningAlert } from "@components/alerts";
@ -10,493 +10,533 @@ import { CollapsibleSection } from "./_components";
import * as Components from "./_components";
import { classNames } from "@utils";
type ValueConsumer = {
values: FormikValues;
};
// type ValueConsumer = {
// values: FormikValues;
// };
const Releases = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={values.use_regex || values.match_releases || values.except_releases}
title="Release Names"
subtitle="Match only certain release names and/or ignore other release names."
>
<Components.Layout>
<Components.HalfRow>
<Input.SwitchGroup name="use_regex" label="Use Regex" className="pt-2" />
</Components.HalfRow>
</Components.Layout>
const Releases = () => {
const { values } = useFormikContext<Filter>();
<Components.Layout>
<Components.HalfRow>
<Input.RegexTextAreaField
name="match_releases"
label="Match releases"
useRegex={values.use_regex}
columns={6}
placeholder="eg. *some?movie*,*some?show*s01*"
return (
<CollapsibleSection
//defaultOpen={values.use_regex || values.match_releases !== "" || values.except_releases !== ""}
title="Release Names"
subtitle="Match only certain release names and/or ignore other release names."
>
<Components.Layout>
<Components.HalfRow>
<Input.SwitchGroup name="use_regex" label="Use Regex" className="pt-2" />
</Components.HalfRow>
</Components.Layout>
<Components.Layout>
<Components.HalfRow>
<Input.RegexTextAreaField
name="match_releases"
label="Match releases"
useRegex={values.use_regex}
columns={6}
placeholder="eg. *some?movie*,*some?show*s01*"
tooltip={
<div>
<p>This field has full regex support (Golang flavour).</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
<br />
<br />
<p>Remember to tick <b>Use Regex</b> if using more than <code>*</code> and <code>?</code>.</p>
</div>
}
/>
</Components.HalfRow>
<Components.HalfRow>
<Input.RegexTextAreaField
name="except_releases"
label="Except releases"
useRegex={values.use_regex}
columns={6}
placeholder="eg. *bad?movie*,*bad?show*s03*"
tooltip={
<div>
<p>This field has full regex support (Golang flavour).</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
<br />
<br />
<p>Remember to tick <b>Use Regex</b> below if using more than <code>*</code> and <code>?</code>.</p>
</div>
}
/>
</Components.HalfRow>
</Components.Layout>
{values.match_releases ? (
<WarningAlert
alert="Ask yourself:"
text={
<>
Do you have a good reason to use <strong>Match releases</strong> instead of one of the other tabs?
</>
}
colors="text-cyan-700 bg-cyan-100 dark:bg-cyan-200 dark:text-cyan-800"
/>
) : null}
{values.except_releases ? (
<WarningAlert
alert="Ask yourself:"
text={
<>
Do you have a good reason to use <strong>Except releases</strong> instead of one of the other tabs?
</>
}
colors="text-fuchsia-700 bg-fuchsia-100 dark:bg-fuchsia-200 dark:text-fuchsia-800"
/>
) : null}
</CollapsibleSection>
);
}
const Groups = () => {
// const { values } = useFormikContext<Filter>();
return (
<CollapsibleSection
//defaultOpen={values.match_release_groups !== "" || values.except_release_groups !== ""}
title="Groups"
subtitle="Match only certain groups and/or ignore other groups."
>
<Input.TextAreaAutoResize
name="match_release_groups"
label="Match release groups"
columns={6}
placeholder="eg. group1,group2"
tooltip={
<div>
<p>Comma separated list of release groups to match.</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
<Input.TextAreaAutoResize
name="except_release_groups"
label="Except release groups"
columns={6}
placeholder="eg. badgroup1,badgroup2"
tooltip={
<div>
<p>Comma separated list of release groups to ignore (takes priority over Match releases).</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
</CollapsibleSection>
);
}
const Categories = () => {
// const { values } = useFormikContext<Filter>();
return (
<CollapsibleSection
//defaultOpen={values.match_categories.length >0 || values.except_categories !== ""}
title="Categories"
subtitle="Match or exclude categories (if announced)"
>
<Input.TextAreaAutoResize
name="match_categories"
label="Match categories"
columns={6}
placeholder="eg. *category*,category1"
tooltip={
<div>
<p>Comma separated list of categories to match.</p>
<DocsLink href="https://autobrr.com/filters/categories" />
</div>
}
/>
<Input.TextAreaAutoResize
name="except_categories"
label="Except categories"
columns={6}
placeholder="eg. *category*"
tooltip={
<div>
<p>Comma separated list of categories to ignore (takes priority over Match releases).</p>
<DocsLink href="https://autobrr.com/filters/categories" />
</div>
}
/>
</CollapsibleSection>
);
}
const Tags = () => {
// const { values } = useFormikContext<Filter>();
return (
<CollapsibleSection
//defaultOpen={values.tags !== "" || values.except_tags !== ""}
title="Tags"
subtitle="Match or exclude tags (if announced)"
>
<div className={classNames("sm:col-span-6", Components.LayoutClass, Components.TightGridGapClass)}>
<Input.TextAreaAutoResize
name="tags"
label="Match tags"
columns={8}
placeholder="eg. tag1,tag2"
tooltip={
<div>
<p>This field has full regex support (Golang flavour).</p>
<p>Comma separated list of tags to match.</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
<br />
<br />
<p>Remember to tick <b>Use Regex</b> if using more than <code>*</code> and <code>?</code>.</p>
</div>
}
/>
</Components.HalfRow>
<Components.HalfRow>
<Input.RegexTextAreaField
name="except_releases"
label="Except releases"
useRegex={values.use_regex}
columns={6}
placeholder="eg. *bad?movie*,*bad?show*s03*"
<Input.Select
name="tags_match_logic"
label="Match logic"
columns={4}
options={CONSTS.tagsMatchLogicOptions}
optionDefaultText="any"
tooltip={
<div>
<p>This field has full regex support (Golang flavour).</p>
<p>Logic used to match filter tags.</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
<br />
<br />
<p>Remember to tick <b>Use Regex</b> below if using more than <code>*</code> and <code>?</code>.</p>
</div>
}
/>
</Components.HalfRow>
</div>
<div className={classNames("sm:col-span-6", Components.LayoutClass, Components.TightGridGapClass)}>
<Input.TextAreaAutoResize
name="except_tags"
label="Except tags"
columns={8}
placeholder="eg. tag1,tag2"
tooltip={
<div>
<p>Comma separated list of tags to ignore (takes priority over Match releases).</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
<Input.Select
name="except_tags_match_logic"
label="Except logic"
columns={4}
options={CONSTS.tagsMatchLogicOptions}
optionDefaultText="any"
tooltip={
<div>
<p>Logic used to match except tags.</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
</div>
</CollapsibleSection>
);
}
</Components.Layout>
const Uploaders = () => {
// const { values } = useFormikContext<Filter>();
{values.match_releases ? (
<WarningAlert
alert="Ask yourself:"
text={
<>
Do you have a good reason to use <strong>Match releases</strong> instead of one of the other tabs?
</>
}
colors="text-cyan-700 bg-cyan-100 dark:bg-cyan-200 dark:text-cyan-800"
/>
) : null}
{values.except_releases ? (
<WarningAlert
alert="Ask yourself:"
text={
<>
Do you have a good reason to use <strong>Except releases</strong> instead of one of the other tabs?
</>
}
colors="text-fuchsia-700 bg-fuchsia-100 dark:bg-fuchsia-200 dark:text-fuchsia-800"
/>
) : null}
</CollapsibleSection>
);
const Groups = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={values.match_release_groups || values.except_release_groups}
title="Groups"
subtitle="Match only certain groups and/or ignore other groups."
>
<Input.TextAreaAutoResize
name="match_release_groups"
label="Match release groups"
columns={6}
placeholder="eg. group1,group2"
tooltip={
<div>
<p>Comma separated list of release groups to match.</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
<Input.TextAreaAutoResize
name="except_release_groups"
label="Except release groups"
columns={6}
placeholder="eg. badgroup1,badgroup2"
tooltip={
<div>
<p>Comma separated list of release groups to ignore (takes priority over Match releases).</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
</CollapsibleSection>
);
const Categories = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={values.match_categories || values.except_categories}
title="Categories"
subtitle="Match or exclude categories (if announced)"
>
<Input.TextAreaAutoResize
name="match_categories"
label="Match categories"
columns={6}
placeholder="eg. *category*,category1"
tooltip={
<div>
<p>Comma separated list of categories to match.</p>
<DocsLink href="https://autobrr.com/filters/categories" />
</div>
}
/>
<Input.TextAreaAutoResize
name="except_categories"
label="Except categories"
columns={6}
placeholder="eg. *category*"
tooltip={
<div>
<p>Comma separated list of categories to ignore (takes priority over Match releases).</p>
<DocsLink href="https://autobrr.com/filters/categories" />
</div>
}
/>
</CollapsibleSection>
);
const Tags = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={values.tags || values.except_tags}
title="Tags"
subtitle="Match or exclude tags (if announced)"
>
<div className={classNames("sm:col-span-6", Components.LayoutClass, Components.TightGridGapClass)}>
return (
<CollapsibleSection
//defaultOpen={values.match_uploaders !== "" || values.except_uploaders !== ""}
title="Uploaders"
subtitle="Match or ignore uploaders (if announced)"
>
<Input.TextAreaAutoResize
name="tags"
label="Match tags"
columns={8}
placeholder="eg. tag1,tag2"
name="match_uploaders"
label="Match uploaders"
columns={6}
placeholder="eg. uploader1,uploader2"
tooltip={
<div>
<p>Comma separated list of tags to match.</p>
<p>Comma separated list of uploaders to match.</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
<Input.Select
name="tags_match_logic"
label="Match logic"
columns={4}
options={CONSTS.tagsMatchLogicOptions}
optionDefaultText="any"
tooltip={
<div>
<p>Logic used to match filter tags.</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
</div>
<div className={classNames("sm:col-span-6", Components.LayoutClass, Components.TightGridGapClass)}>
<Input.TextAreaAutoResize
name="except_tags"
label="Except tags"
columns={8}
placeholder="eg. tag1,tag2"
name="except_uploaders"
label="Except uploaders"
columns={6}
placeholder="eg. anonymous1,anonymous2"
tooltip={
<div>
<p>Comma separated list of tags to ignore (takes priority over Match releases).</p>
<p>Comma separated list of uploaders to ignore (takes priority over Match releases).
</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
<Input.Select
name="except_tags_match_logic"
label="Except logic"
columns={4}
options={CONSTS.tagsMatchLogicOptions}
optionDefaultText="any"
</CollapsibleSection>
);
}
const Language = () => {
// const { values } = useFormikContext<Filter>();
return (
<CollapsibleSection
//defaultOpen={(values.match_language && values.match_language.length > 0) || (values.except_language && values.except_language.length > 0)}
title="Language"
subtitle="Match or ignore languages (if announced)"
>
<Input.MultiSelect
name="match_language"
options={CONSTS.LANGUAGE_OPTIONS}
label="Match Language"
columns={6}
/>
<Input.MultiSelect
name="except_language"
options={CONSTS.LANGUAGE_OPTIONS}
label="Except Language"
columns={6}
/>
</CollapsibleSection>
);
}
const Origins = () => {
// const { values } = useFormikContext<Filter>();
return (
<CollapsibleSection
//defaultOpen={(values.origins && values.origins.length > 0 || values.except_origins && values.except_origins.length > 0)}
title="Origins"
subtitle="Match Internals, Scene, P2P, etc. (if announced)"
>
<Input.MultiSelect
name="origins"
options={CONSTS.ORIGIN_OPTIONS}
label="Match Origins"
columns={6}
/>
<Input.MultiSelect
name="except_origins"
options={CONSTS.ORIGIN_OPTIONS}
label="Except Origins"
columns={6}
/>
</CollapsibleSection>
);
}
const Freeleech = () => {
const { values } = useFormikContext<Filter>();
return (
<CollapsibleSection
//defaultOpen={values.freeleech || values.freeleech_percent !== ""}
title="Freeleech"
subtitle="Match based off freeleech (if announced)"
>
<Input.TextField
name="freeleech_percent"
label="Freeleech percent"
disabled={values.freeleech}
tooltip={
<div>
<p>Logic used to match except tags.</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
</div>
</CollapsibleSection>
);
const Uploaders = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={values.uploaders || values.except_uploaders}
title="Uploaders"
subtitle="Match or ignore uploaders (if announced)"
>
<Input.TextAreaAutoResize
name="match_uploaders"
label="Match uploaders"
columns={6}
placeholder="eg. uploader1,uploader2"
tooltip={
<div>
<p>Comma separated list of uploaders to match.</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
<Input.TextAreaAutoResize
name="except_uploaders"
label="Except uploaders"
columns={6}
placeholder="eg. anonymous1,anonymous2"
tooltip={
<div>
<p>Comma separated list of uploaders to ignore (takes priority over Match releases).
</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
</CollapsibleSection>
);
const Language = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={(values.match_language && values.match_language.length > 0) || (values.except_language && values.except_language.length > 0)}
title="Language"
subtitle="Match or ignore languages (if announced)"
>
<Input.MultiSelect
name="match_language"
options={CONSTS.LANGUAGE_OPTIONS}
label="Match Language"
columns={6}
/>
<Input.MultiSelect
name="except_language"
options={CONSTS.LANGUAGE_OPTIONS}
label="Except Language"
columns={6}
/>
</CollapsibleSection>
);
const Origins = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={(values.origins && values.origins.length > 0 || values.except_origins && values.except_origins.length > 0)}
title="Origins"
subtitle="Match Internals, Scene, P2P, etc. (if announced)"
>
<Input.MultiSelect
name="origins"
options={CONSTS.ORIGIN_OPTIONS}
label="Match Origins"
columns={6}
/>
<Input.MultiSelect
name="except_origins"
options={CONSTS.ORIGIN_OPTIONS}
label="Except Origins"
columns={6}
/>
</CollapsibleSection>
);
const Freeleech = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={values.freeleech || values.freeleech_percent}
title="Freeleech"
subtitle="Match based off freeleech (if announced)"
>
<Input.TextField
name="freeleech_percent"
label="Freeleech percent"
disabled={values.freeleech}
tooltip={
<div>
<p>
<p>
Freeleech may be announced as a binary true/false value or as a
percentage (less likely), depending on the indexer. Use one <span className="font-bold">or</span> the other.
The Freeleech toggle overrides this field if it is toggled/true.
</p>
<br />
<p>
Refer to our documentation for more details:{" "}
<DocsLink href="https://autobrr.com/filters/freeleech" />
</p>
</div>
}
columns={6}
placeholder="eg. 50,75-100"
/>
<Components.HalfRow>
<Input.SwitchGroup
name="freeleech"
label="Freeleech"
className="py-0"
description="Cannot be used with Freeleech percent. Overrides Freeleech percent if toggled/true."
tooltip={
<div>
<p>
Freeleech may be announced as a binary true/false value (more likely) or as a
percentage, depending on the indexer. Use one <span className="font-bold">or</span> the other.
This field overrides Freeleech percent if it is toggled/true.
</p>
<br />
<p>
See who uses what in the documentation:{" "}
Refer to our documentation for more details:{" "}
<DocsLink href="https://autobrr.com/filters/freeleech" />
</p>
</div>
}
columns={6}
placeholder="eg. 50,75-100"
/>
</Components.HalfRow>
</CollapsibleSection>
);
<Components.HalfRow>
<Input.SwitchGroup
name="freeleech"
label="Freeleech"
className="py-0"
description="Cannot be used with Freeleech percent. Overrides Freeleech percent if toggled/true."
tooltip={
<div>
<p>
Freeleech may be announced as a binary true/false value (more likely) or as a
percentage, depending on the indexer. Use one <span className="font-bold">or</span> the other.
This field overrides Freeleech percent if it is toggled/true.
</p>
<br />
<p>
See who uses what in the documentation:{" "}
<DocsLink href="https://autobrr.com/filters/freeleech" />
</p>
</div>
}
/>
</Components.HalfRow>
</CollapsibleSection>
);
}
const FeedSpecific = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={values.use_regex_description || values.match_description || values.except_description}
title="RSS/Torznab/Newznab-specific"
subtitle={
<>These options are <span className="font-bold">only</span> for Feeds such as RSS, Torznab and Newznab</>
}
>
<Components.Layout>
<Input.SwitchGroup
name="use_regex_description"
label="Use Regex"
className="col-span-12 sm:col-span-6"
const FeedSpecific = () => {
const { values } = useFormikContext<Filter>();
return (
<CollapsibleSection
//defaultOpen={values.use_regex_description || values.match_description || values.except_description}
title="RSS/Torznab/Newznab-specific"
subtitle={
<>These options are <span className="font-bold">only</span> for Feeds such as RSS, Torznab and Newznab</>
}
>
<Components.Layout>
<Input.SwitchGroup
name="use_regex_description"
label="Use Regex"
className="col-span-12 sm:col-span-6"
/>
</Components.Layout>
<Input.RegexTextAreaField
name="match_description"
label="Match description"
useRegex={values.use_regex_description}
columns={6}
placeholder="eg. *some?movie*,*some?show*s01*"
tooltip={
<div>
<p>This field has full regex support (Golang flavour).</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
<br />
<br />
<p>Remember to tick <b>Use Regex</b> below if using more than <code>*</code> and <code>?</code>.</p>
</div>
}
/>
</Components.Layout>
<Input.RegexTextAreaField
name="match_description"
label="Match description"
useRegex={values.use_regex_description}
columns={6}
placeholder="eg. *some?movie*,*some?show*s01*"
tooltip={
<div>
<p>This field has full regex support (Golang flavour).</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
<br />
<br />
<p>Remember to tick <b>Use Regex</b> below if using more than <code>*</code> and <code>?</code>.</p>
</div>
}
/>
<Input.RegexTextAreaField
name="except_description"
label="Except description"
useRegex={values.use_regex_description}
columns={6}
placeholder="eg. *bad?movie*,*bad?show*s03*"
tooltip={
<div>
<p>This field has full regex support (Golang flavour).</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
<br />
<br />
<p>Remember to tick <b>Use Regex</b> below if using more than <code>*</code> and <code>?</code>.</p>
</div>
}
/>
<Input.NumberField
name="min_seeders"
label="Min Seeders"
placeholder="Takes any number (0 is infinite)"
tooltip={
<div>
<p>Number of min seeders as specified by the respective unit. Only for Torznab</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
<Input.NumberField
name="max_seeders"
label="Max Seeders"
placeholder="Takes any number (0 is infinite)"
tooltip={
<div>
<p>Number of max seeders as specified by the respective unit. Only for Torznab</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
<Input.NumberField
name="min_leechers"
label="Min Leechers"
placeholder="Takes any number (0 is infinite)"
tooltip={
<div>
<p>Number of min leechers as specified by the respective unit. Only for Torznab</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
<Input.NumberField
name="max_leechers"
label="Max Leechers"
placeholder="Takes any number (0 is infinite)"
tooltip={
<div>
<p>Number of max leechers as specified by the respective unit. Only for Torznab</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
</CollapsibleSection>
);
const RawReleaseTags = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={values.use_regex_release_tags || values.match_release_tags || values.except_release_tags}
title="Raw Release Tags"
subtitle={
<>
<span className="underline underline-offset-2">Advanced users only</span>
{": "}This is the <span className="font-bold">raw</span> releaseTags string from the announce.
</>
}
>
<WarningAlert
text={
<>These might not be what you think they are. For <span className="underline font-bold">very advanced</span> users who know how things are parsed.</>
}
/>
<Components.Layout>
<Input.SwitchGroup
name="use_regex_release_tags"
label="Use Regex"
className="col-span-12 sm:col-span-6"
<Input.RegexTextAreaField
name="except_description"
label="Except description"
useRegex={values.use_regex_description}
columns={6}
placeholder="eg. *bad?movie*,*bad?show*s03*"
tooltip={
<div>
<p>This field has full regex support (Golang flavour).</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
<br />
<br />
<p>Remember to tick <b>Use Regex</b> below if using more than <code>*</code> and <code>?</code>.</p>
</div>
}
/>
</Components.Layout>
<Input.NumberField
name="min_seeders"
label="Min Seeders"
placeholder="Takes any number (0 is infinite)"
tooltip={
<div>
<p>Number of min seeders as specified by the respective unit. Only for Torznab</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
<Input.NumberField
name="max_seeders"
label="Max Seeders"
placeholder="Takes any number (0 is infinite)"
tooltip={
<div>
<p>Number of max seeders as specified by the respective unit. Only for Torznab</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
<Input.NumberField
name="min_leechers"
label="Min Leechers"
placeholder="Takes any number (0 is infinite)"
tooltip={
<div>
<p>Number of min leechers as specified by the respective unit. Only for Torznab</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
<Input.NumberField
name="max_leechers"
label="Max Leechers"
placeholder="Takes any number (0 is infinite)"
tooltip={
<div>
<p>Number of max leechers as specified by the respective unit. Only for Torznab</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
</CollapsibleSection>
);
}
const RawReleaseTags = () => {
const { values } = useFormikContext<Filter>();
<Input.RegexField
name="match_release_tags"
label="Match release tags"
useRegex={values.use_regex_release_tags}
columns={6}
placeholder="eg. *mkv*,*foreign*"
/>
<Input.RegexField
name="except_release_tags"
label="Except release tags"
useRegex={values.use_regex_release_tags}
columns={6}
placeholder="eg. *mkv*,*foreign*"
/>
</CollapsibleSection>
);
return (
<CollapsibleSection
//defaultOpen={values.use_regex_release_tags || values.match_release_tags || values.except_release_tags}
title="Raw Release Tags"
subtitle={
<>
<span className="underline underline-offset-2">Advanced users only</span>
{": "}This is the <span className="font-bold">raw</span> releaseTags string from the announce.
</>
}
>
<WarningAlert
text={
<>These might not be what you think they are. For <span className="underline font-bold">very advanced</span> users who know how things are parsed.</>
}
/>
export const Advanced = ({ values }: { values: FormikValues; }) => (
<div className="flex flex-col w-full gap-y-4 py-2 sm:-mx-1">
<Releases values={values} />
<Groups values={values} />
<Categories values={values} />
<Freeleech values={values} />
<Tags values={values}/>
<Uploaders values={values}/>
<Language values={values}/>
<Origins values={values} />
<FeedSpecific values={values} />
<RawReleaseTags values={values} />
</div>
);
<Components.Layout>
<Input.SwitchGroup
name="use_regex_release_tags"
label="Use Regex"
className="col-span-12 sm:col-span-6"
/>
</Components.Layout>
<Input.RegexField
name="match_release_tags"
label="Match release tags"
useRegex={values.use_regex_release_tags}
columns={6}
placeholder="eg. *mkv*,*foreign*"
/>
<Input.RegexField
name="except_release_tags"
label="Except release tags"
useRegex={values.use_regex_release_tags}
columns={6}
placeholder="eg. *mkv*,*foreign*"
/>
</CollapsibleSection>
);
}
export const Advanced = () => {
return (
<div className="flex flex-col w-full gap-y-4 py-2 sm:-mx-1">
<Releases />
<Groups />
<Categories />
<Freeleech />
<Tags />
<Uploaders />
<Language />
<Origins />
<FeedSpecific />
<RawReleaseTags />
</div>
);
}

View file

@ -1,25 +1,23 @@
import { useQuery } from "@tanstack/react-query";
import { useSuspenseQuery } from "@tanstack/react-query";
import { APIClient } from "@api/APIClient";
import { downloadsPerUnitOptions } from "@domain/constants";
import { IndexersOptionsQueryOptions } from "@api/queries";
import { DocsLink } from "@components/ExternalLink";
import * as Input from "@components/inputs";
import * as Components from "./_components";
const MapIndexer = (indexer: Indexer) => (
{ label: indexer.name, value: indexer.id } as Input.MultiSelectOption
);
export const General = () => {
const { isLoading, data } = useQuery({
queryKey: ["filters", "indexer_list"],
queryFn: APIClient.indexers.getOptions,
refetchOnWindowFocus: false
});
const indexersQuery = useSuspenseQuery(IndexersOptionsQueryOptions())
const indexerOptions = indexersQuery.data && indexersQuery.data.map(MapIndexer)
const indexerOptions = data?.map(MapIndexer) ?? [];
// const indexerOptions = data?.map(MapIndexer) ?? [];
return (
<Components.Page>
@ -27,9 +25,9 @@ export const General = () => {
<Components.Layout>
<Input.TextField name="name" label="Filter name" columns={6} placeholder="eg. Filter 1" />
{!isLoading && (
{/*{!isLoading && (*/}
<Input.IndexerMultiSelect name="indexers" options={indexerOptions} label="Indexers" columns={6} />
)}
{/*)}*/}
</Components.Layout>
</Components.Section>

View file

@ -1,4 +1,4 @@
import type { FormikValues } from "formik";
import { useFormikContext } from "formik";
import { DocsLink } from "@components/ExternalLink";
import * as Input from "@components/inputs";
@ -6,182 +6,186 @@ import * as Input from "@components/inputs";
import * as CONSTS from "@domain/constants";
import * as Components from "./_components";
export const Music = ({ values }: { values: FormikValues; }) => (
<Components.Page>
<Components.Section>
<Components.Layout>
<Input.TextAreaAutoResize
name="artists"
label="Artists"
columns={6}
placeholder="eg. Artist One"
tooltip={
<div>
<p>You can use basic filtering like wildcards <code>*</code> or replace single characters with <code>?</code></p>
<DocsLink href="https://autobrr.com/filters#music" />
</div>
}
/>
<Input.TextAreaAutoResize
name="albums"
label="Albums"
columns={6}
placeholder="eg. That Album"
tooltip={
<div>
<p>You can use basic filtering like wildcards <code>*</code> or replace single characters with <code>?</code></p>
<DocsLink href="https://autobrr.com/filters#music" />
</div>
}
/>
</Components.Layout>
</Components.Section>
export const Music = () => {
const { values } = useFormikContext<Filter>();
<Components.Section
title="Release details"
subtitle="Type (Album, Single, EP, etc.) and year of release (if announced)"
>
<Components.Layout>
<Input.MultiSelect
name="match_release_types"
options={CONSTS.RELEASE_TYPE_MUSIC_OPTIONS}
label="Music Type"
columns={6}
tooltip={
<div>
<p>Will only match releases with any of the selected types.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
</div>
}
/>
<Input.TextField
name="years"
label="Years"
columns={6}
placeholder="eg. 2018,2019-2021"
tooltip={
<div>
<p>This field takes a range of years and/or comma separated single years.</p>
<DocsLink href="https://autobrr.com/filters#music" />
</div>
}
/>
</Components.Layout>
</Components.Section>
<Components.Section
title="Quality"
subtitle="Format, source, log, etc."
>
<Components.Layout>
return (
<Components.Page>
<Components.Section>
<Components.Layout>
<Input.MultiSelect
name="formats"
options={CONSTS.FORMATS_OPTIONS}
label="Format"
columns={4}
disabled={values.perfect_flac}
<Input.TextAreaAutoResize
name="artists"
label="Artists"
columns={6}
placeholder="eg. Artist One"
tooltip={
<div>
<p>Will only match releases with any of the selected formats. This is overridden by Perfect FLAC.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
<p>You can use basic filtering like wildcards <code>*</code> or replace single characters with <code>?</code></p>
<DocsLink href="https://autobrr.com/filters#music" />
</div>
}
/>
<Input.MultiSelect
name="quality"
options={CONSTS.QUALITY_MUSIC_OPTIONS}
label="Quality"
columns={4}
disabled={values.perfect_flac}
<Input.TextAreaAutoResize
name="albums"
label="Albums"
columns={6}
placeholder="eg. That Album"
tooltip={
<div>
<p>Will only match releases with any of the selected qualities. This is overridden by Perfect FLAC.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
</div>
}
/>
<Input.MultiSelect
name="media"
options={CONSTS.SOURCES_MUSIC_OPTIONS}
label="Media"
columns={4}
disabled={values.perfect_flac}
tooltip={
<div>
<p>Will only match releases with any of the selected sources. This is overridden by Perfect FLAC.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
<p>You can use basic filtering like wildcards <code>*</code> or replace single characters with <code>?</code></p>
<DocsLink href="https://autobrr.com/filters#music" />
</div>
}
/>
</Components.Layout>
</Components.Section>
<Components.Layout className="items-end sm:!gap-x-6">
<Components.Row className="sm:col-span-4">
<Input.SwitchGroup
name="cue"
label="Cue"
description="Must include CUE info"
<Components.Section
title="Release details"
subtitle="Type (Album, Single, EP, etc.) and year of release (if announced)"
>
<Components.Layout>
<Input.MultiSelect
name="match_release_types"
options={CONSTS.RELEASE_TYPE_MUSIC_OPTIONS}
label="Music Type"
columns={6}
tooltip={
<div>
<p>Will only match releases with any of the selected types.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
</div>
}
/>
<Input.TextField
name="years"
label="Years"
columns={6}
placeholder="eg. 2018,2019-2021"
tooltip={
<div>
<p>This field takes a range of years and/or comma separated single years.</p>
<DocsLink href="https://autobrr.com/filters#music" />
</div>
}
/>
</Components.Layout>
</Components.Section>
<Components.Section
title="Quality"
subtitle="Format, source, log, etc."
>
<Components.Layout>
<Components.Layout>
<Input.MultiSelect
name="formats"
options={CONSTS.FORMATS_OPTIONS}
label="Format"
columns={4}
disabled={values.perfect_flac}
className="sm:col-span-4"
/>
</Components.Row>
<Components.Row className="sm:col-span-4">
<Input.SwitchGroup
name="log"
label="Log"
description="Must include LOG info"
disabled={values.perfect_flac}
className="sm:col-span-4"
/>
</Components.Row>
<Components.Row className="sm:col-span-4">
<Input.NumberField
name="log_score"
label="Log score"
placeholder="eg. 100"
min={0}
max={100}
disabled={values.perfect_flac || !values.log}
tooltip={
<div>
<p>Log scores go from 0 to 100. This is overridden by Perfect FLAC.</p>
<p>Will only match releases with any of the selected formats. This is overridden by Perfect FLAC.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
</div>
}
/>
</Components.Row>
</Components.Layout>
</Components.Layout>
<Input.MultiSelect
name="quality"
options={CONSTS.QUALITY_MUSIC_OPTIONS}
label="Quality"
columns={4}
disabled={values.perfect_flac}
tooltip={
<div>
<p>Will only match releases with any of the selected qualities. This is overridden by Perfect FLAC.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
</div>
}
/>
<Input.MultiSelect
name="media"
options={CONSTS.SOURCES_MUSIC_OPTIONS}
label="Media"
columns={4}
disabled={values.perfect_flac}
tooltip={
<div>
<p>Will only match releases with any of the selected sources. This is overridden by Perfect FLAC.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
</div>
}
/>
</Components.Layout>
<div className="col-span-12 flex items-center justify-center">
<span className="border-b border-gray-150 dark:border-gray-750 w-full" />
<span className="flex mx-2 shrink-0 text-lg font-bold uppercase tracking-wide text-gray-700 dark:text-gray-200">
<Components.Layout className="items-end sm:!gap-x-6">
<Components.Row className="sm:col-span-4">
<Input.SwitchGroup
name="cue"
label="Cue"
description="Must include CUE info"
disabled={values.perfect_flac}
className="sm:col-span-4"
/>
</Components.Row>
<Components.Row className="sm:col-span-4">
<Input.SwitchGroup
name="log"
label="Log"
description="Must include LOG info"
disabled={values.perfect_flac}
className="sm:col-span-4"
/>
</Components.Row>
<Components.Row className="sm:col-span-4">
<Input.NumberField
name="log_score"
label="Log score"
placeholder="eg. 100"
min={0}
max={100}
disabled={values.perfect_flac || !values.log}
tooltip={
<div>
<p>Log scores go from 0 to 100. This is overridden by Perfect FLAC.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
</div>
}
/>
</Components.Row>
</Components.Layout>
</Components.Layout>
<div className="col-span-12 flex items-center justify-center">
<span className="border-b border-gray-150 dark:border-gray-750 w-full" />
<span className="flex mx-2 shrink-0 text-lg font-bold uppercase tracking-wide text-gray-700 dark:text-gray-200">
OR
</span>
<span className="border-b border-gray-150 dark:border-gray-750 w-full" />
</div>
<span className="border-b border-gray-150 dark:border-gray-750 w-full" />
</div>
<Components.Layout className="sm:!gap-x-6">
<Input.SwitchGroup
name="perfect_flac"
label="Perfect FLAC"
description="Override all options about quality, source, format, and cue/log/log score."
className="py-2 col-span-12 sm:col-span-6"
tooltip={
<div>
<p>Override all options about quality, source, format, and CUE/LOG/LOG score.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
</div>
}
/>
<Components.Layout className="sm:!gap-x-6">
<Input.SwitchGroup
name="perfect_flac"
label="Perfect FLAC"
description="Override all options about quality, source, format, and cue/log/log score."
className="py-2 col-span-12 sm:col-span-6"
tooltip={
<div>
<p>Override all options about quality, source, format, and CUE/LOG/LOG score.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
</div>
}
/>
<span className="col-span-12 sm:col-span-6 self-center ml-0 text-center sm:text-left text-sm text-gray-500 dark:text-gray-425 underline underline-offset-2">
<span className="col-span-12 sm:col-span-6 self-center ml-0 text-center sm:text-left text-sm text-gray-500 dark:text-gray-425 underline underline-offset-2">
This is what you want in 90% of cases (instead of options above).
</span>
</Components.Layout>
</Components.Section>
</Components.Page>
);
</Components.Layout>
</Components.Section>
</Components.Page>
);
}

View file

@ -1,4 +1,4 @@
import { Link } from "react-router-dom";
import { Link } from "@tanstack/react-router";
import { DocsLink } from "@components/ExternalLink";
import { ActionContentLayoutOptions, ActionPriorityOptions } from "@domain/constants";

View file

@ -4,15 +4,15 @@
*/
import * as React from "react";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { Listbox, Transition } from "@headlessui/react";
import { CheckIcon, ChevronDownIcon } from "@heroicons/react/24/solid";
import { APIClient } from "@api/APIClient";
import { classNames } from "@utils";
import { PushStatusOptions } from "@domain/constants";
import { FilterProps } from "react-table";
import { DebounceInput } from "react-debounce-input";
import { ReleasesIndexersQueryOptions } from "@api/queries";
interface ListboxFilterProps {
id: string;
@ -54,7 +54,7 @@ const ListboxFilter = ({
<Listbox.Options
className="absolute z-10 w-full mt-1 overflow-auto text-base bg-white dark:bg-gray-800 rounded-md shadow-lg max-h-60 border border-opacity-5 border-black dark:border-gray-700 dark:border-opacity-40 focus:outline-none sm:text-sm"
>
<FilterOption label="All" />
<FilterOption label="All" value="" />
{children}
</Listbox.Options>
</Transition>
@ -67,12 +67,7 @@ const ListboxFilter = ({
export const IndexerSelectColumnFilter = ({
column: { filterValue, setFilter, id }
}: FilterProps<object>) => {
const { data, isSuccess } = useQuery({
queryKey: ["indexer_options"],
queryFn: () => APIClient.release.indexerOptions(),
placeholderData: keepPreviousData,
staleTime: Infinity
});
const { data, isSuccess } = useQuery(ReleasesIndexersQueryOptions());
// Render a multi-select box
return (
@ -80,10 +75,10 @@ export const IndexerSelectColumnFilter = ({
id={id}
key={id}
label={filterValue ?? "Indexer"}
currentValue={filterValue}
currentValue={filterValue ?? ""}
onChange={setFilter}
>
{isSuccess && data?.map((indexer, idx) => (
{isSuccess && data && data?.map((indexer, idx) => (
<FilterOption key={idx} label={indexer} value={indexer} />
))}
</ListboxFilter>
@ -138,7 +133,7 @@ export const PushStatusSelectColumnFilter = ({
<ListboxFilter
id={id}
label={label ?? "Push status"}
currentValue={filterValue}
currentValue={filterValue ?? ""}
onChange={setFilter}
>
{PushStatusOptions.map((status, idx) => (

View file

@ -4,33 +4,28 @@
*/
import React, { useState } from "react";
import { useLocation } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Column, useFilters, usePagination, useSortBy, useTable } from "react-table";
import {
ChevronDoubleLeftIcon,
ChevronDoubleRightIcon,
ChevronLeftIcon,
ChevronRightIcon
ChevronRightIcon,
EyeIcon,
EyeSlashIcon
} from "@heroicons/react/24/solid";
import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/solid";
import { ReleasesIndexRoute } from "@app/routes";
import { ReleasesListQueryOptions } from "@api/queries";
import { RandomLinuxIsos } from "@utils";
import { APIClient } from "@api/APIClient";
import { EmptyListState } from "@components/emptystates";
import * as Icons from "@components/Icons";
import { RingResizeSpinner } from "@components/Icons";
import * as DataTable from "@components/data-table";
import { IndexerSelectColumnFilter, PushStatusSelectColumnFilter, SearchColumnFilter } from "./Filters";
export const releaseKeys = {
all: ["releases"] as const,
lists: () => [...releaseKeys.all, "list"] as const,
list: (pageIndex: number, pageSize: number, filters: ReleaseFilter[]) => [...releaseKeys.lists(), { pageIndex, pageSize, filters }] as const,
details: () => [...releaseKeys.all, "detail"] as const,
detail: (id: number) => [...releaseKeys.details(), id] as const
};
import { IndexerSelectColumnFilter, PushStatusSelectColumnFilter, SearchColumnFilter } from "./ReleaseFilters";
import { EmptyListState } from "@components/emptystates";
type TableState = {
queryPageIndex: number;
@ -79,10 +74,28 @@ const TableReducer = (state: TableState, action: Actions): TableState => {
}
};
const EmptyReleaseList = () => (
<div className="bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-table rounded-md overflow-auto">
<table className="min-w-full rounded-md divide-y divide-gray-200 dark:divide-gray-750">
<thead className="bg-gray-100 dark:bg-gray-850 border-b border-gray-200 dark:border-gray-750">
<tr>
<th>
<div className="flex items-center justify-between">
<span className="h-10"/>
</div>
</th>
</tr>
</thead>
</table>
<div className="flex items-center justify-center py-52">
<EmptyListState text="No results"/>
</div>
</div>
);
export const ReleaseTable = () => {
const location = useLocation();
const queryParams = new URLSearchParams(location.search);
const filterTypeFromUrl = queryParams.get("filter");
const search = ReleasesIndexRoute.useSearch()
const columns = React.useMemo(() => [
{
Header: "Age",
@ -116,14 +129,14 @@ export const ReleaseTable = () => {
}
] as Column<Release>[], []);
if (search.action_status != "") {
initialState.queryFilters = [{id: "action_status", value: search.action_status! }]
}
const [{ queryPageIndex, queryPageSize, totalCount, queryFilters }, dispatch] =
React.useReducer(TableReducer, initialState);
const { isLoading, error, data, isSuccess } = useQuery({
queryKey: releaseKeys.list(queryPageIndex, queryPageSize, queryFilters),
queryFn: () => APIClient.release.findQuery(queryPageIndex * queryPageSize, queryPageSize, queryFilters),
staleTime: 5000
});
const { isLoading, error, data, isSuccess } = useQuery(ReleasesListQueryOptions(queryPageIndex * queryPageSize, queryPageSize, queryFilters));
const [modifiedData, setModifiedData] = useState<Release[]>([]);
const [showLinuxIsos, setShowLinuxIsos] = useState(false);
@ -207,10 +220,10 @@ export const ReleaseTable = () => {
}, [filters]);
React.useEffect(() => {
if (filterTypeFromUrl != null) {
dispatch({ type: ActionType.FILTER_CHANGED, payload: [{ id: "action_status", value: filterTypeFromUrl! }] });
if (search.action_status != null) {
dispatch({ type: ActionType.FILTER_CHANGED, payload: [{ id: "action_status", value: search.action_status! }] });
}
}, [filterTypeFromUrl]);
}, [search.action_status]);
if (error) {
return <p>Error</p>;
@ -218,167 +231,33 @@ export const ReleaseTable = () => {
if (isLoading) {
return (
<div className="flex flex-col animate-pulse">
<div>
<div className="flex mb-6 flex-col sm:flex-row">
{headerGroups.map((headerGroup) =>
headerGroup.headers.map((column) => (
{ headerGroups.map((headerGroup) => headerGroup.headers.map((column) => (
column.Filter ? (
<React.Fragment key={column.id}>{column.render("Filter")}</React.Fragment>
<React.Fragment key={ column.id }>{ column.render("Filter") }</React.Fragment>
) : null
))
)}
) }
</div>
<div className="bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-lg rounded-md overflow-auto">
<table {...getTableProps()} className="min-w-full rounded-md divide-y divide-gray-200 dark:divide-gray-750">
<thead className="bg-gray-100 dark:bg-gray-800">
<tr>
<th
scope="col"
className="first:pl-5 pl-3 pr-3 py-3 first:rounded-tl-md last:rounded-tr-md text-xs font-medium tracking-wider text-left text-gray-500 uppercase group"
>
<div className="flex items-center justify-between">
{/* Add a sort direction indicator */}
<span className="h-4">
</span>
</div>
</th>
</tr>
<div className="bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-lg rounded-md mt-4">
<table className="min-w-full rounded-md divide-y divide-gray-200 dark:divide-gray-750">
<thead className="bg-gray-100 dark:bg-gray-850 border-b border-gray-200 dark:border-gray-750">
<tr>
<th>
<div className="flex items-center justify-between">
<span className="h-10"/>
</div>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-150 dark:divide-gray-700">
<tr
className="flex justify-between py-4 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
<tr
className="flex justify-between py-4 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
<tr
className="flex justify-between py-4 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap">&nbsp;</td>
</tr>
<tr
className="flex justify-between py-4 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
<tr className="justify-between py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap text-center">
<p className="text-black dark:text-white">Loading release table...</p>
</td>
</tr>
<tr
className="flex justify-between py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
<tr
className="flex justify-between py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
<tr
className="flex justify-between py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
<tr
className="flex justify-between py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
<tr
className="flex justify-between py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
</tbody>
</table>
{/* Pagination */}
<div className="flex items-center justify-between px-6 py-3 border-t border-gray-200 dark:border-gray-700">
<div className="flex justify-between flex-1 sm:hidden">
<DataTable.Button onClick={() => previousPage()} disabled={!canPreviousPage}>Previous</DataTable.Button>
<DataTable.Button onClick={() => nextPage()} disabled={!canNextPage}>Next</DataTable.Button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div className="flex items-baseline gap-x-2">
<span className="text-sm text-gray-700 dark:text-gray-500">
Page <span className="font-medium">{pageIndex + 1}</span> of <span className="font-medium">{pageOptions.length}</span>
</span>
<label>
<span className="sr-only bg-gray-700">Items Per Page</span>
<select
className="py-1 pl-2 pr-8 text-sm block w-full border-gray-300 rounded-md shadow-sm cursor-pointer dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100 dark:hover:text-gray-500 focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
value={pageSize}
onChange={e => {
setPageSize(Number(e.target.value));
}}
>
{[5, 10, 20, 50].map(pageSize => (
<option key={pageSize} value={pageSize}>
Show {pageSize}
</option>
))}
</select>
</label>
</div>
<div>
<nav className="inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<DataTable.PageButton
className="rounded-l-md"
onClick={() => gotoPage(0)}
disabled={!canPreviousPage}
>
<span className="sr-only text-gray-400 dark:text-gray-500 dark:bg-gray-700">First</span>
<ChevronDoubleLeftIcon className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true"/>
</DataTable.PageButton>
<DataTable.PageButton
onClick={() => previousPage()}
disabled={!canPreviousPage}
>
<span className="sr-only text-gray-400 dark:text-gray-500 dark:bg-gray-700">Previous</span>
<ChevronLeftIcon className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true"/>
</DataTable.PageButton>
<DataTable.PageButton
onClick={() => nextPage()}
disabled={!canNextPage}>
<span className="sr-only text-gray-400 dark:text-gray-500 dark:bg-gray-700">Next</span>
<ChevronRightIcon className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true"/>
</DataTable.PageButton>
<DataTable.PageButton
className="rounded-r-md"
onClick={() => gotoPage(pageCount - 1)}
disabled={!canNextPage}
>
<span className="sr-only text-gray-400 dark:text-gray-500 dark:bg-gray-700">Last</span>
<ChevronDoubleRightIcon className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true"/>
</DataTable.PageButton>
</nav>
</div>
</div>
<div className="flex items-center justify-center py-64">
<RingResizeSpinner className="text-blue-500 size-24"/>
</div>
</div>
</div>
);
}
if (!data) {
return <EmptyListState text="No recent activity" />;
)
}
// Render the UI for your table
@ -394,18 +273,21 @@ export const ReleaseTable = () => {
)}
</div>
<div className="relative">
<div className="bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-table rounded-md overflow-auto">
<table {...getTableProps()} className="min-w-full rounded-md divide-y divide-gray-200 dark:divide-gray-750">
<thead className="bg-gray-100 dark:bg-gray-850">
{displayData.length === 0
? <EmptyReleaseList/>
: (
<div className="bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-table rounded-md overflow-auto">
<table {...getTableProps()} className="min-w-full rounded-md divide-y divide-gray-200 dark:divide-gray-750">
<thead className="bg-gray-100 dark:bg-gray-850">
{headerGroups.map((headerGroup) => {
const { key: rowKey, ...rowRest } = headerGroup.getHeaderGroupProps();
const {key: rowKey, ...rowRest} = headerGroup.getHeaderGroupProps();
return (
<tr key={rowKey} {...rowRest}>
{headerGroup.headers.map((column) => {
const { key: columnKey, ...columnRest } = column.getHeaderProps(column.getSortByToggleProps());
const {key: columnKey, ...columnRest} = column.getHeaderProps(column.getSortByToggleProps());
return (
// Add the sorting props to control sorting. For this example
// we can add them into the header props
// Add the sorting props to control sorting. For this example
// we can add them into the header props
<th
key={`${rowKey}-${columnKey}`}
scope="col"
@ -418,12 +300,12 @@ export const ReleaseTable = () => {
<span>
{column.isSorted ? (
column.isSortedDesc ? (
<Icons.SortDownIcon className="w-4 h-4 text-gray-400" />
<Icons.SortDownIcon className="w-4 h-4 text-gray-400"/>
) : (
<Icons.SortUpIcon className="w-4 h-4 text-gray-400" />
<Icons.SortUpIcon className="w-4 h-4 text-gray-400"/>
)
) : (
<Icons.SortIcon className="w-4 h-4 text-gray-400 opacity-0 group-hover:opacity-100" />
<Icons.SortIcon className="w-4 h-4 text-gray-400 opacity-0 group-hover:opacity-100"/>
)}
</span>
</div>
@ -433,19 +315,19 @@ export const ReleaseTable = () => {
</tr>
);
})}
</thead>
<tbody
{...getTableBodyProps()}
className="divide-y divide-gray-150 dark:divide-gray-750"
>
</thead>
<tbody
{...getTableBodyProps()}
className="divide-y divide-gray-150 dark:divide-gray-750"
>
{page.map((row) => {
prepareRow(row);
const { key: bodyRowKey, ...bodyRowRest } = row.getRowProps();
const {key: bodyRowKey, ...bodyRowRest} = row.getRowProps();
return (
<tr key={bodyRowKey} {...bodyRowRest}>
{row.cells.map((cell) => {
const { key: cellRowKey, ...cellRowRest } = cell.getCellProps();
const {key: cellRowKey, ...cellRowRest} = cell.getCellProps();
return (
<td
key={cellRowKey}
@ -460,88 +342,90 @@ export const ReleaseTable = () => {
</tr>
);
})}
</tbody>
</table>
{/* Pagination */}
<div className="flex items-center justify-between px-6 py-3 border-t border-gray-200 dark:border-gray-700">
<div className="flex justify-between flex-1 sm:hidden">
<DataTable.Button onClick={() => previousPage()} disabled={!canPreviousPage}>Previous</DataTable.Button>
<DataTable.Button onClick={() => nextPage()} disabled={!canNextPage}>Next</DataTable.Button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div className="flex items-baseline gap-x-2">
</tbody>
</table>
{/* Pagination */}
<div className="flex items-center justify-between px-6 py-3 border-t border-gray-200 dark:border-gray-700">
<div className="flex justify-between flex-1 sm:hidden">
<DataTable.Button onClick={() => previousPage()} disabled={!canPreviousPage}>Previous</DataTable.Button>
<DataTable.Button onClick={() => nextPage()} disabled={!canNextPage}>Next</DataTable.Button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div className="flex items-baseline gap-x-2">
<span className="text-sm text-gray-700 dark:text-gray-500">
Page <span className="font-medium">{pageIndex + 1}</span> of <span className="font-medium">{pageOptions.length}</span>
Page <span className="font-medium">{pageIndex + 1}</span> of <span
className="font-medium">{pageOptions.length}</span>
</span>
<label>
<span className="sr-only bg-gray-700">Items Per Page</span>
<select
className="py-1 pl-2 pr-8 text-sm block w-full border-gray-300 rounded-md shadow-sm cursor-pointer transition-colors dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:text-gray-200 focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
value={pageSize}
onChange={e => {
setPageSize(Number(e.target.value));
}}
>
{[5, 10, 20, 50].map(pageSize => (
<option key={pageSize} value={pageSize}>
{pageSize} entries
</option>
))}
</select>
</label>
</div>
<div>
<nav className="inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<DataTable.PageButton
className="rounded-l-md"
onClick={() => gotoPage(0)}
disabled={!canPreviousPage}
>
<span className="sr-only">First</span>
<ChevronDoubleLeftIcon className="w-4 h-4" aria-hidden="true" />
</DataTable.PageButton>
<DataTable.PageButton
className="pl-1 pr-2"
onClick={() => previousPage()}
disabled={!canPreviousPage}
>
<ChevronLeftIcon className="w-4 h-4 mr-1" aria-hidden="true" />
<span>Prev</span>
</DataTable.PageButton>
<DataTable.PageButton
className="pl-2 pr-1"
onClick={() => nextPage()}
disabled={!canNextPage}>
<span>Next</span>
<ChevronRightIcon className="w-4 h-4 ml-1" aria-hidden="true" />
</DataTable.PageButton>
<DataTable.PageButton
className="rounded-r-md"
onClick={() => gotoPage(pageCount - 1)}
disabled={!canNextPage}
>
<ChevronDoubleRightIcon className="w-4 h-4" aria-hidden="true" />
<span className="sr-only">Last</span>
</DataTable.PageButton>
</nav>
<label>
<span className="sr-only bg-gray-700">Items Per Page</span>
<select
className="py-1 pl-2 pr-8 text-sm block w-full border-gray-300 rounded-md shadow-sm cursor-pointer transition-colors dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:text-gray-200 focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
value={pageSize}
onChange={e => {
setPageSize(Number(e.target.value));
}}
>
{[5, 10, 20, 50].map(pageSize => (
<option key={pageSize} value={pageSize}>
{pageSize} entries
</option>
))}
</select>
</label>
</div>
<div>
<nav className="inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<DataTable.PageButton
className="rounded-l-md"
onClick={() => gotoPage(0)}
disabled={!canPreviousPage}
>
<span className="sr-only">First</span>
<ChevronDoubleLeftIcon className="w-4 h-4" aria-hidden="true"/>
</DataTable.PageButton>
<DataTable.PageButton
className="pl-1 pr-2"
onClick={() => previousPage()}
disabled={!canPreviousPage}
>
<ChevronLeftIcon className="w-4 h-4 mr-1" aria-hidden="true"/>
<span>Prev</span>
</DataTable.PageButton>
<DataTable.PageButton
className="pl-2 pr-1"
onClick={() => nextPage()}
disabled={!canNextPage}>
<span>Next</span>
<ChevronRightIcon className="w-4 h-4 ml-1" aria-hidden="true"/>
</DataTable.PageButton>
<DataTable.PageButton
className="rounded-r-md"
onClick={() => gotoPage(pageCount - 1)}
disabled={!canNextPage}
>
<ChevronDoubleRightIcon className="w-4 h-4" aria-hidden="true"/>
<span className="sr-only">Last</span>
</DataTable.PageButton>
</nav>
</div>
</div>
</div>
<div className="absolute -bottom-11 right-0 p-2">
<button
onClick={toggleReleaseNames}
className="p-2 absolute bottom-0 right-0 bg-gray-750 text-white rounded-full opacity-10 hover:opacity-100 transition-opacity duration-300"
aria-label="Toggle view"
title="Go incognito"
>
{showLinuxIsos ? (
<EyeIcon className="h-4 w-4"/>
) : (
<EyeSlashIcon className="h-4 w-4"/>
)}
</button>
</div>
</div>
<div className="absolute -bottom-11 right-0 p-2">
<button
onClick={toggleReleaseNames}
className="p-2 absolute bottom-0 right-0 bg-gray-750 text-white rounded-full opacity-10 hover:opacity-100 transition-opacity duration-300"
aria-label="Toggle view"
title="Go incognito"
>
{showLinuxIsos ? (
<EyeIcon className="h-4 w-4" />
) : (
<EyeSlashIcon className="h-4 w-4" />
)}
</button>
</div>
</div>
)}
</div>
</div>
);

View file

@ -4,15 +4,17 @@
*/
import { useMutation } from "@tanstack/react-query";
import { APIClient } from "@api/APIClient";
import Toast from "@components/notifications/Toast";
import { Section } from "./_components";
import { Form, Formik } from "formik";
import { PasswordField, TextField } from "@components/inputs";
import { AuthContext } from "@utils/Context";
import toast from "react-hot-toast";
import { UserIcon } from "@heroicons/react/24/solid";
import { SettingsAccountRoute } from "@app/routes";
import { AuthContext } from "@utils/Context";
import { APIClient } from "@api/APIClient";
import { Section } from "./_components";
import { PasswordField, TextField } from "@components/inputs";
import Toast from "@components/notifications/Toast";
const AccountSettings = () => (
<Section
title="Account"
@ -33,8 +35,7 @@ interface InputValues {
}
function Credentials() {
const [ getAuthContext ] = AuthContext.use();
const ctx = SettingsAccountRoute.useRouteContext()
const validate = (values: InputValues) => {
const errors: Record<string, string> = {};
@ -51,7 +52,8 @@ function Credentials() {
const logoutMutation = useMutation({
mutationFn: APIClient.auth.logout,
onSuccess: () => {
AuthContext.reset();
AuthContext.logout();
toast.custom((t) => (
<Toast type="success" body="User updated successfully. Please sign in again!" t={t} />
));
@ -76,7 +78,7 @@ function Credentials() {
<div className="px-2 pb-6 bg-white dark:bg-gray-800">
<Formik
initialValues={{
username: getAuthContext.username,
username: ctx.auth.username!,
newUsername: "",
oldPassword: "",
newPassword: "",

View file

@ -13,33 +13,19 @@ import { DeleteModal } from "@components/modals";
import { APIKeyAddForm } from "@forms/settings/APIKeyAddForm";
import Toast from "@components/notifications/Toast";
import { APIClient } from "@api/APIClient";
import { ApikeysQueryOptions } from "@api/queries";
import { ApiKeys } from "@api/query_keys";
import { useToggle } from "@hooks/hooks";
import { classNames } from "@utils";
import { EmptySimple } from "@components/emptystates";
import { Section } from "./_components";
import { PlusIcon } from "@heroicons/react/24/solid";
export const apiKeys = {
all: ["api_keys"] as const,
lists: () => [...apiKeys.all, "list"] as const,
details: () => [...apiKeys.all, "detail"] as const,
// detail: (id: number) => [...apiKeys.details(), id] as const
detail: (id: string) => [...apiKeys.details(), id] as const
};
function APISettings() {
const [addFormIsOpen, toggleAddForm] = useToggle(false);
const { isError, error, data } = useSuspenseQuery({
queryKey: apiKeys.lists(),
queryFn: APIClient.apikeys.getAll,
retry: false,
refetchOnWindowFocus: false
});
if (isError) {
console.log(error);
}
const apikeysQuery = useSuspenseQuery(ApikeysQueryOptions())
return (
<Section
@ -58,7 +44,7 @@ function APISettings() {
>
<APIKeyAddForm isOpen={addFormIsOpen} toggle={toggleAddForm} />
{data && data.length > 0 ? (
{apikeysQuery.data && apikeysQuery.data.length > 0 ? (
<ul className="min-w-full relative">
<li className="hidden sm:grid grid-cols-12 gap-4 mb-2 border-b border-gray-200 dark:border-gray-700">
<div className="col-span-3 px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
@ -69,7 +55,7 @@ function APISettings() {
</div>
</li>
{data.map((k, idx) => <APIListItem key={idx} apikey={k} />)}
{apikeysQuery.data.map((k, idx) => <APIListItem key={idx} apikey={k} />)}
</ul>
) : (
<EmptySimple
@ -96,8 +82,8 @@ function APIListItem({ apikey }: ApiKeyItemProps) {
const deleteMutation = useMutation({
mutationFn: (key: string) => APIClient.apikeys.delete(key),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: apiKeys.lists() });
queryClient.invalidateQueries({ queryKey: apiKeys.detail(apikey.key) });
queryClient.invalidateQueries({ queryKey: ApiKeys.lists() });
queryClient.invalidateQueries({ queryKey: ApiKeys.detail(apikey.key) });
toast.custom((t) => (
<Toast

View file

@ -3,10 +3,13 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "react-hot-toast";
import { SettingsIndexRoute } from "@app/routes";
import { APIClient } from "@api/APIClient";
import { ConfigQueryOptions, UpdatesQueryOptions } from "@api/queries";
import { SettingsKeys } from "@api/query_keys";
import { SettingsContext } from "@utils/Context";
import { Checkbox } from "@components/Checkbox";
import Toast from "@components/notifications/Toast";
@ -17,34 +20,23 @@ import { Section, RowItem } from "./_components";
function ApplicationSettings() {
const [settings, setSettings] = SettingsContext.use();
const { isError:isConfigError, error: configError, data } = useQuery({
queryKey: ["config"],
queryFn: APIClient.config.get,
retry: false,
refetchOnWindowFocus: false
});
const ctx = SettingsIndexRoute.useRouteContext()
const queryClient = ctx.queryClient
const { isError:isConfigError, error: configError, data } = useQuery(ConfigQueryOptions());
if (isConfigError) {
console.log(configError);
}
const { isError, error, data: updateData } = useQuery({
queryKey: ["updates"],
queryFn: APIClient.updates.getLatestRelease,
retry: false,
refetchOnWindowFocus: false,
enabled: data?.check_for_updates === true
});
const { isError, error, data: updateData } = useQuery(UpdatesQueryOptions(data?.check_for_updates === true));
if (isError) {
console.log(error);
}
const queryClient = useQueryClient();
const checkUpdateMutation = useMutation({
mutationFn: APIClient.updates.check,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["updates"] });
queryClient.invalidateQueries({ queryKey: SettingsKeys.updates() });
}
});
@ -52,7 +44,7 @@ function ApplicationSettings() {
mutationFn: (value: boolean) => APIClient.config.update({ check_for_updates: value }).then(() => value),
onSuccess: (_, value: boolean) => {
toast.custom(t => <Toast type="success" body={`${value ? "You will now be notified of new updates." : "You will no longer be notified of new updates."}`} t={t} />);
queryClient.invalidateQueries({ queryKey: ["config"] });
queryClient.invalidateQueries({ queryKey: SettingsKeys.config() });
checkUpdateMutation.mutate();
}
});

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { useState, useMemo } from "react";
import { useMemo, useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { PlusIcon } from "@heroicons/react/24/solid";
import toast from "react-hot-toast";
@ -12,20 +12,14 @@ import { useToggle } from "@hooks/hooks";
import { DownloadClientAddForm, DownloadClientUpdateForm } from "@forms";
import { EmptySimple } from "@components/emptystates";
import { APIClient } from "@api/APIClient";
import { DownloadClientKeys } from "@api/query_keys";
import { DownloadClientsQueryOptions } from "@api/queries";
import { ActionTypeNameMap } from "@domain/constants";
import Toast from "@components/notifications/Toast";
import { Checkbox } from "@components/Checkbox";
import { Section } from "./_components";
export const clientKeys = {
all: ["download_clients"] as const,
lists: () => [...clientKeys.all, "list"] as const,
// list: (indexers: string[], sortOrder: string) => [...clientKeys.lists(), { indexers, sortOrder }] as const,
details: () => [...clientKeys.all, "detail"] as const,
detail: (id: number) => [...clientKeys.details(), id] as const
};
interface DLSettingsItemProps {
client: DownloadClient;
}
@ -97,7 +91,7 @@ function ListItem({ client }: DLSettingsItemProps) {
mutationFn: (client: DownloadClient) => APIClient.download_clients.update(client).then(() => client),
onSuccess: (client: DownloadClient) => {
toast.custom(t => <Toast type="success" body={`${client.name} was ${client.enabled ? "enabled" : "disabled"} successfully.`} t={t} />);
queryClient.invalidateQueries({ queryKey: clientKeys.lists() });
queryClient.invalidateQueries({ queryKey: DownloadClientKeys.lists() });
}
});
@ -140,17 +134,9 @@ function ListItem({ client }: DLSettingsItemProps) {
function DownloadClientSettings() {
const [addClientIsOpen, toggleAddClient] = useToggle(false);
const { error, data } = useSuspenseQuery({
queryKey: clientKeys.lists(),
queryFn: APIClient.download_clients.getAll,
refetchOnWindowFocus: false
});
const downloadClientsQuery = useSuspenseQuery(DownloadClientsQueryOptions())
const sortedClients = useSort(data || []);
if (error) {
return <p>Failed to fetch download clients</p>;
}
const sortedClients = useSort(downloadClientsQuery.data || []);
return (
<Section

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Fragment, useRef, useState, useMemo } from "react";
import { Fragment, useMemo, useRef, useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { Menu, Transition } from "@headlessui/react";
import { toast } from "react-hot-toast";
@ -11,12 +11,14 @@ import {
ArrowsRightLeftIcon,
DocumentTextIcon,
EllipsisHorizontalIcon,
PencilSquareIcon,
ForwardIcon,
PencilSquareIcon,
TrashIcon
} from "@heroicons/react/24/outline";
import { APIClient } from "@api/APIClient";
import { FeedsQueryOptions } from "@api/queries";
import { FeedKeys } from "@api/query_keys";
import { useToggle } from "@hooks/hooks";
import { baseUrl, classNames, IsEmptyDate, simplifyDate } from "@utils";
import Toast from "@components/notifications/Toast";
@ -29,14 +31,6 @@ import { ExternalLink } from "@components/ExternalLink";
import { Section } from "./_components";
import { Checkbox } from "@components/Checkbox";
export const feedKeys = {
all: ["feeds"] as const,
lists: () => [...feedKeys.all, "list"] as const,
// list: (indexers: string[], sortOrder: string) => [...feedKeys.lists(), { indexers, sortOrder }] as const,
details: () => [...feedKeys.all, "detail"] as const,
detail: (id: number) => [...feedKeys.details(), id] as const
};
interface SortConfig {
key: keyof ListItemProps["feed"] | "enabled";
direction: "ascending" | "descending";
@ -97,20 +91,16 @@ function useSort(items: ListItemProps["feed"][], config?: SortConfig) {
}
function FeedSettings() {
const { data } = useSuspenseQuery({
queryKey: feedKeys.lists(),
queryFn: APIClient.feeds.find,
refetchOnWindowFocus: false
});
const feedsQuery = useSuspenseQuery(FeedsQueryOptions())
const sortedFeeds = useSort(data || []);
const sortedFeeds = useSort(feedsQuery.data || []);
return (
<Section
title="Feeds"
description="Manage RSS, Newznab, and Torznab feeds."
>
{data && data.length > 0 ? (
{feedsQuery.data && feedsQuery.data.length > 0 ? (
<ul className="min-w-full relative">
<li className="grid grid-cols-12 border-b border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400 font-medium uppercase tracking-wider">
<div
@ -163,8 +153,8 @@ function ListItem({ feed }: ListItemProps) {
const updateMutation = useMutation({
mutationFn: (status: boolean) => APIClient.feeds.toggleEnable(feed.id, status),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: feedKeys.lists() });
queryClient.invalidateQueries({ queryKey: feedKeys.detail(feed.id) });
queryClient.invalidateQueries({ queryKey: FeedKeys.lists() });
queryClient.invalidateQueries({ queryKey: FeedKeys.detail(feed.id) });
toast.custom((t) => <Toast type="success" body={`${feed.name} was ${!enabled ? "disabled" : "enabled"} successfully.`} t={t} />);
}
@ -240,8 +230,8 @@ const FeedItemDropdown = ({
const deleteMutation = useMutation({
mutationFn: (id: number) => APIClient.feeds.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: feedKeys.lists() });
queryClient.invalidateQueries({ queryKey: feedKeys.detail(feed.id) });
queryClient.invalidateQueries({ queryKey: FeedKeys.lists() });
queryClient.invalidateQueries({ queryKey: FeedKeys.detail(feed.id) });
toast.custom((t) => <Toast type="success" body={`Feed ${feed?.name} was deleted`} t={t} />);
}
@ -257,7 +247,7 @@ const FeedItemDropdown = ({
const forceRunMutation = useMutation({
mutationFn: (id: number) => APIClient.feeds.forceRun(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: feedKeys.lists() });
queryClient.invalidateQueries({ queryKey: FeedKeys.lists() });
toast.custom((t) => <Toast type="success" body={`Feed ${feed?.name} was force run successfully.`} t={t} />);
toggleForceRunModal();
},

View file

@ -3,13 +3,15 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { useState, useMemo } from "react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { PlusIcon } from "@heroicons/react/24/solid";
import { useToggle } from "@hooks/hooks";
import { APIClient } from "@api/APIClient";
import { IndexerKeys } from "@api/query_keys";
import { IndexersQueryOptions } from "@api/queries";
import { Checkbox } from "@components/Checkbox";
import Toast from "@components/notifications/Toast";
import { EmptySimple } from "@components/emptystates";
@ -18,14 +20,6 @@ import { componentMapType } from "@forms/settings/DownloadClientForms";
import { Section } from "./_components";
export const indexerKeys = {
all: ["indexers"] as const,
lists: () => [...indexerKeys.all, "list"] as const,
// list: (indexers: string[], sortOrder: string) => [...indexerKeys.lists(), { indexers, sortOrder }] as const,
details: () => [...indexerKeys.all, "detail"] as const,
detail: (id: number) => [...indexerKeys.details(), id] as const
};
interface SortConfig {
key: keyof ListItemProps["indexer"] | "enabled";
direction: "ascending" | "descending";
@ -123,7 +117,7 @@ const ListItem = ({ indexer }: ListItemProps) => {
const updateMutation = useMutation({
mutationFn: (enabled: boolean) => APIClient.indexers.toggleEnable(indexer.id, enabled),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: indexerKeys.lists() });
queryClient.invalidateQueries({ queryKey: IndexerKeys.lists() });
toast.custom((t) => <Toast type="success" body={`${indexer.name} was updated successfully`} t={t} />);
}
});
@ -169,17 +163,13 @@ const ListItem = ({ indexer }: ListItemProps) => {
function IndexerSettings() {
const [addIndexerIsOpen, toggleAddIndexer] = useToggle(false);
const { error, data } = useSuspenseQuery({
queryKey: indexerKeys.lists(),
queryFn: APIClient.indexers.getAll,
refetchOnWindowFocus: false
});
const indexersQuery = useSuspenseQuery(IndexersQueryOptions())
const indexers = indexersQuery.data
const sortedIndexers = useSort(indexers || []);
const sortedIndexers = useSort(data || []);
if (error) {
return (<p>An error has occurred</p>);
}
// if (error) {
// return (<p>An error has occurred</p>);
// }
return (
<Section

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Fragment, useRef, useState, useMemo, useEffect, MouseEvent } from "react";
import { Fragment, MouseEvent, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { LockClosedIcon, LockOpenIcon, PlusIcon } from "@heroicons/react/24/solid";
import { Menu, Transition } from "@headlessui/react";
@ -22,23 +22,16 @@ import { classNames, IsEmptyDate, simplifyDate } from "@utils";
import { IrcNetworkAddForm, IrcNetworkUpdateForm } from "@forms";
import { useToggle } from "@hooks/hooks";
import { APIClient } from "@api/APIClient";
import { IrcKeys } from "@api/query_keys";
import { IrcQueryOptions } from "@api/queries";
import { EmptySimple } from "@components/emptystates";
import { DeleteModal } from "@components/modals";
import Toast from "@components/notifications/Toast";
import { SettingsContext } from "@utils/Context";
import { Checkbox } from "@components/Checkbox";
// import { useForm } from "react-hook-form";
import { Section } from "./_components";
export const ircKeys = {
all: ["irc_networks"] as const,
lists: () => [...ircKeys.all, "list"] as const,
// list: (indexers: string[], sortOrder: string) => [...ircKeys.lists(), { indexers, sortOrder }] as const,
details: () => [...ircKeys.all, "detail"] as const,
detail: (id: number) => [...ircKeys.details(), id] as const
};
interface SortConfig {
key: keyof ListItemProps["network"] | "enabled";
direction: "ascending" | "descending";
@ -98,14 +91,9 @@ const IrcSettings = () => {
const [expandNetworks, toggleExpand] = useToggle(false);
const [addNetworkIsOpen, toggleAddNetwork] = useToggle(false);
const { data } = useSuspenseQuery({
queryKey: ircKeys.lists(),
queryFn: APIClient.irc.getNetworks,
refetchOnWindowFocus: false,
refetchInterval: 3000 // Refetch every 3 seconds
});
const ircQuery = useSuspenseQuery(IrcQueryOptions())
const sortedNetworks = useSort(data || []);
const sortedNetworks = useSort(ircQuery.data || []);
return (
<Section
@ -168,7 +156,7 @@ const IrcSettings = () => {
</div>
</div>
{data && data.length > 0 ? (
{ircQuery.data && ircQuery.data.length > 0 ? (
<ul className="mt-6 min-w-full relative text-sm">
<li className="grid grid-cols-12 gap-4 border-b border-gray-200 dark:border-gray-700 text-xs font-medium text-gray-500 dark:text-gray-400">
<div className="flex col-span-2 md:col-span-1 pl-2 sm:px-3 py-3 text-left uppercase tracking-wider cursor-pointer"
@ -218,7 +206,7 @@ const ListItem = ({ network, expanded }: ListItemProps) => {
const updateMutation = useMutation({
mutationFn: (network: IrcNetwork) => APIClient.irc.updateNetwork(network).then(() => network),
onSuccess: (network: IrcNetwork) => {
queryClient.invalidateQueries({ queryKey: ircKeys.lists() });
queryClient.invalidateQueries({ queryKey: IrcKeys.lists() });
toast.custom(t => <Toast type="success" body={`${network.name} was ${network.enabled ? "enabled" : "disabled"} successfully.`} t={t} />);
}
});
@ -431,8 +419,8 @@ const ListItemDropdown = ({
const deleteMutation = useMutation({
mutationFn: (id: number) => APIClient.irc.deleteNetwork(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ircKeys.lists() });
queryClient.invalidateQueries({ queryKey: ircKeys.detail(network.id) });
queryClient.invalidateQueries({ queryKey: IrcKeys.lists() });
queryClient.invalidateQueries({ queryKey: IrcKeys.detail(network.id) });
toast.custom((t) => <Toast type="success" body={`Network ${network.name} was deleted`} t={t} />);
@ -443,8 +431,8 @@ const ListItemDropdown = ({
const restartMutation = useMutation({
mutationFn: (id: number) => APIClient.irc.restartNetwork(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ircKeys.lists() });
queryClient.invalidateQueries({ queryKey: ircKeys.detail(network.id) });
queryClient.invalidateQueries({ queryKey: IrcKeys.lists() });
queryClient.invalidateQueries({ queryKey: IrcKeys.detail(network.id) });
toast.custom((t) => <Toast type="success" body={`${network.name} was successfully restarted`} t={t} />);
}

View file

@ -3,12 +3,15 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { toast } from "react-hot-toast";
import { Link } from "react-router-dom";
import Select from "react-select";
import { APIClient } from "@api/APIClient";
import { ConfigQueryOptions } from "@api/queries";
import { SettingsKeys } from "@api/query_keys";
import { SettingsLogRoute } from "@app/routes";
import Toast from "@components/notifications/Toast";
import { LogLevelOptions, SelectOption } from "@domain/constants";
@ -56,25 +59,19 @@ const SelectWrapper = ({ id, value, onChange, options }: SelectWrapperProps) =>
);
function LogSettings() {
const { isError, error, isLoading, data } = useSuspenseQuery({
queryKey: ["config"],
queryFn: APIClient.config.get,
retry: false,
refetchOnWindowFocus: false
});
const ctx = SettingsLogRoute.useRouteContext()
const queryClient = ctx.queryClient
if (isError) {
console.log(error);
}
const configQuery = useSuspenseQuery(ConfigQueryOptions())
const queryClient = useQueryClient();
const config = configQuery.data
const setLogLevelUpdateMutation = useMutation({
mutationFn: (value: string) => APIClient.config.update({ log_level: value }),
onSuccess: () => {
toast.custom((t) => <Toast type="success" body={"Config successfully updated!"} t={t} />);
queryClient.invalidateQueries({ queryKey: ["config"] });
queryClient.invalidateQueries({ queryKey: SettingsKeys.config() });
}
});
@ -86,7 +83,7 @@ function LogSettings() {
Configure log level, log size rotation, etc. You can download your old log files
{" "}
<Link
to="/logs"
to="/settings/logs"
className="text-gray-700 dark:text-gray-200 underline font-semibold underline-offset-2 decoration-blue-500 decoration hover:text-black hover:dark:text-gray-100"
>
on the Logs page
@ -96,9 +93,9 @@ function LogSettings() {
>
<div className="-mx-4 lg:col-span-9">
<div className="divide-y divide-gray-200 dark:divide-gray-750">
{!isLoading && data && (
{!configQuery.isLoading && config && (
<form className="divide-y divide-gray-200 dark:divide-gray-750" action="#" method="POST">
<RowItem label="Path" value={data?.log_path} title="Set in config.toml" emptyText="Not set!"/>
<RowItem label="Path" value={config?.log_path} title="Set in config.toml" emptyText="Not set!"/>
<RowItem
className="sm:col-span-1"
label="Level"
@ -106,14 +103,14 @@ function LogSettings() {
value={
<SelectWrapper
id="log_level"
value={data?.log_level}
value={config?.log_level}
options={LogLevelOptions}
onChange={(value: SelectOption) => setLogLevelUpdateMutation.mutate(value.value)}
/>
}
/>
<RowItem label="Max Size" value={data?.log_max_size} title="Set in config.toml" rightSide="MB"/>
<RowItem label="Max Backups" value={data?.log_max_backups} title="Set in config.toml"/>
<RowItem label="Max Size" value={config?.log_max_size} title="Set in config.toml" rightSide="MB"/>
<RowItem label="Max Backups" value={config?.log_max_backups} title="Set in config.toml"/>
</form>
)}

View file

@ -4,35 +4,33 @@
*/
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { PlusIcon } from "@heroicons/react/24/solid";
import toast from "react-hot-toast";
import { APIClient } from "@api/APIClient";
import { NotificationKeys } from "@api/query_keys";
import { NotificationsQueryOptions } from "@api/queries";
import { EmptySimple } from "@components/emptystates";
import { useToggle } from "@hooks/hooks";
import { NotificationAddForm, NotificationUpdateForm } from "@forms/settings/NotificationForms";
import { componentMapType } from "@forms/settings/DownloadClientForms";
import Toast from "@components/notifications/Toast";
import toast from "react-hot-toast";
import { Section } from "./_components";
import { PlusIcon } from "@heroicons/react/24/solid";
import {
DiscordIcon,
GotifyIcon,
LunaSeaIcon,
NotifiarrIcon,
NtfyIcon,
PushoverIcon,
Section,
TelegramIcon
} from "./_components";
import { Checkbox } from "@components/Checkbox";
import { DiscordIcon, GotifyIcon, LunaSeaIcon, NotifiarrIcon, NtfyIcon, PushoverIcon, TelegramIcon } from "./_components";
export const notificationKeys = {
all: ["notifications"] as const,
lists: () => [...notificationKeys.all, "list"] as const,
details: () => [...notificationKeys.all, "detail"] as const,
detail: (id: number) => [...notificationKeys.details(), id] as const
};
function NotificationSettings() {
const [addNotificationsIsOpen, toggleAddNotifications] = useToggle(false);
const { data } = useSuspenseQuery({
queryKey: notificationKeys.lists(),
queryFn: APIClient.notifications.getAll,
refetchOnWindowFocus: false
}
);
const notificationsQuery = useSuspenseQuery(NotificationsQueryOptions())
return (
<Section
@ -51,7 +49,7 @@ function NotificationSettings() {
>
<NotificationAddForm isOpen={addNotificationsIsOpen} toggle={toggleAddNotifications} />
{data && data.length > 0 ? (
{notificationsQuery.data && notificationsQuery.data.length > 0 ? (
<ul className="min-w-full">
<li className="grid grid-cols-12 border-b border-gray-200 dark:border-gray-700">
<div className="col-span-2 sm:col-span-1 pl-1 sm:pl-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Enabled</div>
@ -60,7 +58,7 @@ function NotificationSettings() {
<div className="hidden md:flex col-span-3 px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Events</div>
</li>
{data.map((n) => <ListItem key={n.id} notification={n} />)}
{notificationsQuery.data.map((n) => <ListItem key={n.id} notification={n} />)}
</ul>
) : (
<EmptySimple title="No notifications" subtitle="" buttonText="Create new notification" buttonAction={toggleAddNotifications} />
@ -94,7 +92,7 @@ function ListItem({ notification }: ListItemProps) {
mutationFn: (notification: ServiceNotification) => APIClient.notifications.update(notification).then(() => notification),
onSuccess: (notification: ServiceNotification) => {
toast.custom(t => <Toast type="success" body={`${notification.name} was ${notification.enabled ? "enabled" : "disabled"} successfully.`} t={t} />);
queryClient.invalidateQueries({ queryKey: notificationKeys.lists() });
queryClient.invalidateQueries({ queryKey: NotificationKeys.lists() });
}
});

View file

@ -8,8 +8,8 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "react-hot-toast";
import { APIClient } from "@api/APIClient";
import { ReleaseKeys } from "@api/query_keys";
import Toast from "@components/notifications/Toast";
import { releaseKeys } from "@screens/releases/ReleaseTable";
import { useToggle } from "@hooks/hooks";
import { DeleteModal } from "@components/modals";
import { Section } from "./_components";
@ -74,7 +74,7 @@ function DeleteReleases() {
}
// Invalidate filters just in case, most likely not necessary but can't hurt.
queryClient.invalidateQueries({ queryKey: releaseKeys.lists() });
queryClient.invalidateQueries({ queryKey: ReleaseKeys.lists() });
}
});

View file

@ -3,13 +3,8 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { newRidgeState } from "react-ridge-state";
import type { StateWithValue } from "react-ridge-state";
interface AuthInfo {
username: string;
isLoggedIn: boolean;
}
import { newRidgeState } from "react-ridge-state";
interface SettingsType {
debug: boolean;
@ -26,11 +21,16 @@ export type FilterListState = {
status: string;
};
// interface AuthInfo {
// username: string;
// isLoggedIn: boolean;
// }
// Default values
const AuthContextDefaults: AuthInfo = {
username: "",
isLoggedIn: false
};
// const AuthContextDefaults: AuthInfo = {
// username: "",
// isLoggedIn: false
// };
const SettingsContextDefaults: SettingsType = {
debug: false,
@ -53,7 +53,7 @@ function ContextMerger<T extends {}>(
defaults: T,
ctxState: StateWithValue<T>
) {
let values = defaults;
let values = structuredClone(defaults);
const storage = localStorage.getItem(key);
if (storage) {
@ -62,7 +62,7 @@ function ContextMerger<T extends {}>(
if (json === null) {
console.warn(`JSON localStorage value for '${key}' context state is null`);
} else {
values = { ...defaults, ...json };
values = { ...values, ...json };
}
} catch (e) {
console.error(`Failed to merge ${key} context state: ${e}`);
@ -72,15 +72,18 @@ function ContextMerger<T extends {}>(
ctxState.set(values);
}
const SettingsKey = "autobrr_settings";
const FilterListKey = "autobrr_filter_list";
export const InitializeGlobalContext = () => {
ContextMerger<AuthInfo>("auth", AuthContextDefaults, AuthContext);
// ContextMerger<AuthInfo>(localStorageUserKey, AuthContextDefaults, AuthContextt);
ContextMerger<SettingsType>(
"settings",
SettingsKey,
SettingsContextDefaults,
SettingsContext
);
ContextMerger<FilterListState>(
"filterList",
FilterListKey,
FilterListContextDefaults,
FilterListContext
);
@ -98,16 +101,16 @@ function DefaultSetter<T>(name: string, newState: T, prevState: T) {
}
}
export const AuthContext = newRidgeState<AuthInfo>(AuthContextDefaults, {
onSet: (newState, prevState) => DefaultSetter("auth", newState, prevState)
});
// export const AuthContextt = newRidgeState<AuthInfo>(AuthContextDefaults, {
// onSet: (newState, prevState) => DefaultSetter(localStorageUserKey, newState, prevState)
// });
export const SettingsContext = newRidgeState<SettingsType>(
SettingsContextDefaults,
{
onSet: (newState, prevState) => {
document.documentElement.classList.toggle("dark", newState.darkTheme);
DefaultSetter("settings", newState, prevState);
DefaultSetter(SettingsKey, newState, prevState);
}
}
);
@ -115,6 +118,32 @@ export const SettingsContext = newRidgeState<SettingsType>(
export const FilterListContext = newRidgeState<FilterListState>(
FilterListContextDefaults,
{
onSet: (newState, prevState) => DefaultSetter("filterList", newState, prevState)
onSet: (newState, prevState) => DefaultSetter(FilterListKey, newState, prevState)
}
);
export type AuthCtx = {
isLoggedIn: boolean
username?: string
login: (username: string) => void
logout: () => void
}
export const localStorageUserKey = "autobrr_user_auth"
export const AuthContext: AuthCtx = {
isLoggedIn: false,
username: undefined,
login: (username: string) => {
AuthContext.isLoggedIn = true
AuthContext.username = username
localStorage.setItem(localStorageUserKey, JSON.stringify(AuthContext));
},
logout: () => {
AuthContext.isLoggedIn = false
AuthContext.username = undefined
localStorage.removeItem(localStorageUserKey);
},
}

View file

@ -12,7 +12,7 @@ export function sleep(ms: number) {
// get baseUrl sent from server rendered index template
export function baseUrl() {
let baseUrl = "";
let baseUrl = "/";
if (window.APP.baseUrl) {
if (window.APP.baseUrl === "{{.BaseUrl}}") {
baseUrl = "/";
@ -23,6 +23,20 @@ export function baseUrl() {
return baseUrl;
}
// get routerBasePath sent from server rendered index template
// routerBasePath is used for RouterProvider and does not need work with trailing slash
export function routerBasePath() {
let baseUrl = "";
if (window.APP.baseUrl) {
if (window.APP.baseUrl === "{{.BaseUrl}}") {
baseUrl = "";
} else {
baseUrl = window.APP.baseUrl;
}
}
return baseUrl;
}
// get sseBaseUrl for SSE
export function sseBaseUrl() {
if (process.env.NODE_ENV === "development")