mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 08:49:13 +00:00
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:
parent
cc9656cd41
commit
1a23b69bcf
64 changed files with 2543 additions and 2091 deletions
|
@ -12,13 +12,11 @@ import (
|
||||||
|
|
||||||
"github.com/autobrr/autobrr/internal/domain"
|
"github.com/autobrr/autobrr/internal/domain"
|
||||||
"github.com/autobrr/autobrr/internal/logger"
|
"github.com/autobrr/autobrr/internal/logger"
|
||||||
"github.com/autobrr/autobrr/pkg/cmp"
|
|
||||||
"github.com/autobrr/autobrr/pkg/errors"
|
"github.com/autobrr/autobrr/pkg/errors"
|
||||||
|
|
||||||
sq "github.com/Masterminds/squirrel"
|
sq "github.com/Masterminds/squirrel"
|
||||||
"github.com/lib/pq"
|
"github.com/lib/pq"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"golang.org/x/exp/slices"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type FilterRepo struct {
|
type FilterRepo struct {
|
||||||
|
@ -245,25 +243,8 @@ func (r *FilterRepo) FindByID(ctx context.Context, filterID int) (*domain.Filter
|
||||||
"f.max_leechers",
|
"f.max_leechers",
|
||||||
"f.created_at",
|
"f.created_at",
|
||||||
"f.updated_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").
|
From("filter f").
|
||||||
LeftJoin("filter_external fe ON f.id = fe.filter_id").
|
|
||||||
Where(sq.Eq{"f.id": filterID})
|
Where(sq.Eq{"f.id": filterID})
|
||||||
|
|
||||||
query, args, err := queryBuilder.ToSql()
|
query, args, err := queryBuilder.ToSql()
|
||||||
|
@ -271,30 +252,24 @@ func (r *FilterRepo) FindByID(ctx context.Context, filterID int) (*domain.Filter
|
||||||
return nil, errors.Wrap(err, "error building query")
|
return nil, errors.Wrap(err, "error building query")
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := r.db.handler.QueryContext(ctx, query, args...)
|
row := r.db.handler.QueryRowContext(ctx, query, args...)
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if row.Err() != nil {
|
||||||
|
if errors.Is(row.Err(), sql.ErrNoRows) {
|
||||||
return nil, domain.ErrRecordNotFound
|
return nil, domain.ErrRecordNotFound
|
||||||
}
|
}
|
||||||
return nil, errors.Wrap(err, "error executing query")
|
|
||||||
|
return nil, errors.Wrap(row.Err(), "error row")
|
||||||
}
|
}
|
||||||
|
|
||||||
var f domain.Filter
|
var f domain.Filter
|
||||||
|
|
||||||
externalMap := make(map[int]domain.FilterExternal)
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
// filter
|
// 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 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 useRegex, scene, freeleech, hasLog, hasCue, perfectFlac sql.NullBool
|
||||||
var delay, maxDownloads, logScore sql.NullInt32
|
var delay, maxDownloads, logScore sql.NullInt32
|
||||||
|
|
||||||
// filter external
|
err = row.Scan(
|
||||||
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.ID,
|
||||||
&f.Enabled,
|
&f.Enabled,
|
||||||
&f.Name,
|
&f.Name,
|
||||||
|
@ -359,23 +334,12 @@ func (r *FilterRepo) FindByID(ctx context.Context, filterID int) (*domain.Filter
|
||||||
&f.MaxLeechers,
|
&f.MaxLeechers,
|
||||||
&f.CreatedAt,
|
&f.CreatedAt,
|
||||||
&f.UpdatedAt,
|
&f.UpdatedAt,
|
||||||
&extId,
|
)
|
||||||
&extName,
|
if err != nil {
|
||||||
&extIndex,
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
&extType,
|
return nil, domain.ErrRecordNotFound
|
||||||
&extEnabled,
|
}
|
||||||
&extExecCmd,
|
|
||||||
&extExecArgs,
|
|
||||||
&extExecStatus,
|
|
||||||
&extWebhookHost,
|
|
||||||
&extWebhookMethod,
|
|
||||||
&extWebhookData,
|
|
||||||
&extWebhookHeaders,
|
|
||||||
&extWebhookStatus,
|
|
||||||
&extWebhookRetryStatus,
|
|
||||||
&extWebhookRetryAttempts,
|
|
||||||
&extWebhookDelaySeconds,
|
|
||||||
); err != nil {
|
|
||||||
return nil, errors.Wrap(err, "error scanning row")
|
return nil, errors.Wrap(err, "error scanning row")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -415,33 +379,6 @@ func (r *FilterRepo) FindByID(ctx context.Context, filterID int) (*domain.Filter
|
||||||
f.Scene = scene.Bool
|
f.Scene = scene.Bool
|
||||||
f.Freeleech = freeleech.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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, external := range externalMap {
|
|
||||||
f.External = append(f.External, external)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &f, nil
|
return &f, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -517,28 +454,10 @@ func (r *FilterRepo) findByIndexerIdentifier(ctx context.Context, indexer string
|
||||||
"f.max_leechers",
|
"f.max_leechers",
|
||||||
"f.created_at",
|
"f.created_at",
|
||||||
"f.updated_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").
|
From("filter f").
|
||||||
Join("filter_indexer fi ON f.id = fi.filter_id").
|
Join("filter_indexer fi ON f.id = fi.filter_id").
|
||||||
Join("indexer i ON i.id = fi.indexer_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.identifier": indexer}).
|
||||||
Where(sq.Eq{"i.enabled": true}).
|
Where(sq.Eq{"i.enabled": true}).
|
||||||
Where(sq.Eq{"f.enabled": true}).
|
Where(sq.Eq{"f.enabled": true}).
|
||||||
|
@ -556,7 +475,7 @@ func (r *FilterRepo) findByIndexerIdentifier(ctx context.Context, indexer string
|
||||||
|
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
filtersMap := make(map[int]*domain.Filter)
|
var filters []*domain.Filter
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var f domain.Filter
|
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 useRegex, scene, freeleech, hasLog, hasCue, perfectFlac sql.NullBool
|
||||||
var delay, maxDownloads, logScore sql.NullInt32
|
var delay, maxDownloads, logScore sql.NullInt32
|
||||||
|
|
||||||
// filter external
|
err := rows.Scan(
|
||||||
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(
|
|
||||||
&f.ID,
|
&f.ID,
|
||||||
&f.Enabled,
|
&f.Enabled,
|
||||||
&f.Name,
|
&f.Name,
|
||||||
|
@ -635,29 +549,11 @@ func (r *FilterRepo) findByIndexerIdentifier(ctx context.Context, indexer string
|
||||||
&f.MaxLeechers,
|
&f.MaxLeechers,
|
||||||
&f.CreatedAt,
|
&f.CreatedAt,
|
||||||
&f.UpdatedAt,
|
&f.UpdatedAt,
|
||||||
&extId,
|
)
|
||||||
&extName,
|
if err != nil {
|
||||||
&extIndex,
|
|
||||||
&extType,
|
|
||||||
&extEnabled,
|
|
||||||
&extExecCmd,
|
|
||||||
&extExecArgs,
|
|
||||||
&extExecStatus,
|
|
||||||
&extWebhookHost,
|
|
||||||
&extWebhookMethod,
|
|
||||||
&extWebhookData,
|
|
||||||
&extWebhookHeaders,
|
|
||||||
&extWebhookStatus,
|
|
||||||
&extWebhookRetryStatus,
|
|
||||||
&extWebhookRetryAttempts,
|
|
||||||
&extWebhookDelaySeconds,
|
|
||||||
&extFilterId,
|
|
||||||
); err != nil {
|
|
||||||
return nil, errors.Wrap(err, "error scanning row")
|
return nil, errors.Wrap(err, "error scanning row")
|
||||||
}
|
}
|
||||||
|
|
||||||
filter, ok := filtersMap[f.ID]
|
|
||||||
if !ok {
|
|
||||||
f.MinSize = minSize.String
|
f.MinSize = minSize.String
|
||||||
f.MaxSize = maxSize.String
|
f.MaxSize = maxSize.String
|
||||||
f.Delay = int(delay.Int32)
|
f.Delay = int(delay.Int32)
|
||||||
|
@ -696,47 +592,9 @@ func (r *FilterRepo) findByIndexerIdentifier(ctx context.Context, indexer string
|
||||||
|
|
||||||
f.Rejections = []string{}
|
f.Rejections = []string{}
|
||||||
|
|
||||||
filter = &f
|
filters = append(filters, &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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
return filters, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1232,39 +1090,6 @@ func (r *FilterRepo) UpdatePartial(ctx context.Context, filter domain.FilterUpda
|
||||||
if filter.ExceptOrigins != nil {
|
if filter.ExceptOrigins != nil {
|
||||||
q = q.Set("except_origins", pq.Array(filter.ExceptOrigins))
|
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 {
|
if filter.MinSeeders != nil {
|
||||||
q = q.Set("min_seeders", filter.MinSeeders)
|
q = q.Set("min_seeders", filter.MinSeeders)
|
||||||
}
|
}
|
||||||
|
|
|
@ -205,11 +205,10 @@ func TestFilterRepo_Delete(t *testing.T) {
|
||||||
err = repo.Delete(context.Background(), createdFilters[0].ID)
|
err = repo.Delete(context.Background(), createdFilters[0].ID)
|
||||||
assert.NoError(t, err)
|
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)
|
filter, err := repo.FindByID(context.Background(), createdFilters[0].ID)
|
||||||
assert.NoError(t, err)
|
assert.ErrorIs(t, err, domain.ErrRecordNotFound)
|
||||||
assert.NotNil(t, filter)
|
assert.Nil(t, filter)
|
||||||
assert.Equal(t, 0, filter.ID)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run(fmt.Sprintf("Delete_Fails_No_Record [%s]", dbType), func(t *testing.T) {
|
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)
|
_ = 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) {
|
t.Run(fmt.Sprintf("FindByID_Fails_Invalid_ID [%s]", dbType), func(t *testing.T) {
|
||||||
// Test using an invalid ID
|
// Test using an invalid ID
|
||||||
filter, err := repo.FindByID(context.Background(), -1)
|
filter, err := repo.FindByID(context.Background(), -1)
|
||||||
assert.NoError(t, err) // should return an error
|
assert.ErrorIs(t, err, domain.ErrRecordNotFound) // should return an error
|
||||||
assert.NotNil(t, filter) // should be nil
|
assert.Nil(t, filter) // should be nil
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -240,17 +240,6 @@ type FilterUpdate struct {
|
||||||
MaxSeeders *int `json:"max_seeders,omitempty"`
|
MaxSeeders *int `json:"max_seeders,omitempty"`
|
||||||
MinLeechers *int `json:"min_leechers,omitempty"`
|
MinLeechers *int `json:"min_leechers,omitempty"`
|
||||||
MaxLeechers *int `json:"max_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"`
|
Actions []*Action `json:"actions,omitempty"`
|
||||||
External []FilterExternal `json:"external,omitempty"`
|
External []FilterExternal `json:"external,omitempty"`
|
||||||
Indexers []Indexer `json:"indexers,omitempty"`
|
Indexers []Indexer `json:"indexers,omitempty"`
|
||||||
|
|
|
@ -124,6 +124,12 @@ func (s *service) FindByID(ctx context.Context, filterID int) (*domain.Filter, e
|
||||||
return nil, err
|
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)
|
actions, err := s.actionRepo.FindByFilterID(ctx, filter.ID, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error().Err(err).Msgf("could not find filter actions for filter id: %v", filter.ID)
|
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) {
|
func (s *service) FindByIndexerIdentifier(ctx context.Context, indexer string) ([]*domain.Filter, error) {
|
||||||
// get filters for indexer
|
// 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
|
// we do not load actions here since we do not need it at this stage
|
||||||
// only load those after filter has matched
|
// 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) {
|
func (s *service) GetDownloadsByFilterId(ctx context.Context, filterID int) (*domain.FilterDownloads, error) {
|
||||||
|
|
|
@ -21,6 +21,7 @@ import (
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
type authServiceMock struct {
|
type authServiceMock struct {
|
||||||
|
@ -144,9 +145,7 @@ func TestAuthHandlerLogin(t *testing.T) {
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// check for response, here we'll just check for 204 NoContent
|
// check for response, here we'll just check for 204 NoContent
|
||||||
if status := resp.StatusCode; status != http.StatusNoContent {
|
assert.Equalf(t, http.StatusNoContent, resp.StatusCode, "login handler: unexpected http status")
|
||||||
t.Errorf("login: handler returned wrong status code: got %v want %v", status, http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
if v := resp.Header.Get("Set-Cookie"); v == "" {
|
if v := resp.Header.Get("Set-Cookie"); v == "" {
|
||||||
t.Errorf("handler returned no cookie")
|
t.Errorf("handler returned no cookie")
|
||||||
|
@ -207,12 +206,10 @@ func TestAuthHandlerValidateOK(t *testing.T) {
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// check for response, here we'll just check for 204 NoContent
|
// check for response, here we'll just check for 204 NoContent
|
||||||
if status := resp.StatusCode; status != http.StatusNoContent {
|
assert.Equalf(t, http.StatusNoContent, resp.StatusCode, "login handler: bad response")
|
||||||
t.Errorf("login: handler returned wrong status code: got %v want %v", status, http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
if v := resp.Header.Get("Set-Cookie"); v == "" {
|
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
|
// validate token
|
||||||
|
@ -223,9 +220,7 @@ func TestAuthHandlerValidateOK(t *testing.T) {
|
||||||
|
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if status := resp.StatusCode; status != http.StatusNoContent {
|
assert.Equalf(t, http.StatusNoContent, resp.StatusCode, "validate handler: unexpected http status")
|
||||||
t.Errorf("validate: handler returned wrong status code: got %v want %v", status, http.StatusNoContent)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthHandlerValidateBad(t *testing.T) {
|
func TestAuthHandlerValidateBad(t *testing.T) {
|
||||||
|
@ -272,9 +267,7 @@ func TestAuthHandlerValidateBad(t *testing.T) {
|
||||||
|
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if status := resp.StatusCode; status != http.StatusNoContent {
|
assert.Equalf(t, http.StatusForbidden, resp.StatusCode, "validate handler: unexpected http status")
|
||||||
t.Errorf("validate: handler returned wrong status code: got %v want %v", status, http.StatusNoContent)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthHandlerLoginBad(t *testing.T) {
|
func TestAuthHandlerLoginBad(t *testing.T) {
|
||||||
|
@ -321,9 +314,7 @@ func TestAuthHandlerLoginBad(t *testing.T) {
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// check for response, here we'll just check for 403 Forbidden
|
// check for response, here we'll just check for 403 Forbidden
|
||||||
if status := resp.StatusCode; status != http.StatusForbidden {
|
assert.Equalf(t, http.StatusForbidden, resp.StatusCode, "login handler: unexpected http status")
|
||||||
t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusForbidden)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthHandlerLogout(t *testing.T) {
|
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)
|
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 == "" {
|
if v := resp.Header.Get("Set-Cookie"); v == "" {
|
||||||
t.Errorf("handler returned no cookie")
|
t.Errorf("handler returned no cookie")
|
||||||
}
|
}
|
||||||
|
@ -396,9 +389,7 @@ func TestAuthHandlerLogout(t *testing.T) {
|
||||||
|
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if status := resp.StatusCode; status != http.StatusNoContent {
|
assert.Equalf(t, http.StatusNoContent, resp.StatusCode, "validate handler: unexpected http status")
|
||||||
t.Errorf("validate: handler returned wrong status code: got %v want %v", status, http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// logout
|
// logout
|
||||||
resp, err = client.Post(testServer.URL+"/auth/logout", "application/json", nil)
|
resp, err = client.Post(testServer.URL+"/auth/logout", "application/json", nil)
|
||||||
|
@ -408,9 +399,7 @@ func TestAuthHandlerLogout(t *testing.T) {
|
||||||
|
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if status := resp.StatusCode; status != http.StatusNoContent {
|
assert.Equalf(t, http.StatusNoContent, resp.StatusCode, "logout handler: unexpected http status")
|
||||||
t.Errorf("logout: handler returned wrong status code: got %v want %v", status, http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
//if v := resp.Header.Get("Set-Cookie"); v != "" {
|
//if v := resp.Header.Get("Set-Cookie"); v != "" {
|
||||||
// t.Errorf("logout handler returned cookie")
|
// t.Errorf("logout handler returned cookie")
|
||||||
|
|
|
@ -67,6 +67,16 @@ func (e encoder) StatusNotFound(w http.ResponseWriter) {
|
||||||
w.WriteHeader(http.StatusNotFound)
|
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) {
|
func (e encoder) StatusInternalError(w http.ResponseWriter) {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
|
@ -119,7 +119,7 @@ func (h filterHandler) getByID(w http.ResponseWriter, r *http.Request) {
|
||||||
filter, err := h.service.FindByID(ctx, id)
|
filter, err := h.service.FindByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, domain.ErrRecordNotFound) {
|
if errors.Is(err, domain.ErrRecordNotFound) {
|
||||||
h.encoder.StatusNotFound(w)
|
h.encoder.NotFoundErr(w, errors.New("filter with id %d not found", id))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -38,7 +38,6 @@ func (s Server) IsAuthenticated(next http.Handler) http.Handler {
|
||||||
|
|
||||||
// MaxAge<0 means delete cookie immediately
|
// MaxAge<0 means delete cookie immediately
|
||||||
session.Options.MaxAge = -1
|
session.Options.MaxAge = -1
|
||||||
|
|
||||||
session.Options.Path = s.config.Config.BaseURL
|
session.Options.Path = s.config.Config.BaseURL
|
||||||
|
|
||||||
if err := session.Save(r, w); err != nil {
|
if err := session.Save(r, w); err != nil {
|
||||||
|
@ -50,13 +49,10 @@ func (s Server) IsAuthenticated(next http.Handler) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if session.IsNew {
|
|
||||||
http.Error(w, http.StatusText(http.StatusNoContent), http.StatusNoContent)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is authenticated
|
// Check if user is authenticated
|
||||||
if auth, ok := session.Values["authenticated"].(bool); !ok || !auth {
|
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)
|
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,11 +39,11 @@
|
||||||
"@tailwindcss/forms": "^0.5.7",
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
"@tanstack/react-query": "^5.17.19",
|
"@tanstack/react-query": "^5.17.19",
|
||||||
"@tanstack/react-query-devtools": "^5.8.4",
|
"@tanstack/react-query-devtools": "^5.8.4",
|
||||||
|
"@tanstack/react-router": "^1.16.0",
|
||||||
"@types/node": "^20.11.6",
|
"@types/node": "^20.11.6",
|
||||||
"@types/react": "^18.2.48",
|
"@types/react": "^18.2.48",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.18",
|
||||||
"@types/react-portal": "^4.0.7",
|
"@types/react-portal": "^4.0.7",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
|
||||||
"@types/react-table": "^7.7.19",
|
"@types/react-table": "^7.7.19",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.19.1",
|
"@typescript-eslint/eslint-plugin": "^6.19.1",
|
||||||
"@typescript-eslint/parser": "^6.19.1",
|
"@typescript-eslint/parser": "^6.19.1",
|
||||||
|
@ -64,7 +64,6 @@
|
||||||
"react-popper-tooltip": "^4.4.2",
|
"react-popper-tooltip": "^4.4.2",
|
||||||
"react-portal": "^4.2.2",
|
"react-portal": "^4.2.2",
|
||||||
"react-ridge-state": "4.2.9",
|
"react-ridge-state": "4.2.9",
|
||||||
"react-router-dom": "6.21.3",
|
|
||||||
"react-select": "^5.8.0",
|
"react-select": "^5.8.0",
|
||||||
"react-table": "^7.8.0",
|
"react-table": "^7.8.0",
|
||||||
"react-textarea-autosize": "^8.5.3",
|
"react-textarea-autosize": "^8.5.3",
|
||||||
|
@ -78,6 +77,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@microsoft/eslint-formatter-sarif": "^3.0.0",
|
"@microsoft/eslint-formatter-sarif": "^3.0.0",
|
||||||
"@rollup/wasm-node": "^4.9.6",
|
"@rollup/wasm-node": "^4.9.6",
|
||||||
|
"@tanstack/router-devtools": "^1.1.4",
|
||||||
"@types/node": "^20.11.6",
|
"@types/node": "^20.11.6",
|
||||||
"@types/react": "^18.2.48",
|
"@types/react": "^18.2.48",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.18",
|
||||||
|
|
209
web/pnpm-lock.yaml
generated
209
web/pnpm-lock.yaml
generated
|
@ -30,6 +30,9 @@ dependencies:
|
||||||
'@tanstack/react-query-devtools':
|
'@tanstack/react-query-devtools':
|
||||||
specifier: ^5.8.4
|
specifier: ^5.8.4
|
||||||
version: 5.8.4(@tanstack/react-query@5.17.19)(react-dom@18.2.0)(react@18.2.0)
|
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':
|
'@types/node':
|
||||||
specifier: ^20.11.6
|
specifier: ^20.11.6
|
||||||
version: 20.11.6
|
version: 20.11.6
|
||||||
|
@ -42,9 +45,6 @@ dependencies:
|
||||||
'@types/react-portal':
|
'@types/react-portal':
|
||||||
specifier: ^4.0.7
|
specifier: ^4.0.7
|
||||||
version: 4.0.7
|
version: 4.0.7
|
||||||
'@types/react-router-dom':
|
|
||||||
specifier: ^5.3.3
|
|
||||||
version: 5.3.3
|
|
||||||
'@types/react-table':
|
'@types/react-table':
|
||||||
specifier: ^7.7.19
|
specifier: ^7.7.19
|
||||||
version: 7.7.19
|
version: 7.7.19
|
||||||
|
@ -105,9 +105,6 @@ dependencies:
|
||||||
react-ridge-state:
|
react-ridge-state:
|
||||||
specifier: 4.2.9
|
specifier: 4.2.9
|
||||||
version: 4.2.9(react@18.2.0)
|
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:
|
react-select:
|
||||||
specifier: ^5.8.0
|
specifier: ^5.8.0
|
||||||
version: 5.8.0(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.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':
|
'@rollup/wasm-node':
|
||||||
specifier: ^4.9.6
|
specifier: ^4.9.6
|
||||||
version: 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:
|
eslint:
|
||||||
specifier: ^8.56.0
|
specifier: ^8.56.0
|
||||||
version: 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)
|
version: 0.17.5(vite@5.0.12)(workbox-build@7.0.0)(workbox-window@7.0.0)
|
||||||
vite-plugin-svgr:
|
vite-plugin-svgr:
|
||||||
specifier: ^4.2.0
|
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:
|
packages:
|
||||||
|
|
||||||
|
@ -1432,7 +1432,6 @@ packages:
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
dependencies:
|
dependencies:
|
||||||
regenerator-runtime: 0.14.1
|
regenerator-runtime: 0.14.1
|
||||||
dev: false
|
|
||||||
|
|
||||||
/@babel/runtime@7.23.9:
|
/@babel/runtime@7.23.9:
|
||||||
resolution: {integrity: sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==}
|
resolution: {integrity: sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==}
|
||||||
|
@ -1982,12 +1981,7 @@ packages:
|
||||||
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
|
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/@remix-run/router@1.14.2:
|
/@rollup/plugin-babel@5.3.1(@babel/core@7.23.9)(@rollup/wasm-node@4.10.0):
|
||||||
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):
|
|
||||||
resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==}
|
resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -2000,36 +1994,36 @@ packages:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.23.9
|
'@babel/core': 7.23.9
|
||||||
'@babel/helper-module-imports': 7.22.15
|
'@babel/helper-module-imports': 7.22.15
|
||||||
'@rollup/pluginutils': 3.1.0(@rollup/wasm-node@4.9.6)
|
'@rollup/pluginutils': 3.1.0(@rollup/wasm-node@4.10.0)
|
||||||
rollup: /@rollup/wasm-node@4.9.6
|
rollup: /@rollup/wasm-node@4.10.0
|
||||||
dev: true
|
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==}
|
resolution: {integrity: sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
rollup: npm:@rollup/wasm-node
|
rollup: npm:@rollup/wasm-node
|
||||||
dependencies:
|
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
|
'@types/resolve': 1.17.1
|
||||||
builtin-modules: 3.3.0
|
builtin-modules: 3.3.0
|
||||||
deepmerge: 4.3.1
|
deepmerge: 4.3.1
|
||||||
is-module: 1.0.0
|
is-module: 1.0.0
|
||||||
resolve: 1.22.8
|
resolve: 1.22.8
|
||||||
rollup: /@rollup/wasm-node@4.9.6
|
rollup: /@rollup/wasm-node@4.10.0
|
||||||
dev: true
|
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==}
|
resolution: {integrity: sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
rollup: npm:@rollup/wasm-node
|
rollup: npm:@rollup/wasm-node
|
||||||
dependencies:
|
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
|
magic-string: 0.25.9
|
||||||
rollup: /@rollup/wasm-node@4.9.6
|
rollup: /@rollup/wasm-node@4.10.0
|
||||||
dev: true
|
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==}
|
resolution: {integrity: sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==}
|
||||||
engines: {node: '>= 8.0.0'}
|
engines: {node: '>= 8.0.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -2038,10 +2032,10 @@ packages:
|
||||||
'@types/estree': 0.0.39
|
'@types/estree': 0.0.39
|
||||||
estree-walker: 1.0.1
|
estree-walker: 1.0.1
|
||||||
picomatch: 2.3.1
|
picomatch: 2.3.1
|
||||||
rollup: /@rollup/wasm-node@4.9.6
|
rollup: /@rollup/wasm-node@4.10.0
|
||||||
dev: true
|
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==}
|
resolution: {integrity: sha512-6aEYR910NyP73oHiJglti74iRyOwgFU4x3meH/H8OJx6Ry0j6cOVZ5X/wTvub7G7Ao6qaHBEaNsV3GLJkSsF+Q==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -2053,9 +2047,18 @@ packages:
|
||||||
'@types/estree': 1.0.5
|
'@types/estree': 1.0.5
|
||||||
estree-walker: 2.0.2
|
estree-walker: 2.0.2
|
||||||
picomatch: 2.3.1
|
picomatch: 2.3.1
|
||||||
rollup: /@rollup/wasm-node@4.9.6
|
rollup: /@rollup/wasm-node@4.10.0
|
||||||
dev: true
|
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:
|
/@rollup/wasm-node@4.9.6:
|
||||||
resolution: {integrity: sha512-B3FpAkroTE6q+MRHzv8XLBgPbxdjJiy5UnduZNQ/4lxeF1JT2O/OAr0JPpXeRG/7zpKm/kdqU/4m6AULhmnSqw==}
|
resolution: {integrity: sha512-B3FpAkroTE6q+MRHzv8XLBgPbxdjJiy5UnduZNQ/4lxeF1JT2O/OAr0JPpXeRG/7zpKm/kdqU/4m6AULhmnSqw==}
|
||||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
|
@ -2064,6 +2067,7 @@ packages:
|
||||||
'@types/estree': 1.0.5
|
'@types/estree': 1.0.5
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@surma/rollup-plugin-off-main-thread@2.2.3:
|
/@surma/rollup-plugin-off-main-thread@2.2.3:
|
||||||
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
|
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
|
||||||
|
@ -2332,6 +2336,16 @@ packages:
|
||||||
tailwindcss: 3.4.1(ts-node@10.9.2)
|
tailwindcss: 3.4.1(ts-node@10.9.2)
|
||||||
dev: false
|
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:
|
/@tanstack/query-core@5.17.19:
|
||||||
resolution: {integrity: sha512-Lzw8FUtnLCc9Jwz0sw9xOjZB+/mCCmJev38v2wHMUl/ioXNIhnNWeMxu0NKUjIhAd62IRB3eAtvxAGDJ55UkyA==}
|
resolution: {integrity: sha512-Lzw8FUtnLCc9Jwz0sw9xOjZB+/mCCmJev38v2wHMUl/ioXNIhnNWeMxu0NKUjIhAd62IRB3eAtvxAGDJ55UkyA==}
|
||||||
dev: false
|
dev: false
|
||||||
|
@ -2362,6 +2376,49 @@ packages:
|
||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
dev: false
|
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):
|
/@tanstack/react-virtual@3.0.2(react-dom@18.2.0)(react@18.2.0):
|
||||||
resolution: {integrity: sha512-9XbRLPKgnhMwwmuQMnJMv+5a9sitGNCSEtf/AZXzmJdesYk7XsjYHaEDny+IrJzvPNwZliIIDwCRiaUqR3zzCA==}
|
resolution: {integrity: sha512-9XbRLPKgnhMwwmuQMnJMv+5a9sitGNCSEtf/AZXzmJdesYk7XsjYHaEDny+IrJzvPNwZliIIDwCRiaUqR3zzCA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -2373,6 +2430,23 @@ packages:
|
||||||
react-dom: 18.2.0(react@18.2.0)
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
dev: false
|
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:
|
/@tanstack/virtual-core@3.0.0:
|
||||||
resolution: {integrity: sha512-SYXOBTjJb05rXa2vl55TTwO40A6wKu0R5i1qQwhJYNDIqaIGF7D0HsLw+pJAyi2OvntlEIVusx3xtbbgSUi6zg==}
|
resolution: {integrity: sha512-SYXOBTjJb05rXa2vl55TTwO40A6wKu0R5i1qQwhJYNDIqaIGF7D0HsLw+pJAyi2OvntlEIVusx3xtbbgSUi6zg==}
|
||||||
dev: false
|
dev: false
|
||||||
|
@ -2396,10 +2470,6 @@ packages:
|
||||||
/@types/estree@1.0.5:
|
/@types/estree@1.0.5:
|
||||||
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
|
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:
|
/@types/hoist-non-react-statics@3.3.5:
|
||||||
resolution: {integrity: sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==}
|
resolution: {integrity: sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -2446,21 +2516,6 @@ packages:
|
||||||
'@types/react': 18.2.48
|
'@types/react': 18.2.48
|
||||||
dev: false
|
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:
|
/@types/react-table@7.7.19:
|
||||||
resolution: {integrity: sha512-47jMa1Pai7ily6BXJCW33IL5ghqmCWs2VM9s+h1D4mCaK5P4uNkZOW3RMMg8MCXBvAJ0v9+sPqKjhid0PaJPQA==}
|
resolution: {integrity: sha512-47jMa1Pai7ily6BXJCW33IL5ghqmCWs2VM9s+h1D4mCaK5P4uNkZOW3RMMg8MCXBvAJ0v9+sPqKjhid0PaJPQA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -3108,6 +3163,13 @@ packages:
|
||||||
resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==}
|
resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==}
|
||||||
dev: false
|
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:
|
/date-fns@3.3.1:
|
||||||
resolution: {integrity: sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==}
|
resolution: {integrity: sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==}
|
||||||
dev: false
|
dev: false
|
||||||
|
@ -4838,7 +4900,6 @@ packages:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
scheduler: 0.23.0
|
scheduler: 0.23.0
|
||||||
dev: false
|
|
||||||
|
|
||||||
/react-error-boundary@4.0.12(react@18.2.0):
|
/react-error-boundary@4.0.12(react@18.2.0):
|
||||||
resolution: {integrity: sha512-kJdxdEYlb7CPC1A0SeUY38cHpjuu6UkvzKiAmqmOFL21VRfMhOcWxTCBgLVCO0VEMh9JhFNcVaXlV4/BTpiwOA==}
|
resolution: {integrity: sha512-kJdxdEYlb7CPC1A0SeUY38cHpjuu6UkvzKiAmqmOFL21VRfMhOcWxTCBgLVCO0VEMh9JhFNcVaXlV4/BTpiwOA==}
|
||||||
|
@ -4939,29 +5000,6 @@ packages:
|
||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
dev: false
|
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):
|
/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==}
|
resolution: {integrity: sha512-TfjLDo58XrhP6VG5M/Mi56Us0Yt8X7xD6cDybC7yoRMUNm7BGO7qk8J0TLQOua/prb8vUOtsfnXZwfm30HGsAA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -5024,7 +5062,6 @@ packages:
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
dev: false
|
|
||||||
|
|
||||||
/read-cache@1.0.0:
|
/read-cache@1.0.0:
|
||||||
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
|
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
|
||||||
|
@ -5138,7 +5175,7 @@ packages:
|
||||||
dependencies:
|
dependencies:
|
||||||
glob: 7.2.3
|
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==}
|
resolution: {integrity: sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==}
|
||||||
deprecated: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-terser
|
deprecated: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-terser
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -5146,7 +5183,7 @@ packages:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.23.5
|
'@babel/code-frame': 7.23.5
|
||||||
jest-worker: 26.6.2
|
jest-worker: 26.6.2
|
||||||
rollup: /@rollup/wasm-node@4.9.6
|
rollup: /@rollup/wasm-node@4.10.0
|
||||||
serialize-javascript: 4.0.0
|
serialize-javascript: 4.0.0
|
||||||
terser: 5.27.0
|
terser: 5.27.0
|
||||||
dev: true
|
dev: true
|
||||||
|
@ -5182,7 +5219,6 @@ packages:
|
||||||
resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==}
|
resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==}
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
dev: false
|
|
||||||
|
|
||||||
/semver@6.3.1:
|
/semver@6.3.1:
|
||||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||||
|
@ -5516,9 +5552,11 @@ packages:
|
||||||
any-promise: 1.3.0
|
any-promise: 1.3.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/tiny-invariant@1.3.1:
|
||||||
|
resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==}
|
||||||
|
|
||||||
/tiny-warning@1.0.3:
|
/tiny-warning@1.0.3:
|
||||||
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
|
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
|
||||||
dev: false
|
|
||||||
|
|
||||||
/to-fast-properties@2.0.0:
|
/to-fast-properties@2.0.0:
|
||||||
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
|
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)
|
use-isomorphic-layout-effect: 1.1.2(@types/react@18.2.48)(react@18.2.0)
|
||||||
dev: false
|
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:
|
/utf8@3.0.0:
|
||||||
resolution: {integrity: sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ==}
|
resolution: {integrity: sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ==}
|
||||||
dev: true
|
dev: true
|
||||||
|
@ -5779,12 +5824,12 @@ packages:
|
||||||
- supports-color
|
- supports-color
|
||||||
dev: true
|
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==}
|
resolution: {integrity: sha512-SC7+FfVtNQk7So0XMjrrtLAbEC8qjFPifyD7+fs/E6aaNdVde6umlVVh0QuwDLdOMu7vp5RiGFsB70nj5yo0XA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: ^2.6.0 || 3 || 4 || 5
|
vite: ^2.6.0 || 3 || 4 || 5
|
||||||
dependencies:
|
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/core': 8.1.0(typescript@5.3.3)
|
||||||
'@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0)
|
'@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0)
|
||||||
vite: 5.0.12(@types/node@20.11.6)
|
vite: 5.0.12(@types/node@20.11.6)
|
||||||
|
@ -5825,7 +5870,7 @@ packages:
|
||||||
'@types/node': 20.11.6
|
'@types/node': 20.11.6
|
||||||
esbuild: 0.19.12
|
esbuild: 0.19.12
|
||||||
postcss: 8.4.33
|
postcss: 8.4.33
|
||||||
rollup: /@rollup/wasm-node@4.9.6
|
rollup: /@rollup/wasm-node@4.10.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
@ -5923,9 +5968,9 @@ packages:
|
||||||
'@babel/core': 7.23.9
|
'@babel/core': 7.23.9
|
||||||
'@babel/preset-env': 7.23.9(@babel/core@7.23.9)
|
'@babel/preset-env': 7.23.9(@babel/core@7.23.9)
|
||||||
'@babel/runtime': 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-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.9.6)
|
'@rollup/plugin-node-resolve': 11.2.1(@rollup/wasm-node@4.10.0)
|
||||||
'@rollup/plugin-replace': 2.4.2(@rollup/wasm-node@4.9.6)
|
'@rollup/plugin-replace': 2.4.2(@rollup/wasm-node@4.10.0)
|
||||||
'@surma/rollup-plugin-off-main-thread': 2.2.3
|
'@surma/rollup-plugin-off-main-thread': 2.2.3
|
||||||
ajv: 8.12.0
|
ajv: 8.12.0
|
||||||
common-tags: 1.8.2
|
common-tags: 1.8.2
|
||||||
|
@ -5934,8 +5979,8 @@ packages:
|
||||||
glob: 7.2.3
|
glob: 7.2.3
|
||||||
lodash: 4.17.21
|
lodash: 4.17.21
|
||||||
pretty-bytes: 5.6.0
|
pretty-bytes: 5.6.0
|
||||||
rollup: /@rollup/wasm-node@4.9.6
|
rollup: /@rollup/wasm-node@4.10.0
|
||||||
rollup-plugin-terser: 7.0.2(@rollup/wasm-node@4.9.6)
|
rollup-plugin-terser: 7.0.2(@rollup/wasm-node@4.10.0)
|
||||||
source-map: 0.8.0-beta.0
|
source-map: 0.8.0-beta.0
|
||||||
stringify-object: 3.3.0
|
stringify-object: 3.3.0
|
||||||
strip-comments: 2.0.1
|
strip-comments: 2.0.1
|
||||||
|
|
|
@ -3,60 +3,34 @@
|
||||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { QueryClient, QueryClientProvider, useQueryErrorResetBoundary } from "@tanstack/react-query";
|
import { RouterProvider } from "@tanstack/react-router"
|
||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { Toaster } from "react-hot-toast";
|
||||||
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 { Portal } from "react-portal";
|
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({
|
declare module '@tanstack/react-router' {
|
||||||
defaultOptions: {
|
interface Register {
|
||||||
queries: {
|
router: typeof Router
|
||||||
// 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} />);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const { reset } = useQueryErrorResetBoundary();
|
|
||||||
|
|
||||||
const authContext = AuthContext.useValue();
|
|
||||||
const settings = SettingsContext.useValue();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary
|
|
||||||
onReset={reset}
|
|
||||||
FallbackComponent={ErrorPage}
|
|
||||||
>
|
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<Portal>
|
<Portal>
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
</Portal>
|
</Portal>
|
||||||
<LocalRouter isLoggedIn={authContext.isLoggedIn} />
|
<RouterProvider
|
||||||
{settings.debug ? (
|
basepath={routerBasePath()}
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
router={Router}
|
||||||
) : null}
|
context={{
|
||||||
|
auth: AuthContext,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -4,7 +4,6 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { baseUrl, sseBaseUrl } from "@utils";
|
import { baseUrl, sseBaseUrl } from "@utils";
|
||||||
import { AuthContext } from "@utils/Context";
|
|
||||||
import { GithubRelease } from "@app/types/Update";
|
import { GithubRelease } from "@app/types/Update";
|
||||||
|
|
||||||
type RequestBody = BodyInit | object | Record<string, unknown> | null;
|
type RequestBody = BodyInit | object | Record<string, unknown> | null;
|
||||||
|
@ -30,7 +29,8 @@ export async function HttpClient<T = unknown>(
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const init: RequestInit = {
|
const init: RequestInit = {
|
||||||
method: config.method,
|
method: config.method,
|
||||||
headers: { "Accept": "*/*" }
|
headers: { "Accept": "*/*", 'x-requested-with': 'XMLHttpRequest' },
|
||||||
|
credentials: "include",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (config.body) {
|
if (config.body) {
|
||||||
|
@ -87,22 +87,17 @@ export async function HttpClient<T = unknown>(
|
||||||
return Promise.resolve<T>({} as T);
|
return Promise.resolve<T>({} as T);
|
||||||
}
|
}
|
||||||
case 401: {
|
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(response);
|
||||||
|
// return Promise.reject(new Error(`[401] Unauthorized: "${endpoint}"`));
|
||||||
}
|
}
|
||||||
case 403: {
|
case 403: {
|
||||||
// Remove auth info from localStorage
|
|
||||||
AuthContext.reset();
|
|
||||||
|
|
||||||
// Show an error toast to notify the user what occurred
|
|
||||||
return Promise.reject(response);
|
return Promise.reject(response);
|
||||||
}
|
}
|
||||||
case 404: {
|
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: {
|
case 500: {
|
||||||
const health = await window.fetch(`${baseUrl()}api/healthz/liveness`);
|
const health = await window.fetch(`${baseUrl()}api/healthz/liveness`);
|
||||||
|
@ -326,6 +321,8 @@ export const APIClient = {
|
||||||
if (filter.id == "indexer") {
|
if (filter.id == "indexer") {
|
||||||
params["indexer"].push(filter.value);
|
params["indexer"].push(filter.value);
|
||||||
} else if (filter.id === "action_status") {
|
} 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);
|
params["push_status"].push(filter.value);
|
||||||
} else if (filter.id == "name") {
|
} else if (filter.id == "name") {
|
||||||
params["q"].push(filter.value);
|
params["q"].push(filter.value);
|
||||||
|
|
64
web/src/api/QueryClient.tsx
Normal file
64
web/src/api/QueryClient.tsx
Normal 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
135
web/src/api/queries.ts
Normal 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
77
web/src/api/query_keys.ts
Normal 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
|
||||||
|
};
|
|
@ -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")} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
|
@ -3,7 +3,7 @@
|
||||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
* 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 { ExternalLink } from "@components/ExternalLink";
|
||||||
|
|
||||||
import Logo from "@app/logo.svg?react";
|
import Logo from "@app/logo.svg?react";
|
||||||
|
@ -12,8 +12,11 @@ export const NotFound = () => {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col justify-center ">
|
<div className="min-h-screen flex flex-col justify-center ">
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Logo className="h-24 sm:h-48" />
|
<Logo className="h-24 sm:h-48"/>
|
||||||
</div>
|
</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">
|
<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!
|
Oops, looks like there was a little too much brr!
|
||||||
</h1>
|
</h1>
|
||||||
|
|
|
@ -9,7 +9,6 @@ import { formatDistanceToNowStrict } from "date-fns";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { CellProps } from "react-table";
|
import { CellProps } from "react-table";
|
||||||
import { ArrowPathIcon, CheckIcon } from "@heroicons/react/24/solid";
|
import { ArrowPathIcon, CheckIcon } from "@heroicons/react/24/solid";
|
||||||
import { ExternalLink } from "../ExternalLink";
|
|
||||||
import {
|
import {
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
|
@ -19,8 +18,9 @@ import {
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
import { APIClient } from "@api/APIClient";
|
import { APIClient } from "@api/APIClient";
|
||||||
import {classNames, humanFileSize, simplifyDate} from "@utils";
|
import { FilterKeys } from "@api/query_keys";
|
||||||
import { filterKeys } from "@screens/filters/List";
|
import { classNames, humanFileSize, simplifyDate } from "@utils";
|
||||||
|
import { ExternalLink } from "../ExternalLink";
|
||||||
import Toast from "@components/notifications/Toast";
|
import Toast from "@components/notifications/Toast";
|
||||||
import { RingResizeSpinner } from "@components/Icons";
|
import { RingResizeSpinner } from "@components/Icons";
|
||||||
import { Tooltip } from "@components/tooltips/Tooltip";
|
import { Tooltip } from "@components/tooltips/Tooltip";
|
||||||
|
@ -164,7 +164,7 @@ const RetryActionButton = ({ status }: RetryActionButtonProps) => {
|
||||||
mutationFn: (vars: RetryAction) => APIClient.release.replayAction(vars.releaseId, vars.actionId),
|
mutationFn: (vars: RetryAction) => APIClient.release.replayAction(vars.releaseId, vars.actionId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Invalidate filters just in case, most likely not necessary but can't hurt.
|
// 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.custom((t) => (
|
||||||
<Toast type="success" body={`${status?.action} replayed`} t={t} />
|
<Toast type="success" body={`${status?.action} replayed`} t={t} />
|
||||||
|
|
|
@ -23,3 +23,11 @@ export const DEBUG: FC<DebugProps> = ({ values }) => {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function LogDebug(...data: any[]): void {
|
||||||
|
if (process.env.NODE_ENV !== "development") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(...data)
|
||||||
|
}
|
||||||
|
|
|
@ -5,11 +5,11 @@
|
||||||
|
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { useRouter } from "@tanstack/react-router";
|
||||||
import { Disclosure } from "@headlessui/react";
|
import { Disclosure } from "@headlessui/react";
|
||||||
import { Bars3Icon, XMarkIcon, MegaphoneIcon } from "@heroicons/react/24/outline";
|
import { Bars3Icon, XMarkIcon, MegaphoneIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
import { APIClient } from "@api/APIClient";
|
import { APIClient } from "@api/APIClient";
|
||||||
import { AuthContext } from "@utils/Context";
|
|
||||||
import Toast from "@components/notifications/Toast";
|
import Toast from "@components/notifications/Toast";
|
||||||
|
|
||||||
import { LeftNav } from "./LeftNav";
|
import { LeftNav } from "./LeftNav";
|
||||||
|
@ -17,37 +17,35 @@ import { RightNav } from "./RightNav";
|
||||||
import { MobileNav } from "./MobileNav";
|
import { MobileNav } from "./MobileNav";
|
||||||
import { ExternalLink } from "@components/ExternalLink";
|
import { ExternalLink } from "@components/ExternalLink";
|
||||||
|
|
||||||
export const Header = () => {
|
import { AuthIndexRoute } from "@app/routes";
|
||||||
const { isError:isConfigError, error: configError, data: config } = useQuery({
|
import { ConfigQueryOptions, UpdatesQueryOptions } from "@api/queries";
|
||||||
queryKey: ["config"],
|
|
||||||
queryFn: () => APIClient.config.get(),
|
|
||||||
retry: false,
|
|
||||||
refetchOnWindowFocus: false
|
|
||||||
});
|
|
||||||
|
|
||||||
|
export const Header = () => {
|
||||||
|
const router = useRouter()
|
||||||
|
const { auth } = AuthIndexRoute.useRouteContext()
|
||||||
|
|
||||||
|
const { isError:isConfigError, error: configError, data: config } = useQuery(ConfigQueryOptions(true));
|
||||||
if (isConfigError) {
|
if (isConfigError) {
|
||||||
console.log(configError);
|
console.log(configError);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isError, error, data } = useQuery({
|
const { isError: isUpdateError, error, data } = useQuery(UpdatesQueryOptions(config?.check_for_updates === true));
|
||||||
queryKey: ["updates"],
|
if (isUpdateError) {
|
||||||
queryFn: () => APIClient.updates.getLatestRelease(),
|
console.log("update error", error);
|
||||||
retry: false,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
enabled: config?.check_for_updates === true
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
console.log(error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const logoutMutation = useMutation({
|
const logoutMutation = useMutation({
|
||||||
mutationFn: APIClient.auth.logout,
|
mutationFn: APIClient.auth.logout,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
AuthContext.reset();
|
|
||||||
toast.custom((t) => (
|
toast.custom((t) => (
|
||||||
<Toast type="success" body="You have been logged out. Goodbye!" t={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="border-b border-gray-300 dark:border-gray-775">
|
||||||
<div className="flex items-center justify-between h-16 px-4 sm:px-0">
|
<div className="flex items-center justify-between h-16 px-4 sm:px-0">
|
||||||
<LeftNav />
|
<LeftNav />
|
||||||
<RightNav logoutMutation={logoutMutation.mutate} />
|
<RightNav logoutMutation={logoutMutation.mutate} auth={auth} />
|
||||||
<div className="-mr-2 flex sm:hidden">
|
<div className="-mr-2 flex sm:hidden">
|
||||||
{/* Mobile menu button */}
|
{/* 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">
|
<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>
|
</div>
|
||||||
|
|
||||||
<MobileNav logoutMutation={logoutMutation.mutate} />
|
<MobileNav logoutMutation={logoutMutation.mutate} auth={auth} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Disclosure>
|
</Disclosure>
|
||||||
|
|
|
@ -3,7 +3,10 @@
|
||||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
* 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 { ArrowTopRightOnSquareIcon } from "@heroicons/react/24/solid";
|
||||||
|
|
||||||
import { classNames } from "@utils";
|
import { classNames } from "@utils";
|
||||||
|
@ -23,10 +26,15 @@ export const LeftNav = () => (
|
||||||
<div className="sm:ml-3 hidden sm:block">
|
<div className="sm:ml-3 hidden sm:block">
|
||||||
<div className="flex items-baseline space-x-4">
|
<div className="flex items-baseline space-x-4">
|
||||||
{NAV_ROUTES.map((item, itemIdx) => (
|
{NAV_ROUTES.map((item, itemIdx) => (
|
||||||
<NavLink
|
<Link
|
||||||
key={item.name + itemIdx}
|
key={item.name + itemIdx}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
className={({ isActive }) =>
|
params={{}}
|
||||||
|
>
|
||||||
|
{({ isActive }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className={
|
||||||
classNames(
|
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",
|
"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",
|
"transition-colors duration-200",
|
||||||
|
@ -34,11 +42,11 @@ export const LeftNav = () => (
|
||||||
? "text-black dark:text-gray-50 font-bold"
|
? "text-black dark:text-gray-50 font-bold"
|
||||||
: "text-gray-600 dark:text-gray-500"
|
: "text-gray-600 dark:text-gray-500"
|
||||||
)
|
)
|
||||||
}
|
}>{item.name}</span>
|
||||||
end={item.path === "/"}
|
</>
|
||||||
>
|
)
|
||||||
{item.name}
|
}}
|
||||||
</NavLink>
|
</Link>
|
||||||
))}
|
))}
|
||||||
<ExternalLink
|
<ExternalLink
|
||||||
href="https://autobrr.com"
|
href="https://autobrr.com"
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
* 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 { Disclosure } from "@headlessui/react";
|
||||||
|
|
||||||
import { classNames } from "@utils";
|
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">
|
<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">
|
<div className="px-2 py-3 space-y-1 sm:px-3">
|
||||||
{NAV_ROUTES.map((item) => (
|
{NAV_ROUTES.map((item) => (
|
||||||
<NavLink
|
<Link
|
||||||
key={item.path}
|
key={item.path}
|
||||||
|
activeOptions={{ exact: item.exact }}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
className={({ isActive }) =>
|
search={{}}
|
||||||
|
params={{}}
|
||||||
|
>
|
||||||
|
{({ isActive }) => {
|
||||||
|
return (
|
||||||
|
<span className={
|
||||||
classNames(
|
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",
|
"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
|
isActive
|
||||||
? "underline underline-offset-2 decoration-2 decoration-sky-500 font-bold text-black"
|
? "underline underline-offset-2 decoration-2 decoration-sky-500 font-bold text-black"
|
||||||
: "font-medium"
|
: "font-medium"
|
||||||
)
|
)
|
||||||
}
|
}>
|
||||||
end={item.path === "/"}
|
|
||||||
>
|
|
||||||
{item.name}
|
{item.name}
|
||||||
</NavLink>
|
</span>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|
|
@ -4,18 +4,16 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { UserIcon } from "@heroicons/react/24/solid";
|
import { UserIcon } from "@heroicons/react/24/solid";
|
||||||
import { Menu, Transition } from "@headlessui/react";
|
import { Menu, Transition } from "@headlessui/react";
|
||||||
|
|
||||||
import { classNames } from "@utils";
|
import { classNames } from "@utils";
|
||||||
import { AuthContext } from "@utils/Context";
|
|
||||||
|
|
||||||
import { RightNavProps } from "./_shared";
|
import { RightNavProps } from "./_shared";
|
||||||
import { Cog6ToothIcon, ArrowLeftOnRectangleIcon } from "@heroicons/react/24/outline";
|
import { Cog6ToothIcon, ArrowLeftOnRectangleIcon } from "@heroicons/react/24/outline";
|
||||||
|
import {Link} from "@tanstack/react-router";
|
||||||
|
|
||||||
export const RightNav = (props: RightNavProps) => {
|
export const RightNav = (props: RightNavProps) => {
|
||||||
const authContext = AuthContext.useValue();
|
|
||||||
return (
|
return (
|
||||||
<div className="hidden sm:block">
|
<div className="hidden sm:block">
|
||||||
<div className="ml-4 flex items-center sm:ml-6">
|
<div className="ml-4 flex items-center sm:ml-6">
|
||||||
|
@ -34,7 +32,7 @@ export const RightNav = (props: RightNavProps) => {
|
||||||
<span className="sr-only">
|
<span className="sr-only">
|
||||||
Open user menu for{" "}
|
Open user menu for{" "}
|
||||||
</span>
|
</span>
|
||||||
{authContext.username}
|
{props.auth.username}
|
||||||
</span>
|
</span>
|
||||||
<UserIcon
|
<UserIcon
|
||||||
className="inline ml-1 h-5 w-5"
|
className="inline ml-1 h-5 w-5"
|
||||||
|
|
|
@ -3,17 +3,21 @@
|
||||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { AuthCtx } from "@utils/Context";
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
exact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RightNavProps {
|
export interface RightNavProps {
|
||||||
logoutMutation: () => void;
|
logoutMutation: () => void;
|
||||||
|
auth: AuthCtx
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NAV_ROUTES: Array<NavItem> = [
|
export const NAV_ROUTES: Array<NavItem> = [
|
||||||
{ name: "Dashboard", path: "/" },
|
{ name: "Dashboard", path: "/", exact: true },
|
||||||
{ name: "Filters", path: "/filters" },
|
{ name: "Filters", path: "/filters" },
|
||||||
{ name: "Releases", path: "/releases" },
|
{ name: "Releases", path: "/releases" },
|
||||||
{ name: "Settings", path: "/settings" },
|
{ name: "Settings", path: "/settings" },
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { FC, Fragment, MutableRefObject, useState } from "react";
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
||||||
|
|
||||||
import { SectionLoader } from "@components/SectionLoader";
|
import { RingResizeSpinner } from "@components/Icons";
|
||||||
|
|
||||||
interface ModalUpperProps {
|
interface ModalUpperProps {
|
||||||
title: string;
|
title: string;
|
||||||
|
@ -58,7 +58,7 @@ const ModalUpper = ({ title, text }: ModalUpperProps) => (
|
||||||
const ModalLower = ({ isOpen, isLoading, toggle, deleteAction }: ModalLowerProps) => (
|
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">
|
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<SectionLoader $size="small" />
|
<RingResizeSpinner className="text-blue-500 size-6" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button
|
<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">
|
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||||
{props.isLoading ? (
|
{props.isLoading ? (
|
||||||
<SectionLoader $size="small" />
|
<RingResizeSpinner className="text-blue-500 size-6" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
|
@ -5,17 +5,18 @@
|
||||||
|
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { XMarkIcon } from "@heroicons/react/24/solid";
|
import { XMarkIcon } from "@heroicons/react/24/solid";
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
import type { FieldProps } from "formik";
|
import type { FieldProps } from "formik";
|
||||||
import { Field, Form, Formik, FormikErrors, FormikValues } from "formik";
|
import { Field, Form, Formik, FormikErrors, FormikValues } from "formik";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import { APIClient } from "@api/APIClient";
|
import { APIClient } from "@api/APIClient";
|
||||||
|
import { FilterKeys } from "@api/query_keys";
|
||||||
import { DEBUG } from "@components/debug";
|
import { DEBUG } from "@components/debug";
|
||||||
import Toast from "@components/notifications/Toast";
|
import Toast from "@components/notifications/Toast";
|
||||||
import { filterKeys } from "@screens/filters/List";
|
|
||||||
|
|
||||||
interface filterAddFormProps {
|
interface filterAddFormProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
@ -28,13 +29,12 @@ export function FilterAddForm({ isOpen, toggle }: filterAddFormProps) {
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (filter: Filter) => APIClient.filters.create(filter),
|
mutationFn: (filter: Filter) => APIClient.filters.create(filter),
|
||||||
onSuccess: (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} />);
|
toast.custom((t) => <Toast type="success" body={`Filter ${filter.name} was added`} t={t} />);
|
||||||
|
|
||||||
toggle();
|
|
||||||
if (filter.id) {
|
if (filter.id) {
|
||||||
navigate(filter.id.toString());
|
navigate({ to: "/filters/$filterId", params: { filterId: filter.id }})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -12,9 +12,9 @@ import type { FieldProps } from "formik";
|
||||||
import { Field, Form, Formik, FormikErrors, FormikValues } from "formik";
|
import { Field, Form, Formik, FormikErrors, FormikValues } from "formik";
|
||||||
|
|
||||||
import { APIClient } from "@api/APIClient";
|
import { APIClient } from "@api/APIClient";
|
||||||
|
import { ApiKeys } from "@api/query_keys";
|
||||||
import { DEBUG } from "@components/debug";
|
import { DEBUG } from "@components/debug";
|
||||||
import Toast from "@components/notifications/Toast";
|
import Toast from "@components/notifications/Toast";
|
||||||
import { apiKeys } from "@screens/settings/Api";
|
|
||||||
|
|
||||||
interface apiKeyAddFormProps {
|
interface apiKeyAddFormProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
@ -27,7 +27,7 @@ export function APIKeyAddForm({ isOpen, toggle }: apiKeyAddFormProps) {
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (apikey: APIKey) => APIClient.apikeys.create(apikey),
|
mutationFn: (apikey: APIKey) => APIClient.apikeys.create(apikey),
|
||||||
onSuccess: (_, key) => {
|
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}/>);
|
toast.custom((t) => <Toast type="success" body={`API key ${key.name} was added`} t={t}/>);
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,7 @@ import { toast } from "react-hot-toast";
|
||||||
import { classNames, sleep } from "@utils";
|
import { classNames, sleep } from "@utils";
|
||||||
import { DEBUG } from "@components/debug";
|
import { DEBUG } from "@components/debug";
|
||||||
import { APIClient } from "@api/APIClient";
|
import { APIClient } from "@api/APIClient";
|
||||||
|
import { DownloadClientKeys } from "@api/query_keys";
|
||||||
import { DownloadClientTypeOptions, DownloadRuleConditionOptions } from "@domain/constants";
|
import { DownloadClientTypeOptions, DownloadRuleConditionOptions } from "@domain/constants";
|
||||||
import Toast from "@components/notifications/Toast";
|
import Toast from "@components/notifications/Toast";
|
||||||
import { useToggle } from "@hooks/hooks";
|
import { useToggle } from "@hooks/hooks";
|
||||||
|
@ -24,7 +25,6 @@ import {
|
||||||
SwitchGroupWide,
|
SwitchGroupWide,
|
||||||
TextFieldWide
|
TextFieldWide
|
||||||
} from "@components/inputs";
|
} from "@components/inputs";
|
||||||
import { clientKeys } from "@screens/settings/DownloadClient";
|
|
||||||
import { DocsLink, ExternalLink } from "@components/ExternalLink";
|
import { DocsLink, ExternalLink } from "@components/ExternalLink";
|
||||||
import { SelectFieldBasic } from "@components/inputs/select_wide";
|
import { SelectFieldBasic } from "@components/inputs/select_wide";
|
||||||
|
|
||||||
|
@ -693,7 +693,7 @@ export function DownloadClientAddForm({ isOpen, toggle }: formProps) {
|
||||||
const addMutation = useMutation({
|
const addMutation = useMutation({
|
||||||
mutationFn: (client: DownloadClient) => APIClient.download_clients.create(client),
|
mutationFn: (client: DownloadClient) => APIClient.download_clients.create(client),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: clientKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: DownloadClientKeys.lists() });
|
||||||
toast.custom((t) => <Toast type="success" body="Client was added" t={t} />);
|
toast.custom((t) => <Toast type="success" body="Client was added" t={t} />);
|
||||||
|
|
||||||
toggle();
|
toggle();
|
||||||
|
@ -865,8 +865,8 @@ export function DownloadClientUpdateForm({ client, isOpen, toggle }: updateFormP
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (client: DownloadClient) => APIClient.download_clients.update(client),
|
mutationFn: (client: DownloadClient) => APIClient.download_clients.update(client),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: clientKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: DownloadClientKeys.lists() });
|
||||||
queryClient.invalidateQueries({ queryKey: clientKeys.detail(client.id) });
|
queryClient.invalidateQueries({ queryKey: DownloadClientKeys.detail(client.id) });
|
||||||
|
|
||||||
toast.custom((t) => <Toast type="success" body={`${client.name} was updated successfully`} t={t} />);
|
toast.custom((t) => <Toast type="success" body={`${client.name} was updated successfully`} t={t} />);
|
||||||
toggle();
|
toggle();
|
||||||
|
@ -878,8 +878,8 @@ export function DownloadClientUpdateForm({ client, isOpen, toggle }: updateFormP
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (clientID: number) => APIClient.download_clients.delete(clientID),
|
mutationFn: (clientID: number) => APIClient.download_clients.delete(clientID),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: clientKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: DownloadClientKeys.lists() });
|
||||||
queryClient.invalidateQueries({ queryKey: clientKeys.detail(client.id) });
|
queryClient.invalidateQueries({ queryKey: DownloadClientKeys.detail(client.id) });
|
||||||
|
|
||||||
toast.custom((t) => <Toast type="success" body={`${client.name} was deleted.`} t={t} />);
|
toast.custom((t) => <Toast type="success" body={`${client.name} was deleted.`} t={t} />);
|
||||||
toggleDeleteModal();
|
toggleDeleteModal();
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { toast } from "react-hot-toast";
|
||||||
import { useFormikContext } from "formik";
|
import { useFormikContext } from "formik";
|
||||||
|
|
||||||
import { APIClient } from "@api/APIClient";
|
import { APIClient } from "@api/APIClient";
|
||||||
|
import { FeedKeys } from "@api/query_keys";
|
||||||
import Toast from "@components/notifications/Toast";
|
import Toast from "@components/notifications/Toast";
|
||||||
import { SlideOver } from "@components/panels";
|
import { SlideOver } from "@components/panels";
|
||||||
import { NumberFieldWide, PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs";
|
import { NumberFieldWide, PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs";
|
||||||
|
@ -17,7 +18,7 @@ import { componentMapType } from "./DownloadClientForms";
|
||||||
import { sleep } from "@utils";
|
import { sleep } from "@utils";
|
||||||
import { ImplementationBadges } from "@screens/settings/Indexer";
|
import { ImplementationBadges } from "@screens/settings/Indexer";
|
||||||
import { FeedDownloadTypeOptions } from "@domain/constants";
|
import { FeedDownloadTypeOptions } from "@domain/constants";
|
||||||
import { feedKeys } from "@screens/settings/Feed";
|
|
||||||
|
|
||||||
interface UpdateProps {
|
interface UpdateProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
@ -50,7 +51,7 @@ export function FeedUpdateForm({ isOpen, toggle, feed }: UpdateProps) {
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (feed: Feed) => APIClient.feeds.update(feed),
|
mutationFn: (feed: Feed) => APIClient.feeds.update(feed),
|
||||||
onSuccess: () => {
|
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} />);
|
toast.custom((t) => <Toast type="success" body={`${feed.name} was updated successfully`} t={t} />);
|
||||||
toggle();
|
toggle();
|
||||||
|
@ -62,7 +63,7 @@ export function FeedUpdateForm({ isOpen, toggle, feed }: UpdateProps) {
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (feedID: number) => APIClient.feeds.delete(feedID),
|
mutationFn: (feedID: number) => APIClient.feeds.delete(feedID),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: feedKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: FeedKeys.lists() });
|
||||||
|
|
||||||
toast.custom((t) => <Toast type="success" body={`${feed.name} was deleted.`} t={t} />);
|
toast.custom((t) => <Toast type="success" body={`${feed.name} was deleted.`} t={t} />);
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,13 +15,13 @@ import { Dialog, Transition } from "@headlessui/react";
|
||||||
import { classNames, sleep } from "@utils";
|
import { classNames, sleep } from "@utils";
|
||||||
import { DEBUG } from "@components/debug";
|
import { DEBUG } from "@components/debug";
|
||||||
import { APIClient } from "@api/APIClient";
|
import { APIClient } from "@api/APIClient";
|
||||||
|
import { FeedKeys, IndexerKeys, ReleaseKeys } from "@api/query_keys";
|
||||||
|
import { IndexersSchemaQueryOptions } from "@api/queries";
|
||||||
import { SlideOver } from "@components/panels";
|
import { SlideOver } from "@components/panels";
|
||||||
import Toast from "@components/notifications/Toast";
|
import Toast from "@components/notifications/Toast";
|
||||||
import { PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs";
|
import { PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs";
|
||||||
import { SelectFieldBasic, SelectFieldCreatable } from "@components/inputs/select_wide";
|
import { SelectFieldBasic, SelectFieldCreatable } from "@components/inputs/select_wide";
|
||||||
import { FeedDownloadTypeOptions } from "@domain/constants";
|
import { FeedDownloadTypeOptions } from "@domain/constants";
|
||||||
import { feedKeys } from "@screens/settings/Feed";
|
|
||||||
import { indexerKeys } from "@screens/settings/Indexer";
|
|
||||||
import { DocsLink } from "@components/ExternalLink";
|
import { DocsLink } from "@components/ExternalLink";
|
||||||
import * as common from "@components/inputs/common";
|
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 [indexer, setIndexer] = useState<IndexerDefinition>({} as IndexerDefinition);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data } = useQuery({
|
const { data } = useQuery(IndexersSchemaQueryOptions(isOpen));
|
||||||
queryKey: ["indexerDefinition"],
|
|
||||||
queryFn: APIClient.indexers.getSchema,
|
|
||||||
enabled: isOpen,
|
|
||||||
refetchOnWindowFocus: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (indexer: Indexer) => APIClient.indexers.create(indexer),
|
mutationFn: (indexer: Indexer) => APIClient.indexers.create(indexer),
|
||||||
onSuccess: () => {
|
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} />);
|
toast.custom((t) => <Toast type="success" body="Indexer was added" t={t} />);
|
||||||
sleep(1500);
|
sleep(1500);
|
||||||
|
@ -291,7 +288,7 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
|
||||||
const feedMutation = useMutation({
|
const feedMutation = useMutation({
|
||||||
mutationFn: (feed: FeedCreate) => APIClient.feeds.create(feed),
|
mutationFn: (feed: FeedCreate) => APIClient.feeds.create(feed),
|
||||||
onSuccess: () => {
|
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({
|
const mutation = useMutation({
|
||||||
mutationFn: (indexer: Indexer) => APIClient.indexers.update(indexer),
|
mutationFn: (indexer: Indexer) => APIClient.indexers.update(indexer),
|
||||||
onSuccess: () => {
|
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} />);
|
toast.custom((t) => <Toast type="success" body={`${indexer.name} was updated successfully`} t={t} />);
|
||||||
sleep(1500);
|
sleep(1500);
|
||||||
|
@ -755,7 +752,9 @@ export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) {
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: number) => APIClient.indexers.delete(id),
|
mutationFn: (id: number) => APIClient.indexers.delete(id),
|
||||||
onSuccess: () => {
|
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} />);
|
toast.custom((t) => <Toast type="success" body={`${indexer.name} was deleted.`} t={t} />);
|
||||||
|
|
||||||
|
|
|
@ -14,8 +14,8 @@ import Select from "react-select";
|
||||||
import { Dialog } from "@headlessui/react";
|
import { Dialog } from "@headlessui/react";
|
||||||
|
|
||||||
import { IrcAuthMechanismTypeOptions, OptionBasicTyped } from "@domain/constants";
|
import { IrcAuthMechanismTypeOptions, OptionBasicTyped } from "@domain/constants";
|
||||||
import { ircKeys } from "@screens/settings/Irc";
|
|
||||||
import { APIClient } from "@api/APIClient";
|
import { APIClient } from "@api/APIClient";
|
||||||
|
import { IrcKeys } from "@api/query_keys";
|
||||||
import { NumberFieldWide, PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs";
|
import { NumberFieldWide, PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs";
|
||||||
import { SlideOver } from "@components/panels";
|
import { SlideOver } from "@components/panels";
|
||||||
import Toast from "@components/notifications/Toast";
|
import Toast from "@components/notifications/Toast";
|
||||||
|
@ -132,7 +132,7 @@ export function IrcNetworkAddForm({ isOpen, toggle }: AddFormProps) {
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (network: IrcNetwork) => APIClient.irc.createNetwork(network),
|
mutationFn: (network: IrcNetwork) => APIClient.irc.createNetwork(network),
|
||||||
onSuccess: () => {
|
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} />);
|
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();
|
toggle();
|
||||||
|
@ -288,7 +288,7 @@ export function IrcNetworkUpdateForm({
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: (network: IrcNetwork) => APIClient.irc.updateNetwork(network),
|
mutationFn: (network: IrcNetwork) => APIClient.irc.updateNetwork(network),
|
||||||
onSuccess: () => {
|
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} />);
|
toast.custom((t) => <Toast type="success" body={`${network.name} was updated successfully`} t={t} />);
|
||||||
|
|
||||||
|
@ -301,7 +301,7 @@ export function IrcNetworkUpdateForm({
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: number) => APIClient.irc.deleteNetwork(id),
|
mutationFn: (id: number) => APIClient.irc.deleteNetwork(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ircKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: IrcKeys.lists() });
|
||||||
|
|
||||||
toast.custom((t) => <Toast type="success" body={`${network.name} was deleted.`} t={t} />);
|
toast.custom((t) => <Toast type="success" body={`${network.name} was deleted.`} t={t} />);
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
|
|
||||||
import { APIClient } from "@api/APIClient";
|
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 { EventOptions, NotificationTypeOptions, SelectOption } from "@domain/constants";
|
||||||
import { DEBUG } from "@components/debug";
|
import { DEBUG } from "@components/debug";
|
||||||
import { SlideOver } from "@components/panels";
|
import { SlideOver } from "@components/panels";
|
||||||
|
@ -294,7 +294,7 @@ export function NotificationAddForm({ isOpen, toggle }: AddProps) {
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (notification: ServiceNotification) => APIClient.notifications.create(notification),
|
mutationFn: (notification: ServiceNotification) => APIClient.notifications.create(notification),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: notificationKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: NotificationKeys.lists() });
|
||||||
|
|
||||||
toast.custom((t) => <Toast type="success" body="Notification added!" t={t} />);
|
toast.custom((t) => <Toast type="success" body="Notification added!" t={t} />);
|
||||||
toggle();
|
toggle();
|
||||||
|
@ -565,7 +565,7 @@ export function NotificationUpdateForm({ isOpen, toggle, notification }: UpdateP
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (notification: ServiceNotification) => APIClient.notifications.update(notification),
|
mutationFn: (notification: ServiceNotification) => APIClient.notifications.update(notification),
|
||||||
onSuccess: () => {
|
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}/>);
|
toast.custom((t) => <Toast type="success" body={`${notification.name} was updated successfully`} t={t}/>);
|
||||||
toggle();
|
toggle();
|
||||||
|
@ -577,7 +577,7 @@ export function NotificationUpdateForm({ isOpen, toggle, notification }: UpdateP
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (notificationID: number) => APIClient.notifications.delete(notificationID),
|
mutationFn: (notificationID: number) => APIClient.notifications.delete(notificationID),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: notificationKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: NotificationKeys.lists() });
|
||||||
|
|
||||||
toast.custom((t) => <Toast type="success" body={`${notification.name} was deleted.`} t={t}/>);
|
toast.custom((t) => <Toast type="success" body={`${notification.name} was deleted.`} t={t}/>);
|
||||||
}
|
}
|
||||||
|
|
377
web/src/routes.tsx
Normal file
377
web/src/routes.tsx
Normal 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
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
@ -23,7 +23,6 @@ import { EmptySimple } from "@components/emptystates";
|
||||||
import { RingResizeSpinner } from "@components/Icons";
|
import { RingResizeSpinner } from "@components/Icons";
|
||||||
import Toast from "@components/notifications/Toast";
|
import Toast from "@components/notifications/Toast";
|
||||||
|
|
||||||
|
|
||||||
type LogEvent = {
|
type LogEvent = {
|
||||||
time: string;
|
time: string;
|
||||||
level: string;
|
level: string;
|
||||||
|
@ -182,7 +181,7 @@ export const LogFiles = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
console.log(error);
|
console.log("could not load log files", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -194,7 +193,7 @@ export const LogFiles = () => {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data && data.files.length > 0 ? (
|
{data && data.files && data.files.length > 0 ? (
|
||||||
<ul className="py-3 min-w-full relative">
|
<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">
|
<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">
|
<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">
|
||||||
|
|
|
@ -3,8 +3,6 @@
|
||||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Suspense } from "react";
|
|
||||||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
|
||||||
import {
|
import {
|
||||||
BellIcon,
|
BellIcon,
|
||||||
ChatBubbleLeftRightIcon,
|
ChatBubbleLeftRightIcon,
|
||||||
|
@ -16,25 +14,26 @@ import {
|
||||||
Square3Stack3DIcon,
|
Square3Stack3DIcon,
|
||||||
UserCircleIcon
|
UserCircleIcon
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
|
import { Link, Outlet } from "@tanstack/react-router";
|
||||||
|
|
||||||
import { classNames } from "@utils";
|
import { classNames } from "@utils";
|
||||||
import { SectionLoader } from "@components/SectionLoader";
|
|
||||||
|
|
||||||
interface NavTabType {
|
interface NavTabType {
|
||||||
name: string;
|
name: string;
|
||||||
href: string;
|
href: string;
|
||||||
icon: typeof CogIcon;
|
icon: typeof CogIcon;
|
||||||
|
exact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const subNavigation: NavTabType[] = [
|
const subNavigation: NavTabType[] = [
|
||||||
{ name: "Application", href: "", icon: CogIcon },
|
{ name: "Application", href: ".", icon: CogIcon, exact: true },
|
||||||
{ name: "Logs", href: "logs", icon: Square3Stack3DIcon },
|
{ name: "Logs", href: "logs", icon: Square3Stack3DIcon },
|
||||||
{ name: "Indexers", href: "indexers", icon: KeyIcon },
|
{ name: "Indexers", href: "indexers", icon: KeyIcon },
|
||||||
{ name: "IRC", href: "irc", icon: ChatBubbleLeftRightIcon },
|
{ name: "IRC", href: "irc", icon: ChatBubbleLeftRightIcon },
|
||||||
{ name: "Feeds", href: "feeds", icon: RssIcon },
|
{ name: "Feeds", href: "feeds", icon: RssIcon },
|
||||||
{ name: "Clients", href: "clients", icon: FolderArrowDownIcon },
|
{ name: "Clients", href: "clients", icon: FolderArrowDownIcon },
|
||||||
{ name: "Notifications", href: "notifications", icon: BellIcon },
|
{ 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: "Releases", href: "releases", icon: RectangleStackIcon },
|
||||||
{ name: "Account", href: "account", icon: UserCircleIcon }
|
{ name: "Account", href: "account", icon: UserCircleIcon }
|
||||||
// {name: 'Regex Playground', href: 'regex-playground', icon: CogIcon, current: false}
|
// {name: 'Regex Playground', href: 'regex-playground', icon: CogIcon, current: false}
|
||||||
|
@ -46,29 +45,38 @@ interface NavLinkProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
function SubNavLink({ item }: NavLinkProps) {
|
function SubNavLink({ item }: NavLinkProps) {
|
||||||
const { pathname } = useLocation();
|
// const { pathname } = useLocation();
|
||||||
const splitLocation = pathname.split("/");
|
// const splitLocation = pathname.split("/");
|
||||||
|
|
||||||
// we need to clean the / if it's a base root path
|
// we need to clean the / if it's a base root path
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<Link
|
||||||
key={item.name}
|
key={item.href}
|
||||||
to={item.href}
|
to={item.href}
|
||||||
end
|
activeOptions={{ exact: item.exact }}
|
||||||
className={({ isActive }) => classNames(
|
search={{}}
|
||||||
|
params={{}}
|
||||||
|
// aria-current={splitLocation[2] === item.href ? "page" : undefined}
|
||||||
|
>
|
||||||
|
{({ isActive }) => {
|
||||||
|
return (
|
||||||
|
<span className={
|
||||||
|
classNames(
|
||||||
"transition group border-l-4 px-3 py-2 flex items-center text-sm font-medium",
|
"transition group border-l-4 px-3 py-2 flex items-center text-sm font-medium",
|
||||||
isActive
|
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"
|
? "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"
|
: "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}
|
}>
|
||||||
>
|
|
||||||
<item.icon
|
<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"
|
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"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span className="truncate">{item.name}</span>
|
<span className="truncate">{item.name}</span>
|
||||||
</NavLink>
|
</span>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -78,10 +86,10 @@ interface SidebarNavProps {
|
||||||
|
|
||||||
function SidebarNav({ subNavigation }: SidebarNavProps) {
|
function SidebarNav({ subNavigation }: SidebarNavProps) {
|
||||||
return (
|
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">
|
<nav className="space-y-1">
|
||||||
{subNavigation.map((item) => (
|
{subNavigation.map((item) => (
|
||||||
<SubNavLink item={item} key={item.href} />
|
<SubNavLink key={item.href} item={item} />
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</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="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="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}/>
|
<SidebarNav subNavigation={subNavigation}/>
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="flex items-center justify-center lg:col-span-9">
|
|
||||||
<SectionLoader $size="large" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Suspense>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -3,19 +3,19 @@
|
||||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useRouter, useSearch } from "@tanstack/react-router";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import { RocketLaunchIcon } from "@heroicons/react/24/outline";
|
import { RocketLaunchIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
import { APIClient } from "@api/APIClient";
|
import { APIClient } from "@api/APIClient";
|
||||||
import { AuthContext } from "@utils/Context";
|
|
||||||
import Toast from "@components/notifications/Toast";
|
import Toast from "@components/notifications/Toast";
|
||||||
import { Tooltip } from "@components/tooltips/Tooltip";
|
import { Tooltip } from "@components/tooltips/Tooltip";
|
||||||
import { PasswordInput, TextInput } from "@components/inputs/text";
|
import { PasswordInput, TextInput } from "@components/inputs/text";
|
||||||
|
import { LoginRoute } from "@app/routes";
|
||||||
|
|
||||||
import Logo from "@app/logo.svg?react";
|
import Logo from "@app/logo.svg?react";
|
||||||
|
|
||||||
|
@ -25,35 +25,25 @@ type LoginFormFields = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Login = () => {
|
export const Login = () => {
|
||||||
|
const router = useRouter()
|
||||||
|
const { auth } = LoginRoute.useRouteContext()
|
||||||
|
const search = useSearch({ from: LoginRoute.id })
|
||||||
|
|
||||||
const { handleSubmit, register, formState } = useForm<LoginFormFields>({
|
const { handleSubmit, register, formState } = useForm<LoginFormFields>({
|
||||||
defaultValues: { username: "", password: "" },
|
defaultValues: { username: "", password: "" },
|
||||||
mode: "onBlur"
|
mode: "onBlur"
|
||||||
});
|
});
|
||||||
const navigate = useNavigate();
|
|
||||||
const [, setAuthContext] = AuthContext.use();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// remove user session when visiting login page'
|
// remove user session when visiting login page
|
||||||
APIClient.auth.logout()
|
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]);
|
|
||||||
|
|
||||||
const loginMutation = useMutation({
|
const loginMutation = useMutation({
|
||||||
mutationFn: (data: LoginFormFields) => APIClient.auth.login(data.username, data.password),
|
mutationFn: (data: LoginFormFields) => APIClient.auth.login(data.username, data.password),
|
||||||
onSuccess: (_, variables: LoginFormFields) => {
|
onSuccess: (_, variables: LoginFormFields) => {
|
||||||
setAuthContext({
|
auth.login(variables.username)
|
||||||
username: variables.username,
|
router.invalidate()
|
||||||
isLoggedIn: true
|
|
||||||
});
|
|
||||||
navigate("/");
|
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.custom((t) => (
|
toast.custom((t) => (
|
||||||
|
@ -64,6 +54,14 @@ export const Login = () => {
|
||||||
|
|
||||||
const onSubmit = (data: LoginFormFields) => loginMutation.mutate(data);
|
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 (
|
return (
|
||||||
<div className="min-h-screen flex flex-col justify-center px-3">
|
<div className="min-h-screen flex flex-col justify-center px-3">
|
||||||
<div className="mx-auto w-full max-w-md mb-6">
|
<div className="mx-auto w-full max-w-md mb-6">
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
import { Form, Formik } from "formik";
|
import { Form, Formik } from "formik";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "react-router-dom";
|
import {useNavigate} from "@tanstack/react-router";
|
||||||
|
|
||||||
import { APIClient } from "@api/APIClient";
|
import { APIClient } from "@api/APIClient";
|
||||||
import { TextField, PasswordField } from "@components/inputs";
|
import { TextField, PasswordField } from "@components/inputs";
|
||||||
|
@ -43,7 +43,7 @@ export const Onboarding = () => {
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (data: InputValues) => APIClient.auth.onboard(data.username, data.password1),
|
mutationFn: (data: InputValues) => APIClient.auth.onboard(data.username, data.password1),
|
||||||
onSuccess: () => navigate("/")
|
onSuccess: () => navigate({ to: "/" })
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
* 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 { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
useTable,
|
useTable,
|
||||||
|
@ -12,13 +12,14 @@ import {
|
||||||
useSortBy,
|
useSortBy,
|
||||||
usePagination, FilterProps, Column
|
usePagination, FilterProps, Column
|
||||||
} from "react-table";
|
} from "react-table";
|
||||||
|
import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/solid";
|
||||||
|
|
||||||
import { APIClient } from "@api/APIClient";
|
|
||||||
import { EmptyListState } from "@components/emptystates";
|
import { EmptyListState } from "@components/emptystates";
|
||||||
import * as Icons from "@components/Icons";
|
import * as Icons from "@components/Icons";
|
||||||
import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/solid";
|
|
||||||
import * as DataTable from "@components/data-table";
|
import * as DataTable from "@components/data-table";
|
||||||
import { RandomLinuxIsos } from "@utils";
|
import { RandomLinuxIsos } from "@utils";
|
||||||
|
import { RingResizeSpinner } from "@components/Icons";
|
||||||
|
import { ReleasesLatestQueryOptions } from "@api/queries";
|
||||||
|
|
||||||
// This is a custom filter UI for selecting
|
// This is a custom filter UI for selecting
|
||||||
// a unique option from a list
|
// a unique option from a list
|
||||||
|
@ -80,8 +81,14 @@ function Table({ columns, data }: TableProps) {
|
||||||
usePagination
|
usePagination
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!page.length) {
|
if (data.length === 0) {
|
||||||
return <EmptyListState text="No recent activity" />;
|
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
|
// 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 = () => {
|
export const ActivityTable = () => {
|
||||||
const columns = React.useMemo(() => [
|
const columns = React.useMemo(() => [
|
||||||
{
|
{
|
||||||
|
@ -185,11 +214,7 @@ export const ActivityTable = () => {
|
||||||
}
|
}
|
||||||
] as Column[], []);
|
] as Column[], []);
|
||||||
|
|
||||||
const { isLoading, data } = useSuspenseQuery({
|
const { isLoading, data } = useSuspenseQuery(ReleasesLatestQueryOptions());
|
||||||
queryKey: ["dash_recent_releases"],
|
|
||||||
queryFn: APIClient.release.findRecent,
|
|
||||||
refetchOnWindowFocus: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const [modifiedData, setModifiedData] = useState<Release[]>([]);
|
const [modifiedData, setModifiedData] = useState<Release[]>([]);
|
||||||
const [showLinuxIsos, setShowLinuxIsos] = useState(false);
|
const [showLinuxIsos, setShowLinuxIsos] = useState(false);
|
||||||
|
@ -198,7 +223,7 @@ export const ActivityTable = () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col mt-12">
|
<div className="flex flex-col mt-12">
|
||||||
<h3 className="text-2xl font-medium leading-6 text-gray-900 dark:text-gray-200">
|
<h3 className="text-2xl font-medium leading-6 text-gray-900 dark:text-gray-200">
|
||||||
|
Recent activity
|
||||||
</h3>
|
</h3>
|
||||||
<div className="animate-pulse text-black dark:text-white">
|
<div className="animate-pulse text-black dark:text-white">
|
||||||
<EmptyListState text="Loading..."/>
|
<EmptyListState text="Loading..."/>
|
||||||
|
@ -245,3 +270,75 @@ export const ActivityTable = () => {
|
||||||
</div>
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
@ -3,23 +3,28 @@
|
||||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
import { useQuery} from "@tanstack/react-query";
|
||||||
import { APIClient } from "@api/APIClient";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { classNames } from "@utils";
|
import { classNames } from "@utils";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { LinkIcon } from "@heroicons/react/24/solid";
|
import { LinkIcon } from "@heroicons/react/24/solid";
|
||||||
|
import { ReleasesStatsQueryOptions } from "@api/queries";
|
||||||
|
|
||||||
interface StatsItemProps {
|
interface StatsItemProps {
|
||||||
name: string;
|
name: string;
|
||||||
value?: number;
|
value?: number;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
onClick?: () => void;
|
to?: string;
|
||||||
|
eventType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatsItem = ({ name, placeholder, value, onClick }: StatsItemProps) => (
|
const StatsItem = ({ name, placeholder, value, to, eventType }: StatsItemProps) => (
|
||||||
<div
|
<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"
|
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>
|
<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">
|
<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>
|
<p>{value}</p>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Stats = () => {
|
export const Stats = () => {
|
||||||
const navigate = useNavigate();
|
const { isLoading, data } = useQuery(ReleasesStatsQueryOptions());
|
||||||
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
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
@ -62,11 +54,11 @@ export const Stats = () => {
|
||||||
</h1>
|
</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" : "")}>
|
<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="Filter Rejected Releases" stat={data?.filter_rejected_count} /> */}
|
||||||
<StatsItem name="Approved Pushes" onClick={() => handleStatClick("PUSH_APPROVED")} value={data?.push_approved_count ?? 0} />
|
<StatsItem name="Approved Pushes" to="/releases" eventType="PUSH_APPROVED" value={data?.push_approved_count ?? 0} />
|
||||||
<StatsItem name="Rejected Pushes" onClick={() => handleStatClick("PUSH_REJECTED")} value={data?.push_rejected_count ?? 0 } />
|
<StatsItem name="Rejected Pushes" to="/releases" eventType="PUSH_REJECTED" value={data?.push_rejected_count ?? 0 } />
|
||||||
<StatsItem name="Errored Pushes" onClick={() => handleStatClick("PUSH_ERROR")} value={data?.push_error_count ?? 0} />
|
<StatsItem name="Errored Pushes" to="/releases" eventType="PUSH_ERROR" value={data?.push_error_count ?? 0} />
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -3,17 +3,18 @@
|
||||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Suspense, useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { Form, Formik, useFormikContext } from "formik";
|
import { Form, Formik, useFormikContext } from "formik";
|
||||||
import type { FormikErrors, FormikValues } from "formik";
|
import type { FormikErrors, FormikValues } from "formik";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { toFormikValidationSchema } from "zod-formik-adapter";
|
import { toFormikValidationSchema } from "zod-formik-adapter";
|
||||||
import { ChevronRightIcon } from "@heroicons/react/24/solid";
|
import { ChevronRightIcon } from "@heroicons/react/24/solid";
|
||||||
import { NavLink, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
|
|
||||||
|
|
||||||
import { APIClient } from "@api/APIClient";
|
import { APIClient } from "@api/APIClient";
|
||||||
|
import { FilterByIdQueryOptions } from "@api/queries";
|
||||||
|
import { FilterKeys } from "@api/query_keys";
|
||||||
import { useToggle } from "@hooks/hooks";
|
import { useToggle } from "@hooks/hooks";
|
||||||
import { classNames } from "@utils";
|
import { classNames } from "@utils";
|
||||||
import { DOWNLOAD_CLIENTS } from "@domain/constants";
|
import { DOWNLOAD_CLIENTS } from "@domain/constants";
|
||||||
|
@ -21,18 +22,18 @@ import { DOWNLOAD_CLIENTS } from "@domain/constants";
|
||||||
import { DEBUG } from "@components/debug";
|
import { DEBUG } from "@components/debug";
|
||||||
import Toast from "@components/notifications/Toast";
|
import Toast from "@components/notifications/Toast";
|
||||||
import { DeleteModal } from "@components/modals";
|
import { DeleteModal } from "@components/modals";
|
||||||
import { SectionLoader } from "@components/SectionLoader";
|
|
||||||
|
|
||||||
import { filterKeys } from "./List";
|
import { Link, Outlet, useNavigate } from "@tanstack/react-router";
|
||||||
import * as Section from "./sections";
|
import { FilterGetByIdRoute } from "@app/routes";
|
||||||
|
|
||||||
interface tabType {
|
interface tabType {
|
||||||
name: string;
|
name: string;
|
||||||
href: string;
|
href: string;
|
||||||
|
exact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs: tabType[] = [
|
const tabs: tabType[] = [
|
||||||
{ name: "General", href: "" },
|
{ name: "General", href: ".", exact: true },
|
||||||
{ name: "Movies and TV", href: "movies-tv" },
|
{ name: "Movies and TV", href: "movies-tv" },
|
||||||
{ name: "Music", href: "music" },
|
{ name: "Music", href: "music" },
|
||||||
{ name: "Advanced", href: "advanced" },
|
{ name: "Advanced", href: "advanced" },
|
||||||
|
@ -45,25 +46,35 @@ export interface NavLinkProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabNavLink({ item }: NavLinkProps) {
|
function TabNavLink({ item }: NavLinkProps) {
|
||||||
const location = useLocation();
|
// const location = useLocation();
|
||||||
const splitLocation = location.pathname.split("/");
|
// const splitLocation = location.pathname.split("/");
|
||||||
|
|
||||||
// we need to clean the / if it's a base root path
|
// we need to clean the / if it's a base root path
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<Link
|
||||||
key={item.name}
|
|
||||||
to={item.href}
|
to={item.href}
|
||||||
end
|
activeOptions={{ exact: item.exact }}
|
||||||
className={({ isActive }) => classNames(
|
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"
|
||||||
|
>
|
||||||
|
{({ 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",
|
"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
|
isActive
|
||||||
? "text-blue-600 dark:text-white border-blue-600 dark:border-blue-500"
|
? "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"
|
: "text-gray-550 hover:text-blue-500 dark:hover:text-white border-transparent"
|
||||||
)}
|
)
|
||||||
aria-current={splitLocation[2] === item.href ? "page" : undefined}
|
}>
|
||||||
>
|
|
||||||
{item.name}
|
{item.name}
|
||||||
</NavLink>
|
</span>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -281,32 +292,20 @@ const schema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const FilterDetails = () => {
|
export const FilterDetails = () => {
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { filterId } = useParams<{ filterId: string }>();
|
const ctx = FilterGetByIdRoute.useRouteContext()
|
||||||
|
const queryClient = ctx.queryClient
|
||||||
|
|
||||||
if (filterId === "0" || filterId === undefined) {
|
const params = FilterGetByIdRoute.useParams()
|
||||||
navigate("/filters");
|
const filterQuery = useSuspenseQuery(FilterByIdQueryOptions(params.filterId))
|
||||||
}
|
const filter = filterQuery.data
|
||||||
|
|
||||||
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 updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: (filter: Filter) => APIClient.filters.update(filter),
|
mutationFn: (filter: Filter) => APIClient.filters.update(filter),
|
||||||
onSuccess: (newFilter, variables) => {
|
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) {
|
if (previous) {
|
||||||
return previous.map((filter: Filter) => (filter.id === variables.id ? newFilter : filter));
|
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),
|
mutationFn: (id: number) => APIClient.filters.delete(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Invalidate filters just in case, most likely not necessary but can't hurt.
|
// Invalidate filters just in case, most likely not necessary but can't hurt.
|
||||||
queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: FilterKeys.lists() });
|
||||||
queryClient.invalidateQueries({ queryKey: filterKeys.detail(id) });
|
queryClient.removeQueries({ queryKey: FilterKeys.detail(params.filterId) });
|
||||||
|
|
||||||
toast.custom((t) => (
|
toast.custom((t) => (
|
||||||
<Toast type="success" body={`${filter?.name} was deleted`} t={t} />
|
<Toast type="success" body={`${filter?.name} was deleted`} t={t} />
|
||||||
));
|
));
|
||||||
|
|
||||||
// redirect
|
// redirect
|
||||||
navigate("/filters");
|
navigate({ to: "/filters" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!filter) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = (data: Filter) => {
|
const handleSubmit = (data: Filter) => {
|
||||||
// force set method and type on webhook actions
|
// force set method and type on webhook actions
|
||||||
// TODO add options for these
|
// TODO add options for these
|
||||||
|
@ -362,9 +357,9 @@ export const FilterDetails = () => {
|
||||||
<main>
|
<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">
|
<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">
|
<h1 className="text-3xl font-bold">
|
||||||
<NavLink to="/filters">
|
<Link to="/filters">
|
||||||
Filters
|
Filters
|
||||||
</NavLink>
|
</Link>
|
||||||
</h1>
|
</h1>
|
||||||
<ChevronRightIcon className="h-6 w-4 shrink-0 sm:shrink sm:h-6 sm:w-6 mx-1" aria-hidden="true" />
|
<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>
|
<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="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="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">
|
<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) => (
|
{tabs.map((tab) => (
|
||||||
<TabNavLink item={tab} key={tab.href} />
|
<TabNavLink key={tab.href} item={tab} />
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
@ -452,22 +447,13 @@ export const FilterDetails = () => {
|
||||||
{({ values, dirty, resetForm }) => (
|
{({ values, dirty, resetForm }) => (
|
||||||
<Form className="pt-1 pb-4 px-5">
|
<Form className="pt-1 pb-4 px-5">
|
||||||
<FormErrorNotification />
|
<FormErrorNotification />
|
||||||
<Suspense fallback={<SectionLoader $size="large" />}>
|
<Outlet />
|
||||||
<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>
|
|
||||||
<FormButtonsGroup
|
<FormButtonsGroup
|
||||||
values={values}
|
values={values}
|
||||||
deleteAction={deleteAction}
|
deleteAction={deleteAction}
|
||||||
dirty={dirty}
|
dirty={dirty}
|
||||||
reset={resetForm}
|
reset={resetForm}
|
||||||
isLoading={isLoading}
|
isLoading={false}
|
||||||
/>
|
/>
|
||||||
<DEBUG values={values} />
|
<DEBUG values={values} />
|
||||||
</Form>
|
</Form>
|
||||||
|
|
|
@ -4,9 +4,9 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import { APIClient } from "@api/APIClient";
|
import { APIClient } from "@api/APIClient";
|
||||||
|
import { FilterKeys } from "@api/query_keys";
|
||||||
import Toast from "@components/notifications/Toast";
|
import Toast from "@components/notifications/Toast";
|
||||||
|
|
||||||
import { filterKeys } from "./List";
|
|
||||||
import { AutodlIrssiConfigParser } from "./_configParser";
|
import { AutodlIrssiConfigParser } from "./_configParser";
|
||||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
@ -211,7 +211,7 @@ export const Importer = ({
|
||||||
} finally {
|
} finally {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
// Invalidate filter cache, and trigger refresh request
|
// Invalidate filter cache, and trigger refresh request
|
||||||
await queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
|
await queryClient.invalidateQueries({ queryKey: FilterKeys.lists() });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -3,24 +3,23 @@
|
||||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Dispatch, FC, Fragment, MouseEventHandler, useReducer, useRef, useState, useEffect } from "react";
|
import { Dispatch, FC, Fragment, MouseEventHandler, useCallback, useEffect, useReducer, useRef, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from '@tanstack/react-router'
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { Listbox, Menu, Transition } from "@headlessui/react";
|
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 { FormikValues } from "formik";
|
||||||
import { useCallback } from "react";
|
|
||||||
import {
|
import {
|
||||||
ArrowsRightLeftIcon,
|
ArrowsRightLeftIcon,
|
||||||
|
ArrowUpOnSquareIcon,
|
||||||
|
ChatBubbleBottomCenterTextIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
PlusIcon,
|
|
||||||
DocumentDuplicateIcon,
|
DocumentDuplicateIcon,
|
||||||
EllipsisHorizontalIcon,
|
EllipsisHorizontalIcon,
|
||||||
PencilSquareIcon,
|
PencilSquareIcon,
|
||||||
ChatBubbleBottomCenterTextIcon,
|
PlusIcon,
|
||||||
TrashIcon,
|
TrashIcon
|
||||||
ArrowUpOnSquareIcon
|
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import { ArrowDownTrayIcon } from "@heroicons/react/24/solid";
|
import { ArrowDownTrayIcon } from "@heroicons/react/24/solid";
|
||||||
|
|
||||||
|
@ -29,6 +28,8 @@ import { classNames } from "@utils";
|
||||||
import { FilterAddForm } from "@forms";
|
import { FilterAddForm } from "@forms";
|
||||||
import { useToggle } from "@hooks/hooks";
|
import { useToggle } from "@hooks/hooks";
|
||||||
import { APIClient } from "@api/APIClient";
|
import { APIClient } from "@api/APIClient";
|
||||||
|
import { FilterKeys } from "@api/query_keys";
|
||||||
|
import { FiltersQueryOptions, IndexersOptionsQueryOptions } from "@api/queries";
|
||||||
import Toast from "@components/notifications/Toast";
|
import Toast from "@components/notifications/Toast";
|
||||||
import { EmptyListState } from "@components/emptystates";
|
import { EmptyListState } from "@components/emptystates";
|
||||||
import { DeleteModal } from "@components/modals";
|
import { DeleteModal } from "@components/modals";
|
||||||
|
@ -37,14 +38,6 @@ import { Importer } from "./Importer";
|
||||||
import { Tooltip } from "@components/tooltips/Tooltip";
|
import { Tooltip } from "@components/tooltips/Tooltip";
|
||||||
import { Checkbox } from "@components/Checkbox";
|
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 {
|
enum ActionType {
|
||||||
INDEXER_FILTER_CHANGE = "INDEXER_FILTER_CHANGE",
|
INDEXER_FILTER_CHANGE = "INDEXER_FILTER_CHANGE",
|
||||||
INDEXER_FILTER_RESET = "INDEXER_FILTER_RESET",
|
INDEXER_FILTER_RESET = "INDEXER_FILTER_RESET",
|
||||||
|
@ -192,11 +185,7 @@ function FilterList({ toggleCreateFilter }: any) {
|
||||||
filterListState
|
filterListState
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, error } = useSuspenseQuery({
|
const { data, error } = useQuery(FiltersQueryOptions(indexerFilter, sortOrder));
|
||||||
queryKey: filterKeys.list(indexerFilter, sortOrder),
|
|
||||||
queryFn: ({ queryKey }) => APIClient.filters.find(queryKey[2].indexers, queryKey[2].sortOrder),
|
|
||||||
refetchOnWindowFocus: false
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
FilterListContext.set({ indexerFilter, sortOrder, status });
|
FilterListContext.set({ indexerFilter, sortOrder, status });
|
||||||
|
@ -407,8 +396,8 @@ const FilterItemDropdown = ({ filter, onToggle }: FilterItemDropdownProps) => {
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: number) => APIClient.filters.delete(id),
|
mutationFn: (id: number) => APIClient.filters.delete(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: FilterKeys.lists() });
|
||||||
queryClient.invalidateQueries({ queryKey: filterKeys.detail(filter.id) });
|
queryClient.invalidateQueries({ queryKey: FilterKeys.detail(filter.id) });
|
||||||
|
|
||||||
toast.custom((t) => <Toast type="success" body={`Filter ${filter?.name} was deleted`} t={t} />);
|
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({
|
const duplicateMutation = useMutation({
|
||||||
mutationFn: (id: number) => APIClient.filters.duplicate(id),
|
mutationFn: (id: number) => APIClient.filters.duplicate(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: FilterKeys.lists() });
|
||||||
|
|
||||||
toast.custom((t) => <Toast type="success" body={`Filter ${filter?.name} duplicated`} t={t} />);
|
toast.custom((t) => <Toast type="success" body={`Filter ${filter?.name} duplicated`} t={t} />);
|
||||||
}
|
}
|
||||||
|
@ -459,7 +448,11 @@ const FilterItemDropdown = ({ filter, onToggle }: FilterItemDropdownProps) => {
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
{({ active }) => (
|
{({ active }) => (
|
||||||
<Link
|
<Link
|
||||||
to={filter.id.toString()}
|
// to={filter.id.toString()}
|
||||||
|
to="/filters/$filterId"
|
||||||
|
params={{
|
||||||
|
filterId: filter.id
|
||||||
|
}}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
active ? "bg-blue-600 text-white" : "text-gray-900 dark:text-gray-300",
|
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"
|
"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.
|
// We need to invalidate both keys here.
|
||||||
// The filters key is used on the /filters page,
|
// The filters key is used on the /filters page,
|
||||||
// while the ["filter", filter.id] key is used on the details page.
|
// while the ["filter", filter.id] key is used on the details page.
|
||||||
queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: FilterKeys.lists() });
|
||||||
queryClient.invalidateQueries({ queryKey: filterKeys.detail(filter.id) });
|
queryClient.invalidateQueries({ queryKey: FilterKeys.detail(filter.id) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -629,7 +622,10 @@ function FilterListItem({ filter, values, idx }: FilterListItemProps) {
|
||||||
</span>
|
</span>
|
||||||
<div className="py-2 flex flex-col overflow-hidden w-full justify-center">
|
<div className="py-2 flex flex-col overflow-hidden w-full justify-center">
|
||||||
<Link
|
<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"
|
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}
|
{filter.name}
|
||||||
|
@ -645,7 +641,10 @@ function FilterListItem({ filter, values, idx }: FilterListItemProps) {
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label={
|
label={
|
||||||
<Link
|
<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"
|
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" : ""}>
|
<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>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<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"
|
className="flex items-center cursor-pointer hover:text-black dark:hover:text-gray-300"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
|
@ -784,12 +786,9 @@ const ListboxFilter = ({
|
||||||
|
|
||||||
// a unique option from a list
|
// a unique option from a list
|
||||||
const IndexerSelectFilter = ({ dispatch }: any) => {
|
const IndexerSelectFilter = ({ dispatch }: any) => {
|
||||||
const { data, isSuccess } = useQuery({
|
const filterListState = FilterListContext.useValue();
|
||||||
queryKey: ["filters", "indexers_options"],
|
|
||||||
queryFn: () => APIClient.indexers.getOptions(),
|
const { data, isSuccess } = useQuery(IndexersOptionsQueryOptions());
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
staleTime: Infinity
|
|
||||||
});
|
|
||||||
|
|
||||||
const setFilter = (value: string) => {
|
const setFilter = (value: string) => {
|
||||||
if (value == undefined || value == "") {
|
if (value == undefined || value == "") {
|
||||||
|
@ -804,11 +803,11 @@ const IndexerSelectFilter = ({ dispatch }: any) => {
|
||||||
<ListboxFilter
|
<ListboxFilter
|
||||||
id="1"
|
id="1"
|
||||||
key="indexer-select"
|
key="indexer-select"
|
||||||
label="Indexer"
|
label={data && filterListState.indexerFilter[0] ? `Indexer: ${data.find(i => i.identifier == filterListState.indexerFilter[0])?.name}` : "Indexer"}
|
||||||
currentValue={""}
|
currentValue={filterListState.indexerFilter[0] ?? ""}
|
||||||
onChange={setFilter}
|
onChange={setFilter}
|
||||||
>
|
>
|
||||||
<FilterOption label="All" />
|
<FilterOption label="All" value="" />
|
||||||
{isSuccess && data?.map((indexer, idx) => (
|
{isSuccess && data?.map((indexer, idx) => (
|
||||||
<FilterOption key={idx} label={indexer.name} value={indexer.identifier} />
|
<FilterOption key={idx} label={indexer.name} value={indexer.identifier} />
|
||||||
))}
|
))}
|
||||||
|
@ -830,7 +829,7 @@ const FilterOption = ({ label, value }: FilterOptionProps) => (
|
||||||
value={value}
|
value={value}
|
||||||
>
|
>
|
||||||
{({ selected }) => (
|
{({ selected }) => (
|
||||||
<>
|
<div className="flex justify-between">
|
||||||
<span
|
<span
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"block truncate",
|
"block truncate",
|
||||||
|
@ -840,16 +839,18 @@ const FilterOption = ({ label, value }: FilterOptionProps) => (
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{selected ? (
|
{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" />
|
<CheckIcon className="w-5 h-5" aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Listbox.Option>
|
</Listbox.Option>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SortSelectFilter = ({ dispatch }: any) => {
|
export const SortSelectFilter = ({ dispatch }: any) => {
|
||||||
|
const filterListState = FilterListContext.useValue();
|
||||||
|
|
||||||
const setFilter = (value: string) => {
|
const setFilter = (value: string) => {
|
||||||
if (value == undefined || value == "") {
|
if (value == undefined || value == "") {
|
||||||
dispatch({ type: ActionType.SORT_ORDER_RESET, payload: "" });
|
dispatch({ type: ActionType.SORT_ORDER_RESET, payload: "" });
|
||||||
|
@ -870,8 +871,8 @@ export const SortSelectFilter = ({ dispatch }: any) => {
|
||||||
<ListboxFilter
|
<ListboxFilter
|
||||||
id="sort"
|
id="sort"
|
||||||
key="sort-select"
|
key="sort-select"
|
||||||
label="Sort"
|
label={filterListState.sortOrder ? `Sort: ${options.find(o => o.value == filterListState.sortOrder)?.label}` : "Sort"}
|
||||||
currentValue={""}
|
currentValue={filterListState.sortOrder ?? ""}
|
||||||
onChange={setFilter}
|
onChange={setFilter}
|
||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
|
|
61
web/src/screens/filters/NotFound.tsx
Normal file
61
web/src/screens/filters/NotFound.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -5,3 +5,4 @@
|
||||||
|
|
||||||
export { Filters } from "./List";
|
export { Filters } from "./List";
|
||||||
export { FilterDetails } from "./Details";
|
export { FilterDetails } from "./Details";
|
||||||
|
export { FilterNotFound } from "./NotFound";
|
|
@ -7,7 +7,7 @@ import { useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { Field, FieldArray, useFormikContext } from "formik";
|
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 { ChevronRightIcon, BoltIcon } from "@heroicons/react/24/solid";
|
||||||
|
|
||||||
import { classNames } from "@utils";
|
import { classNames } from "@utils";
|
||||||
|
@ -25,18 +25,17 @@ import { TitleSubtitle } from "@components/headings";
|
||||||
|
|
||||||
import * as FilterSection from "./_components";
|
import * as FilterSection from "./_components";
|
||||||
import * as FilterActions from "./action_components";
|
import * as FilterActions from "./action_components";
|
||||||
|
import { DownloadClientsQueryOptions } from "@api/queries";
|
||||||
|
|
||||||
interface FilterActionsProps {
|
// interface FilterActionsProps {
|
||||||
filter: Filter;
|
// filter: Filter;
|
||||||
values: FormikValues;
|
// values: FormikValues;
|
||||||
}
|
// }
|
||||||
|
|
||||||
export function Actions({ filter, values }: FilterActionsProps) {
|
export function Actions() {
|
||||||
const { data } = useQuery({
|
const { values } = useFormikContext<Filter>();
|
||||||
queryKey: ["filters", "download_clients"],
|
|
||||||
queryFn: () => APIClient.download_clients.getAll(),
|
const { data } = useQuery(DownloadClientsQueryOptions());
|
||||||
refetchOnWindowFocus: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const newAction: Action = {
|
const newAction: Action = {
|
||||||
id: 0,
|
id: 0,
|
||||||
|
@ -63,7 +62,7 @@ export function Actions({ filter, values }: FilterActionsProps) {
|
||||||
reannounce_delete: false,
|
reannounce_delete: false,
|
||||||
reannounce_interval: 7,
|
reannounce_interval: 7,
|
||||||
reannounce_max_attempts: 25,
|
reannounce_max_attempts: 25,
|
||||||
filter_id: filter.id,
|
filter_id: values.id,
|
||||||
webhook_host: "",
|
webhook_host: "",
|
||||||
webhook_type: "",
|
webhook_type: "",
|
||||||
webhook_method: "",
|
webhook_method: "",
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import type { FormikValues } from "formik";
|
import { useFormikContext } from "formik";
|
||||||
|
|
||||||
import { DocsLink } from "@components/ExternalLink";
|
import { DocsLink } from "@components/ExternalLink";
|
||||||
import { WarningAlert } from "@components/alerts";
|
import { WarningAlert } from "@components/alerts";
|
||||||
|
@ -10,13 +10,16 @@ import { CollapsibleSection } from "./_components";
|
||||||
import * as Components from "./_components";
|
import * as Components from "./_components";
|
||||||
import { classNames } from "@utils";
|
import { classNames } from "@utils";
|
||||||
|
|
||||||
type ValueConsumer = {
|
// type ValueConsumer = {
|
||||||
values: FormikValues;
|
// values: FormikValues;
|
||||||
};
|
// };
|
||||||
|
|
||||||
const Releases = ({ values }: ValueConsumer) => (
|
const Releases = () => {
|
||||||
|
const { values } = useFormikContext<Filter>();
|
||||||
|
|
||||||
|
return (
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
defaultOpen={values.use_regex || values.match_releases || values.except_releases}
|
//defaultOpen={values.use_regex || values.match_releases !== "" || values.except_releases !== ""}
|
||||||
title="Release Names"
|
title="Release Names"
|
||||||
subtitle="Match only certain release names and/or ignore other release names."
|
subtitle="Match only certain release names and/or ignore other release names."
|
||||||
>
|
>
|
||||||
|
@ -90,11 +93,15 @@ const Releases = ({ values }: ValueConsumer) => (
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const Groups = ({ values }: ValueConsumer) => (
|
const Groups = () => {
|
||||||
|
// const { values } = useFormikContext<Filter>();
|
||||||
|
|
||||||
|
return (
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
defaultOpen={values.match_release_groups || values.except_release_groups}
|
//defaultOpen={values.match_release_groups !== "" || values.except_release_groups !== ""}
|
||||||
title="Groups"
|
title="Groups"
|
||||||
subtitle="Match only certain groups and/or ignore other groups."
|
subtitle="Match only certain groups and/or ignore other groups."
|
||||||
>
|
>
|
||||||
|
@ -123,11 +130,15 @@ const Groups = ({ values }: ValueConsumer) => (
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const Categories = ({ values }: ValueConsumer) => (
|
const Categories = () => {
|
||||||
|
// const { values } = useFormikContext<Filter>();
|
||||||
|
|
||||||
|
return (
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
defaultOpen={values.match_categories || values.except_categories}
|
//defaultOpen={values.match_categories.length >0 || values.except_categories !== ""}
|
||||||
title="Categories"
|
title="Categories"
|
||||||
subtitle="Match or exclude categories (if announced)"
|
subtitle="Match or exclude categories (if announced)"
|
||||||
>
|
>
|
||||||
|
@ -156,11 +167,15 @@ const Categories = ({ values }: ValueConsumer) => (
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const Tags = ({ values }: ValueConsumer) => (
|
const Tags = () => {
|
||||||
|
// const { values } = useFormikContext<Filter>();
|
||||||
|
|
||||||
|
return (
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
defaultOpen={values.tags || values.except_tags}
|
//defaultOpen={values.tags !== "" || values.except_tags !== ""}
|
||||||
title="Tags"
|
title="Tags"
|
||||||
subtitle="Match or exclude tags (if announced)"
|
subtitle="Match or exclude tags (if announced)"
|
||||||
>
|
>
|
||||||
|
@ -219,11 +234,15 @@ const Tags = ({ values }: ValueConsumer) => (
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const Uploaders = ({ values }: ValueConsumer) => (
|
const Uploaders = () => {
|
||||||
|
// const { values } = useFormikContext<Filter>();
|
||||||
|
|
||||||
|
return (
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
defaultOpen={values.uploaders || values.except_uploaders}
|
//defaultOpen={values.match_uploaders !== "" || values.except_uploaders !== ""}
|
||||||
title="Uploaders"
|
title="Uploaders"
|
||||||
subtitle="Match or ignore uploaders (if announced)"
|
subtitle="Match or ignore uploaders (if announced)"
|
||||||
>
|
>
|
||||||
|
@ -253,11 +272,15 @@ const Uploaders = ({ values }: ValueConsumer) => (
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const Language = ({ values }: ValueConsumer) => (
|
const Language = () => {
|
||||||
|
// const { values } = useFormikContext<Filter>();
|
||||||
|
|
||||||
|
return (
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
defaultOpen={(values.match_language && values.match_language.length > 0) || (values.except_language && values.except_language.length > 0)}
|
//defaultOpen={(values.match_language && values.match_language.length > 0) || (values.except_language && values.except_language.length > 0)}
|
||||||
title="Language"
|
title="Language"
|
||||||
subtitle="Match or ignore languages (if announced)"
|
subtitle="Match or ignore languages (if announced)"
|
||||||
>
|
>
|
||||||
|
@ -274,11 +297,15 @@ const Language = ({ values }: ValueConsumer) => (
|
||||||
columns={6}
|
columns={6}
|
||||||
/>
|
/>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const Origins = ({ values }: ValueConsumer) => (
|
const Origins = () => {
|
||||||
|
// const { values } = useFormikContext<Filter>();
|
||||||
|
|
||||||
|
return (
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
defaultOpen={(values.origins && values.origins.length > 0 || values.except_origins && values.except_origins.length > 0)}
|
//defaultOpen={(values.origins && values.origins.length > 0 || values.except_origins && values.except_origins.length > 0)}
|
||||||
title="Origins"
|
title="Origins"
|
||||||
subtitle="Match Internals, Scene, P2P, etc. (if announced)"
|
subtitle="Match Internals, Scene, P2P, etc. (if announced)"
|
||||||
>
|
>
|
||||||
|
@ -295,11 +322,15 @@ const Origins = ({ values }: ValueConsumer) => (
|
||||||
columns={6}
|
columns={6}
|
||||||
/>
|
/>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const Freeleech = ({ values }: ValueConsumer) => (
|
const Freeleech = () => {
|
||||||
|
const { values } = useFormikContext<Filter>();
|
||||||
|
|
||||||
|
return (
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
defaultOpen={values.freeleech || values.freeleech_percent}
|
//defaultOpen={values.freeleech || values.freeleech_percent !== ""}
|
||||||
title="Freeleech"
|
title="Freeleech"
|
||||||
subtitle="Match based off freeleech (if announced)"
|
subtitle="Match based off freeleech (if announced)"
|
||||||
>
|
>
|
||||||
|
@ -347,11 +378,14 @@ const Freeleech = ({ values }: ValueConsumer) => (
|
||||||
/>
|
/>
|
||||||
</Components.HalfRow>
|
</Components.HalfRow>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const FeedSpecific = ({ values }: ValueConsumer) => (
|
const FeedSpecific = () => {
|
||||||
|
const { values } = useFormikContext<Filter>();
|
||||||
|
return (
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
defaultOpen={values.use_regex_description || values.match_description || values.except_description}
|
//defaultOpen={values.use_regex_description || values.match_description || values.except_description}
|
||||||
title="RSS/Torznab/Newznab-specific"
|
title="RSS/Torznab/Newznab-specific"
|
||||||
subtitle={
|
subtitle={
|
||||||
<>These options are <span className="font-bold">only</span> for Feeds such as RSS, Torznab and Newznab</>
|
<>These options are <span className="font-bold">only</span> for Feeds such as RSS, Torznab and Newznab</>
|
||||||
|
@ -442,11 +476,14 @@ const FeedSpecific = ({ values }: ValueConsumer) => (
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
const RawReleaseTags = () => {
|
||||||
|
const { values } = useFormikContext<Filter>();
|
||||||
|
|
||||||
const RawReleaseTags = ({ values }: ValueConsumer) => (
|
return (
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
defaultOpen={values.use_regex_release_tags || values.match_release_tags || values.except_release_tags}
|
//defaultOpen={values.use_regex_release_tags || values.match_release_tags || values.except_release_tags}
|
||||||
title="Raw Release Tags"
|
title="Raw Release Tags"
|
||||||
subtitle={
|
subtitle={
|
||||||
<>
|
<>
|
||||||
|
@ -484,19 +521,22 @@ const RawReleaseTags = ({ values }: ValueConsumer) => (
|
||||||
placeholder="eg. *mkv*,*foreign*"
|
placeholder="eg. *mkv*,*foreign*"
|
||||||
/>
|
/>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const Advanced = ({ values }: { values: FormikValues; }) => (
|
export const Advanced = () => {
|
||||||
|
return (
|
||||||
<div className="flex flex-col w-full gap-y-4 py-2 sm:-mx-1">
|
<div className="flex flex-col w-full gap-y-4 py-2 sm:-mx-1">
|
||||||
<Releases values={values} />
|
<Releases />
|
||||||
<Groups values={values} />
|
<Groups />
|
||||||
<Categories values={values} />
|
<Categories />
|
||||||
<Freeleech values={values} />
|
<Freeleech />
|
||||||
<Tags values={values}/>
|
<Tags />
|
||||||
<Uploaders values={values}/>
|
<Uploaders />
|
||||||
<Language values={values}/>
|
<Language />
|
||||||
<Origins values={values} />
|
<Origins />
|
||||||
<FeedSpecific values={values} />
|
<FeedSpecific />
|
||||||
<RawReleaseTags values={values} />
|
<RawReleaseTags />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -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 { downloadsPerUnitOptions } from "@domain/constants";
|
||||||
|
import { IndexersOptionsQueryOptions } from "@api/queries";
|
||||||
|
|
||||||
import { DocsLink } from "@components/ExternalLink";
|
import { DocsLink } from "@components/ExternalLink";
|
||||||
|
|
||||||
import * as Input from "@components/inputs";
|
import * as Input from "@components/inputs";
|
||||||
import * as Components from "./_components";
|
import * as Components from "./_components";
|
||||||
|
|
||||||
|
|
||||||
const MapIndexer = (indexer: Indexer) => (
|
const MapIndexer = (indexer: Indexer) => (
|
||||||
{ label: indexer.name, value: indexer.id } as Input.MultiSelectOption
|
{ label: indexer.name, value: indexer.id } as Input.MultiSelectOption
|
||||||
);
|
);
|
||||||
|
|
||||||
export const General = () => {
|
export const General = () => {
|
||||||
const { isLoading, data } = useQuery({
|
const indexersQuery = useSuspenseQuery(IndexersOptionsQueryOptions())
|
||||||
queryKey: ["filters", "indexer_list"],
|
const indexerOptions = indexersQuery.data && indexersQuery.data.map(MapIndexer)
|
||||||
queryFn: APIClient.indexers.getOptions,
|
|
||||||
refetchOnWindowFocus: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const indexerOptions = data?.map(MapIndexer) ?? [];
|
// const indexerOptions = data?.map(MapIndexer) ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Components.Page>
|
<Components.Page>
|
||||||
|
@ -27,9 +25,9 @@ export const General = () => {
|
||||||
<Components.Layout>
|
<Components.Layout>
|
||||||
<Input.TextField name="name" label="Filter name" columns={6} placeholder="eg. Filter 1" />
|
<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} />
|
<Input.IndexerMultiSelect name="indexers" options={indexerOptions} label="Indexers" columns={6} />
|
||||||
)}
|
{/*)}*/}
|
||||||
</Components.Layout>
|
</Components.Layout>
|
||||||
</Components.Section>
|
</Components.Section>
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import type { FormikValues } from "formik";
|
import { useFormikContext } from "formik";
|
||||||
|
|
||||||
import { DocsLink } from "@components/ExternalLink";
|
import { DocsLink } from "@components/ExternalLink";
|
||||||
import * as Input from "@components/inputs";
|
import * as Input from "@components/inputs";
|
||||||
|
@ -6,7 +6,10 @@ import * as Input from "@components/inputs";
|
||||||
import * as CONSTS from "@domain/constants";
|
import * as CONSTS from "@domain/constants";
|
||||||
import * as Components from "./_components";
|
import * as Components from "./_components";
|
||||||
|
|
||||||
export const Music = ({ values }: { values: FormikValues; }) => (
|
export const Music = () => {
|
||||||
|
const { values } = useFormikContext<Filter>();
|
||||||
|
|
||||||
|
return (
|
||||||
<Components.Page>
|
<Components.Page>
|
||||||
<Components.Section>
|
<Components.Section>
|
||||||
<Components.Layout>
|
<Components.Layout>
|
||||||
|
@ -184,4 +187,5 @@ export const Music = ({ values }: { values: FormikValues; }) => (
|
||||||
</Components.Layout>
|
</Components.Layout>
|
||||||
</Components.Section>
|
</Components.Section>
|
||||||
</Components.Page>
|
</Components.Page>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "@tanstack/react-router";
|
||||||
|
|
||||||
import { DocsLink } from "@components/ExternalLink";
|
import { DocsLink } from "@components/ExternalLink";
|
||||||
import { ActionContentLayoutOptions, ActionPriorityOptions } from "@domain/constants";
|
import { ActionContentLayoutOptions, ActionPriorityOptions } from "@domain/constants";
|
||||||
|
|
|
@ -4,15 +4,15 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as React from "react";
|
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 { Listbox, Transition } from "@headlessui/react";
|
||||||
import { CheckIcon, ChevronDownIcon } from "@heroicons/react/24/solid";
|
import { CheckIcon, ChevronDownIcon } from "@heroicons/react/24/solid";
|
||||||
|
|
||||||
import { APIClient } from "@api/APIClient";
|
|
||||||
import { classNames } from "@utils";
|
import { classNames } from "@utils";
|
||||||
import { PushStatusOptions } from "@domain/constants";
|
import { PushStatusOptions } from "@domain/constants";
|
||||||
import { FilterProps } from "react-table";
|
import { FilterProps } from "react-table";
|
||||||
import { DebounceInput } from "react-debounce-input";
|
import { DebounceInput } from "react-debounce-input";
|
||||||
|
import { ReleasesIndexersQueryOptions } from "@api/queries";
|
||||||
|
|
||||||
interface ListboxFilterProps {
|
interface ListboxFilterProps {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -54,7 +54,7 @@ const ListboxFilter = ({
|
||||||
<Listbox.Options
|
<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"
|
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}
|
{children}
|
||||||
</Listbox.Options>
|
</Listbox.Options>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
@ -67,12 +67,7 @@ const ListboxFilter = ({
|
||||||
export const IndexerSelectColumnFilter = ({
|
export const IndexerSelectColumnFilter = ({
|
||||||
column: { filterValue, setFilter, id }
|
column: { filterValue, setFilter, id }
|
||||||
}: FilterProps<object>) => {
|
}: FilterProps<object>) => {
|
||||||
const { data, isSuccess } = useQuery({
|
const { data, isSuccess } = useQuery(ReleasesIndexersQueryOptions());
|
||||||
queryKey: ["indexer_options"],
|
|
||||||
queryFn: () => APIClient.release.indexerOptions(),
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
staleTime: Infinity
|
|
||||||
});
|
|
||||||
|
|
||||||
// Render a multi-select box
|
// Render a multi-select box
|
||||||
return (
|
return (
|
||||||
|
@ -80,10 +75,10 @@ export const IndexerSelectColumnFilter = ({
|
||||||
id={id}
|
id={id}
|
||||||
key={id}
|
key={id}
|
||||||
label={filterValue ?? "Indexer"}
|
label={filterValue ?? "Indexer"}
|
||||||
currentValue={filterValue}
|
currentValue={filterValue ?? ""}
|
||||||
onChange={setFilter}
|
onChange={setFilter}
|
||||||
>
|
>
|
||||||
{isSuccess && data?.map((indexer, idx) => (
|
{isSuccess && data && data?.map((indexer, idx) => (
|
||||||
<FilterOption key={idx} label={indexer} value={indexer} />
|
<FilterOption key={idx} label={indexer} value={indexer} />
|
||||||
))}
|
))}
|
||||||
</ListboxFilter>
|
</ListboxFilter>
|
||||||
|
@ -138,7 +133,7 @@ export const PushStatusSelectColumnFilter = ({
|
||||||
<ListboxFilter
|
<ListboxFilter
|
||||||
id={id}
|
id={id}
|
||||||
label={label ?? "Push status"}
|
label={label ?? "Push status"}
|
||||||
currentValue={filterValue}
|
currentValue={filterValue ?? ""}
|
||||||
onChange={setFilter}
|
onChange={setFilter}
|
||||||
>
|
>
|
||||||
{PushStatusOptions.map((status, idx) => (
|
{PushStatusOptions.map((status, idx) => (
|
|
@ -4,33 +4,28 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { Column, useFilters, usePagination, useSortBy, useTable } from "react-table";
|
import { Column, useFilters, usePagination, useSortBy, useTable } from "react-table";
|
||||||
import {
|
import {
|
||||||
ChevronDoubleLeftIcon,
|
ChevronDoubleLeftIcon,
|
||||||
ChevronDoubleRightIcon,
|
ChevronDoubleRightIcon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon
|
ChevronRightIcon,
|
||||||
|
EyeIcon,
|
||||||
|
EyeSlashIcon
|
||||||
} from "@heroicons/react/24/solid";
|
} 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 { RandomLinuxIsos } from "@utils";
|
||||||
import { APIClient } from "@api/APIClient";
|
|
||||||
import { EmptyListState } from "@components/emptystates";
|
|
||||||
|
|
||||||
import * as Icons from "@components/Icons";
|
import * as Icons from "@components/Icons";
|
||||||
|
import { RingResizeSpinner } from "@components/Icons";
|
||||||
import * as DataTable from "@components/data-table";
|
import * as DataTable from "@components/data-table";
|
||||||
|
|
||||||
import { IndexerSelectColumnFilter, PushStatusSelectColumnFilter, SearchColumnFilter } from "./Filters";
|
import { IndexerSelectColumnFilter, PushStatusSelectColumnFilter, SearchColumnFilter } from "./ReleaseFilters";
|
||||||
|
import { EmptyListState } from "@components/emptystates";
|
||||||
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
|
|
||||||
};
|
|
||||||
|
|
||||||
type TableState = {
|
type TableState = {
|
||||||
queryPageIndex: number;
|
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 = () => {
|
export const ReleaseTable = () => {
|
||||||
const location = useLocation();
|
const search = ReleasesIndexRoute.useSearch()
|
||||||
const queryParams = new URLSearchParams(location.search);
|
|
||||||
const filterTypeFromUrl = queryParams.get("filter");
|
|
||||||
const columns = React.useMemo(() => [
|
const columns = React.useMemo(() => [
|
||||||
{
|
{
|
||||||
Header: "Age",
|
Header: "Age",
|
||||||
|
@ -116,14 +129,14 @@ export const ReleaseTable = () => {
|
||||||
}
|
}
|
||||||
] as Column<Release>[], []);
|
] as Column<Release>[], []);
|
||||||
|
|
||||||
|
if (search.action_status != "") {
|
||||||
|
initialState.queryFilters = [{id: "action_status", value: search.action_status! }]
|
||||||
|
}
|
||||||
|
|
||||||
const [{ queryPageIndex, queryPageSize, totalCount, queryFilters }, dispatch] =
|
const [{ queryPageIndex, queryPageSize, totalCount, queryFilters }, dispatch] =
|
||||||
React.useReducer(TableReducer, initialState);
|
React.useReducer(TableReducer, initialState);
|
||||||
|
|
||||||
const { isLoading, error, data, isSuccess } = useQuery({
|
const { isLoading, error, data, isSuccess } = useQuery(ReleasesListQueryOptions(queryPageIndex * queryPageSize, queryPageSize, queryFilters));
|
||||||
queryKey: releaseKeys.list(queryPageIndex, queryPageSize, queryFilters),
|
|
||||||
queryFn: () => APIClient.release.findQuery(queryPageIndex * queryPageSize, queryPageSize, queryFilters),
|
|
||||||
staleTime: 5000
|
|
||||||
});
|
|
||||||
|
|
||||||
const [modifiedData, setModifiedData] = useState<Release[]>([]);
|
const [modifiedData, setModifiedData] = useState<Release[]>([]);
|
||||||
const [showLinuxIsos, setShowLinuxIsos] = useState(false);
|
const [showLinuxIsos, setShowLinuxIsos] = useState(false);
|
||||||
|
@ -207,10 +220,10 @@ export const ReleaseTable = () => {
|
||||||
}, [filters]);
|
}, [filters]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (filterTypeFromUrl != null) {
|
if (search.action_status != null) {
|
||||||
dispatch({ type: ActionType.FILTER_CHANGED, payload: [{ id: "action_status", value: filterTypeFromUrl! }] });
|
dispatch({ type: ActionType.FILTER_CHANGED, payload: [{ id: "action_status", value: search.action_status! }] });
|
||||||
}
|
}
|
||||||
}, [filterTypeFromUrl]);
|
}, [search.action_status]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <p>Error</p>;
|
return <p>Error</p>;
|
||||||
|
@ -218,167 +231,33 @@ export const ReleaseTable = () => {
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col animate-pulse">
|
<div>
|
||||||
<div className="flex mb-6 flex-col sm:flex-row">
|
<div className="flex mb-6 flex-col sm:flex-row">
|
||||||
{headerGroups.map((headerGroup) =>
|
{ headerGroups.map((headerGroup) => headerGroup.headers.map((column) => (
|
||||||
headerGroup.headers.map((column) => (
|
|
||||||
column.Filter ? (
|
column.Filter ? (
|
||||||
<React.Fragment key={column.id}>{column.render("Filter")}</React.Fragment>
|
<React.Fragment key={ column.id }>{ column.render("Filter") }</React.Fragment>
|
||||||
) : null
|
) : null
|
||||||
))
|
))
|
||||||
)}
|
) }
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-lg rounded-md overflow-auto">
|
<div className="bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-lg rounded-md mt-4">
|
||||||
<table {...getTableProps()} className="min-w-full rounded-md divide-y divide-gray-200 dark:divide-gray-750">
|
<table className="min-w-full rounded-md divide-y divide-gray-200 dark:divide-gray-750">
|
||||||
<thead className="bg-gray-100 dark:bg-gray-800">
|
<thead className="bg-gray-100 dark:bg-gray-850 border-b border-gray-200 dark:border-gray-750">
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<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">
|
<div className="flex items-center justify-between">
|
||||||
{/* Add a sort direction indicator */}
|
<span className="h-10"/>
|
||||||
<span className="h-4">
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
</thead>
|
</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 "> </td>
|
|
||||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </td>
|
|
||||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </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 "> </td>
|
|
||||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </td>
|
|
||||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </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 "> </td>
|
|
||||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </td>
|
|
||||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap"> </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 "> </td>
|
|
||||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </td>
|
|
||||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </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"> </td>
|
|
||||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </td>
|
|
||||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </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 "> </td>
|
|
||||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </td>
|
|
||||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </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 "> </td>
|
|
||||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </td>
|
|
||||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </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 "> </td>
|
|
||||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </td>
|
|
||||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </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 "> </td>
|
|
||||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </td>
|
|
||||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
|
<div className="flex items-center justify-center py-64">
|
||||||
{/* Pagination */}
|
<RingResizeSpinner className="text-blue-500 size-24"/>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return <EmptyListState text="No recent activity" />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render the UI for your table
|
// Render the UI for your table
|
||||||
|
@ -394,15 +273,18 @@ export const ReleaseTable = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
{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">
|
<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">
|
<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">
|
<thead className="bg-gray-100 dark:bg-gray-850">
|
||||||
{headerGroups.map((headerGroup) => {
|
{headerGroups.map((headerGroup) => {
|
||||||
const { key: rowKey, ...rowRest } = headerGroup.getHeaderGroupProps();
|
const {key: rowKey, ...rowRest} = headerGroup.getHeaderGroupProps();
|
||||||
return (
|
return (
|
||||||
<tr key={rowKey} {...rowRest}>
|
<tr key={rowKey} {...rowRest}>
|
||||||
{headerGroup.headers.map((column) => {
|
{headerGroup.headers.map((column) => {
|
||||||
const { key: columnKey, ...columnRest } = column.getHeaderProps(column.getSortByToggleProps());
|
const {key: columnKey, ...columnRest} = column.getHeaderProps(column.getSortByToggleProps());
|
||||||
return (
|
return (
|
||||||
// Add the sorting props to control sorting. For this example
|
// Add the sorting props to control sorting. For this example
|
||||||
// we can add them into the header props
|
// we can add them into the header props
|
||||||
|
@ -418,12 +300,12 @@ export const ReleaseTable = () => {
|
||||||
<span>
|
<span>
|
||||||
{column.isSorted ? (
|
{column.isSorted ? (
|
||||||
column.isSortedDesc ? (
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -441,11 +323,11 @@ export const ReleaseTable = () => {
|
||||||
{page.map((row) => {
|
{page.map((row) => {
|
||||||
prepareRow(row);
|
prepareRow(row);
|
||||||
|
|
||||||
const { key: bodyRowKey, ...bodyRowRest } = row.getRowProps();
|
const {key: bodyRowKey, ...bodyRowRest} = row.getRowProps();
|
||||||
return (
|
return (
|
||||||
<tr key={bodyRowKey} {...bodyRowRest}>
|
<tr key={bodyRowKey} {...bodyRowRest}>
|
||||||
{row.cells.map((cell) => {
|
{row.cells.map((cell) => {
|
||||||
const { key: cellRowKey, ...cellRowRest } = cell.getCellProps();
|
const {key: cellRowKey, ...cellRowRest} = cell.getCellProps();
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={cellRowKey}
|
key={cellRowKey}
|
||||||
|
@ -471,7 +353,8 @@ export const ReleaseTable = () => {
|
||||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||||
<div className="flex items-baseline gap-x-2">
|
<div className="flex items-baseline gap-x-2">
|
||||||
<span className="text-sm text-gray-700 dark:text-gray-500">
|
<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>
|
</span>
|
||||||
<label>
|
<label>
|
||||||
<span className="sr-only bg-gray-700">Items Per Page</span>
|
<span className="sr-only bg-gray-700">Items Per Page</span>
|
||||||
|
@ -498,14 +381,14 @@ export const ReleaseTable = () => {
|
||||||
disabled={!canPreviousPage}
|
disabled={!canPreviousPage}
|
||||||
>
|
>
|
||||||
<span className="sr-only">First</span>
|
<span className="sr-only">First</span>
|
||||||
<ChevronDoubleLeftIcon className="w-4 h-4" aria-hidden="true" />
|
<ChevronDoubleLeftIcon className="w-4 h-4" aria-hidden="true"/>
|
||||||
</DataTable.PageButton>
|
</DataTable.PageButton>
|
||||||
<DataTable.PageButton
|
<DataTable.PageButton
|
||||||
className="pl-1 pr-2"
|
className="pl-1 pr-2"
|
||||||
onClick={() => previousPage()}
|
onClick={() => previousPage()}
|
||||||
disabled={!canPreviousPage}
|
disabled={!canPreviousPage}
|
||||||
>
|
>
|
||||||
<ChevronLeftIcon className="w-4 h-4 mr-1" aria-hidden="true" />
|
<ChevronLeftIcon className="w-4 h-4 mr-1" aria-hidden="true"/>
|
||||||
<span>Prev</span>
|
<span>Prev</span>
|
||||||
</DataTable.PageButton>
|
</DataTable.PageButton>
|
||||||
<DataTable.PageButton
|
<DataTable.PageButton
|
||||||
|
@ -513,14 +396,14 @@ export const ReleaseTable = () => {
|
||||||
onClick={() => nextPage()}
|
onClick={() => nextPage()}
|
||||||
disabled={!canNextPage}>
|
disabled={!canNextPage}>
|
||||||
<span>Next</span>
|
<span>Next</span>
|
||||||
<ChevronRightIcon className="w-4 h-4 ml-1" aria-hidden="true" />
|
<ChevronRightIcon className="w-4 h-4 ml-1" aria-hidden="true"/>
|
||||||
</DataTable.PageButton>
|
</DataTable.PageButton>
|
||||||
<DataTable.PageButton
|
<DataTable.PageButton
|
||||||
className="rounded-r-md"
|
className="rounded-r-md"
|
||||||
onClick={() => gotoPage(pageCount - 1)}
|
onClick={() => gotoPage(pageCount - 1)}
|
||||||
disabled={!canNextPage}
|
disabled={!canNextPage}
|
||||||
>
|
>
|
||||||
<ChevronDoubleRightIcon className="w-4 h-4" aria-hidden="true" />
|
<ChevronDoubleRightIcon className="w-4 h-4" aria-hidden="true"/>
|
||||||
<span className="sr-only">Last</span>
|
<span className="sr-only">Last</span>
|
||||||
</DataTable.PageButton>
|
</DataTable.PageButton>
|
||||||
</nav>
|
</nav>
|
||||||
|
@ -535,13 +418,14 @@ export const ReleaseTable = () => {
|
||||||
title="Go incognito"
|
title="Go incognito"
|
||||||
>
|
>
|
||||||
{showLinuxIsos ? (
|
{showLinuxIsos ? (
|
||||||
<EyeIcon className="h-4 w-4" />
|
<EyeIcon className="h-4 w-4"/>
|
||||||
) : (
|
) : (
|
||||||
<EyeSlashIcon className="h-4 w-4" />
|
<EyeSlashIcon className="h-4 w-4"/>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -4,15 +4,17 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
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 { Form, Formik } from "formik";
|
||||||
import { PasswordField, TextField } from "@components/inputs";
|
|
||||||
import { AuthContext } from "@utils/Context";
|
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { UserIcon } from "@heroicons/react/24/solid";
|
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 = () => (
|
const AccountSettings = () => (
|
||||||
<Section
|
<Section
|
||||||
title="Account"
|
title="Account"
|
||||||
|
@ -33,8 +35,7 @@ interface InputValues {
|
||||||
}
|
}
|
||||||
|
|
||||||
function Credentials() {
|
function Credentials() {
|
||||||
const [ getAuthContext ] = AuthContext.use();
|
const ctx = SettingsAccountRoute.useRouteContext()
|
||||||
|
|
||||||
|
|
||||||
const validate = (values: InputValues) => {
|
const validate = (values: InputValues) => {
|
||||||
const errors: Record<string, string> = {};
|
const errors: Record<string, string> = {};
|
||||||
|
@ -51,7 +52,8 @@ function Credentials() {
|
||||||
const logoutMutation = useMutation({
|
const logoutMutation = useMutation({
|
||||||
mutationFn: APIClient.auth.logout,
|
mutationFn: APIClient.auth.logout,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
AuthContext.reset();
|
AuthContext.logout();
|
||||||
|
|
||||||
toast.custom((t) => (
|
toast.custom((t) => (
|
||||||
<Toast type="success" body="User updated successfully. Please sign in again!" t={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">
|
<div className="px-2 pb-6 bg-white dark:bg-gray-800">
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
username: getAuthContext.username,
|
username: ctx.auth.username!,
|
||||||
newUsername: "",
|
newUsername: "",
|
||||||
oldPassword: "",
|
oldPassword: "",
|
||||||
newPassword: "",
|
newPassword: "",
|
||||||
|
|
|
@ -13,33 +13,19 @@ import { DeleteModal } from "@components/modals";
|
||||||
import { APIKeyAddForm } from "@forms/settings/APIKeyAddForm";
|
import { APIKeyAddForm } from "@forms/settings/APIKeyAddForm";
|
||||||
import Toast from "@components/notifications/Toast";
|
import Toast from "@components/notifications/Toast";
|
||||||
import { APIClient } from "@api/APIClient";
|
import { APIClient } from "@api/APIClient";
|
||||||
|
import { ApikeysQueryOptions } from "@api/queries";
|
||||||
|
import { ApiKeys } from "@api/query_keys";
|
||||||
import { useToggle } from "@hooks/hooks";
|
import { useToggle } from "@hooks/hooks";
|
||||||
import { classNames } from "@utils";
|
import { classNames } from "@utils";
|
||||||
import { EmptySimple } from "@components/emptystates";
|
import { EmptySimple } from "@components/emptystates";
|
||||||
import { Section } from "./_components";
|
import { Section } from "./_components";
|
||||||
import { PlusIcon } from "@heroicons/react/24/solid";
|
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() {
|
function APISettings() {
|
||||||
const [addFormIsOpen, toggleAddForm] = useToggle(false);
|
const [addFormIsOpen, toggleAddForm] = useToggle(false);
|
||||||
|
|
||||||
const { isError, error, data } = useSuspenseQuery({
|
const apikeysQuery = useSuspenseQuery(ApikeysQueryOptions())
|
||||||
queryKey: apiKeys.lists(),
|
|
||||||
queryFn: APIClient.apikeys.getAll,
|
|
||||||
retry: false,
|
|
||||||
refetchOnWindowFocus: false
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
console.log(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
|
@ -58,7 +44,7 @@ function APISettings() {
|
||||||
>
|
>
|
||||||
<APIKeyAddForm isOpen={addFormIsOpen} toggle={toggleAddForm} />
|
<APIKeyAddForm isOpen={addFormIsOpen} toggle={toggleAddForm} />
|
||||||
|
|
||||||
{data && data.length > 0 ? (
|
{apikeysQuery.data && apikeysQuery.data.length > 0 ? (
|
||||||
<ul className="min-w-full relative">
|
<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">
|
<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">
|
<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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
{data.map((k, idx) => <APIListItem key={idx} apikey={k} />)}
|
{apikeysQuery.data.map((k, idx) => <APIListItem key={idx} apikey={k} />)}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<EmptySimple
|
<EmptySimple
|
||||||
|
@ -96,8 +82,8 @@ function APIListItem({ apikey }: ApiKeyItemProps) {
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (key: string) => APIClient.apikeys.delete(key),
|
mutationFn: (key: string) => APIClient.apikeys.delete(key),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: apiKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: ApiKeys.lists() });
|
||||||
queryClient.invalidateQueries({ queryKey: apiKeys.detail(apikey.key) });
|
queryClient.invalidateQueries({ queryKey: ApiKeys.detail(apikey.key) });
|
||||||
|
|
||||||
toast.custom((t) => (
|
toast.custom((t) => (
|
||||||
<Toast
|
<Toast
|
||||||
|
|
|
@ -3,10 +3,13 @@
|
||||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
* 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 { toast } from "react-hot-toast";
|
||||||
|
|
||||||
|
import { SettingsIndexRoute } from "@app/routes";
|
||||||
import { APIClient } from "@api/APIClient";
|
import { APIClient } from "@api/APIClient";
|
||||||
|
import { ConfigQueryOptions, UpdatesQueryOptions } from "@api/queries";
|
||||||
|
import { SettingsKeys } from "@api/query_keys";
|
||||||
import { SettingsContext } from "@utils/Context";
|
import { SettingsContext } from "@utils/Context";
|
||||||
import { Checkbox } from "@components/Checkbox";
|
import { Checkbox } from "@components/Checkbox";
|
||||||
import Toast from "@components/notifications/Toast";
|
import Toast from "@components/notifications/Toast";
|
||||||
|
@ -17,34 +20,23 @@ import { Section, RowItem } from "./_components";
|
||||||
function ApplicationSettings() {
|
function ApplicationSettings() {
|
||||||
const [settings, setSettings] = SettingsContext.use();
|
const [settings, setSettings] = SettingsContext.use();
|
||||||
|
|
||||||
const { isError:isConfigError, error: configError, data } = useQuery({
|
const ctx = SettingsIndexRoute.useRouteContext()
|
||||||
queryKey: ["config"],
|
const queryClient = ctx.queryClient
|
||||||
queryFn: APIClient.config.get,
|
|
||||||
retry: false,
|
const { isError:isConfigError, error: configError, data } = useQuery(ConfigQueryOptions());
|
||||||
refetchOnWindowFocus: false
|
|
||||||
});
|
|
||||||
if (isConfigError) {
|
if (isConfigError) {
|
||||||
console.log(configError);
|
console.log(configError);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isError, error, data: updateData } = useQuery({
|
const { isError, error, data: updateData } = useQuery(UpdatesQueryOptions(data?.check_for_updates === true));
|
||||||
queryKey: ["updates"],
|
|
||||||
queryFn: APIClient.updates.getLatestRelease,
|
|
||||||
retry: false,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
enabled: data?.check_for_updates === true
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const checkUpdateMutation = useMutation({
|
const checkUpdateMutation = useMutation({
|
||||||
mutationFn: APIClient.updates.check,
|
mutationFn: APIClient.updates.check,
|
||||||
onSuccess: () => {
|
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),
|
mutationFn: (value: boolean) => APIClient.config.update({ check_for_updates: value }).then(() => value),
|
||||||
onSuccess: (_, value: boolean) => {
|
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} />);
|
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();
|
checkUpdateMutation.mutate();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
* 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 { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { PlusIcon } from "@heroicons/react/24/solid";
|
import { PlusIcon } from "@heroicons/react/24/solid";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
@ -12,20 +12,14 @@ import { useToggle } from "@hooks/hooks";
|
||||||
import { DownloadClientAddForm, DownloadClientUpdateForm } from "@forms";
|
import { DownloadClientAddForm, DownloadClientUpdateForm } from "@forms";
|
||||||
import { EmptySimple } from "@components/emptystates";
|
import { EmptySimple } from "@components/emptystates";
|
||||||
import { APIClient } from "@api/APIClient";
|
import { APIClient } from "@api/APIClient";
|
||||||
|
import { DownloadClientKeys } from "@api/query_keys";
|
||||||
|
import { DownloadClientsQueryOptions } from "@api/queries";
|
||||||
import { ActionTypeNameMap } from "@domain/constants";
|
import { ActionTypeNameMap } from "@domain/constants";
|
||||||
import Toast from "@components/notifications/Toast";
|
import Toast from "@components/notifications/Toast";
|
||||||
import { Checkbox } from "@components/Checkbox";
|
import { Checkbox } from "@components/Checkbox";
|
||||||
|
|
||||||
import { Section } from "./_components";
|
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 {
|
interface DLSettingsItemProps {
|
||||||
client: DownloadClient;
|
client: DownloadClient;
|
||||||
}
|
}
|
||||||
|
@ -97,7 +91,7 @@ function ListItem({ client }: DLSettingsItemProps) {
|
||||||
mutationFn: (client: DownloadClient) => APIClient.download_clients.update(client).then(() => client),
|
mutationFn: (client: DownloadClient) => APIClient.download_clients.update(client).then(() => client),
|
||||||
onSuccess: (client: DownloadClient) => {
|
onSuccess: (client: DownloadClient) => {
|
||||||
toast.custom(t => <Toast type="success" body={`${client.name} was ${client.enabled ? "enabled" : "disabled"} successfully.`} t={t} />);
|
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() {
|
function DownloadClientSettings() {
|
||||||
const [addClientIsOpen, toggleAddClient] = useToggle(false);
|
const [addClientIsOpen, toggleAddClient] = useToggle(false);
|
||||||
|
|
||||||
const { error, data } = useSuspenseQuery({
|
const downloadClientsQuery = useSuspenseQuery(DownloadClientsQueryOptions())
|
||||||
queryKey: clientKeys.lists(),
|
|
||||||
queryFn: APIClient.download_clients.getAll,
|
|
||||||
refetchOnWindowFocus: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortedClients = useSort(data || []);
|
const sortedClients = useSort(downloadClientsQuery.data || []);
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <p>Failed to fetch download clients</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
* 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 { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { Menu, Transition } from "@headlessui/react";
|
import { Menu, Transition } from "@headlessui/react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
|
@ -11,12 +11,14 @@ import {
|
||||||
ArrowsRightLeftIcon,
|
ArrowsRightLeftIcon,
|
||||||
DocumentTextIcon,
|
DocumentTextIcon,
|
||||||
EllipsisHorizontalIcon,
|
EllipsisHorizontalIcon,
|
||||||
PencilSquareIcon,
|
|
||||||
ForwardIcon,
|
ForwardIcon,
|
||||||
|
PencilSquareIcon,
|
||||||
TrashIcon
|
TrashIcon
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
import { APIClient } from "@api/APIClient";
|
import { APIClient } from "@api/APIClient";
|
||||||
|
import { FeedsQueryOptions } from "@api/queries";
|
||||||
|
import { FeedKeys } from "@api/query_keys";
|
||||||
import { useToggle } from "@hooks/hooks";
|
import { useToggle } from "@hooks/hooks";
|
||||||
import { baseUrl, classNames, IsEmptyDate, simplifyDate } from "@utils";
|
import { baseUrl, classNames, IsEmptyDate, simplifyDate } from "@utils";
|
||||||
import Toast from "@components/notifications/Toast";
|
import Toast from "@components/notifications/Toast";
|
||||||
|
@ -29,14 +31,6 @@ import { ExternalLink } from "@components/ExternalLink";
|
||||||
import { Section } from "./_components";
|
import { Section } from "./_components";
|
||||||
import { Checkbox } from "@components/Checkbox";
|
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 {
|
interface SortConfig {
|
||||||
key: keyof ListItemProps["feed"] | "enabled";
|
key: keyof ListItemProps["feed"] | "enabled";
|
||||||
direction: "ascending" | "descending";
|
direction: "ascending" | "descending";
|
||||||
|
@ -97,20 +91,16 @@ function useSort(items: ListItemProps["feed"][], config?: SortConfig) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeedSettings() {
|
function FeedSettings() {
|
||||||
const { data } = useSuspenseQuery({
|
const feedsQuery = useSuspenseQuery(FeedsQueryOptions())
|
||||||
queryKey: feedKeys.lists(),
|
|
||||||
queryFn: APIClient.feeds.find,
|
|
||||||
refetchOnWindowFocus: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortedFeeds = useSort(data || []);
|
const sortedFeeds = useSort(feedsQuery.data || []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
title="Feeds"
|
title="Feeds"
|
||||||
description="Manage RSS, Newznab, and Torznab feeds."
|
description="Manage RSS, Newznab, and Torznab feeds."
|
||||||
>
|
>
|
||||||
{data && data.length > 0 ? (
|
{feedsQuery.data && feedsQuery.data.length > 0 ? (
|
||||||
<ul className="min-w-full relative">
|
<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">
|
<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
|
<div
|
||||||
|
@ -163,8 +153,8 @@ function ListItem({ feed }: ListItemProps) {
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: (status: boolean) => APIClient.feeds.toggleEnable(feed.id, status),
|
mutationFn: (status: boolean) => APIClient.feeds.toggleEnable(feed.id, status),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: feedKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: FeedKeys.lists() });
|
||||||
queryClient.invalidateQueries({ queryKey: feedKeys.detail(feed.id) });
|
queryClient.invalidateQueries({ queryKey: FeedKeys.detail(feed.id) });
|
||||||
|
|
||||||
toast.custom((t) => <Toast type="success" body={`${feed.name} was ${!enabled ? "disabled" : "enabled"} successfully.`} t={t} />);
|
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({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: number) => APIClient.feeds.delete(id),
|
mutationFn: (id: number) => APIClient.feeds.delete(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: feedKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: FeedKeys.lists() });
|
||||||
queryClient.invalidateQueries({ queryKey: feedKeys.detail(feed.id) });
|
queryClient.invalidateQueries({ queryKey: FeedKeys.detail(feed.id) });
|
||||||
|
|
||||||
toast.custom((t) => <Toast type="success" body={`Feed ${feed?.name} was deleted`} t={t} />);
|
toast.custom((t) => <Toast type="success" body={`Feed ${feed?.name} was deleted`} t={t} />);
|
||||||
}
|
}
|
||||||
|
@ -257,7 +247,7 @@ const FeedItemDropdown = ({
|
||||||
const forceRunMutation = useMutation({
|
const forceRunMutation = useMutation({
|
||||||
mutationFn: (id: number) => APIClient.feeds.forceRun(id),
|
mutationFn: (id: number) => APIClient.feeds.forceRun(id),
|
||||||
onSuccess: () => {
|
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} />);
|
toast.custom((t) => <Toast type="success" body={`Feed ${feed?.name} was force run successfully.`} t={t} />);
|
||||||
toggleForceRunModal();
|
toggleForceRunModal();
|
||||||
},
|
},
|
||||||
|
|
|
@ -3,13 +3,15 @@
|
||||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
* 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 toast from "react-hot-toast";
|
||||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { PlusIcon } from "@heroicons/react/24/solid";
|
import { PlusIcon } from "@heroicons/react/24/solid";
|
||||||
|
|
||||||
import { useToggle } from "@hooks/hooks";
|
import { useToggle } from "@hooks/hooks";
|
||||||
import { APIClient } from "@api/APIClient";
|
import { APIClient } from "@api/APIClient";
|
||||||
|
import { IndexerKeys } from "@api/query_keys";
|
||||||
|
import { IndexersQueryOptions } from "@api/queries";
|
||||||
import { Checkbox } from "@components/Checkbox";
|
import { Checkbox } from "@components/Checkbox";
|
||||||
import Toast from "@components/notifications/Toast";
|
import Toast from "@components/notifications/Toast";
|
||||||
import { EmptySimple } from "@components/emptystates";
|
import { EmptySimple } from "@components/emptystates";
|
||||||
|
@ -18,14 +20,6 @@ import { componentMapType } from "@forms/settings/DownloadClientForms";
|
||||||
|
|
||||||
import { Section } from "./_components";
|
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 {
|
interface SortConfig {
|
||||||
key: keyof ListItemProps["indexer"] | "enabled";
|
key: keyof ListItemProps["indexer"] | "enabled";
|
||||||
direction: "ascending" | "descending";
|
direction: "ascending" | "descending";
|
||||||
|
@ -123,7 +117,7 @@ const ListItem = ({ indexer }: ListItemProps) => {
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: (enabled: boolean) => APIClient.indexers.toggleEnable(indexer.id, enabled),
|
mutationFn: (enabled: boolean) => APIClient.indexers.toggleEnable(indexer.id, enabled),
|
||||||
onSuccess: () => {
|
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} />);
|
toast.custom((t) => <Toast type="success" body={`${indexer.name} was updated successfully`} t={t} />);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -169,17 +163,13 @@ const ListItem = ({ indexer }: ListItemProps) => {
|
||||||
function IndexerSettings() {
|
function IndexerSettings() {
|
||||||
const [addIndexerIsOpen, toggleAddIndexer] = useToggle(false);
|
const [addIndexerIsOpen, toggleAddIndexer] = useToggle(false);
|
||||||
|
|
||||||
const { error, data } = useSuspenseQuery({
|
const indexersQuery = useSuspenseQuery(IndexersQueryOptions())
|
||||||
queryKey: indexerKeys.lists(),
|
const indexers = indexersQuery.data
|
||||||
queryFn: APIClient.indexers.getAll,
|
const sortedIndexers = useSort(indexers || []);
|
||||||
refetchOnWindowFocus: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortedIndexers = useSort(data || []);
|
// if (error) {
|
||||||
|
// return (<p>An error has occurred</p>);
|
||||||
if (error) {
|
// }
|
||||||
return (<p>An error has occurred</p>);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
* 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 { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { LockClosedIcon, LockOpenIcon, PlusIcon } from "@heroicons/react/24/solid";
|
import { LockClosedIcon, LockOpenIcon, PlusIcon } from "@heroicons/react/24/solid";
|
||||||
import { Menu, Transition } from "@headlessui/react";
|
import { Menu, Transition } from "@headlessui/react";
|
||||||
|
@ -22,23 +22,16 @@ import { classNames, IsEmptyDate, simplifyDate } from "@utils";
|
||||||
import { IrcNetworkAddForm, IrcNetworkUpdateForm } from "@forms";
|
import { IrcNetworkAddForm, IrcNetworkUpdateForm } from "@forms";
|
||||||
import { useToggle } from "@hooks/hooks";
|
import { useToggle } from "@hooks/hooks";
|
||||||
import { APIClient } from "@api/APIClient";
|
import { APIClient } from "@api/APIClient";
|
||||||
|
import { IrcKeys } from "@api/query_keys";
|
||||||
|
import { IrcQueryOptions } from "@api/queries";
|
||||||
import { EmptySimple } from "@components/emptystates";
|
import { EmptySimple } from "@components/emptystates";
|
||||||
import { DeleteModal } from "@components/modals";
|
import { DeleteModal } from "@components/modals";
|
||||||
import Toast from "@components/notifications/Toast";
|
import Toast from "@components/notifications/Toast";
|
||||||
import { SettingsContext } from "@utils/Context";
|
import { SettingsContext } from "@utils/Context";
|
||||||
import { Checkbox } from "@components/Checkbox";
|
import { Checkbox } from "@components/Checkbox";
|
||||||
// import { useForm } from "react-hook-form";
|
|
||||||
|
|
||||||
import { Section } from "./_components";
|
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 {
|
interface SortConfig {
|
||||||
key: keyof ListItemProps["network"] | "enabled";
|
key: keyof ListItemProps["network"] | "enabled";
|
||||||
direction: "ascending" | "descending";
|
direction: "ascending" | "descending";
|
||||||
|
@ -98,14 +91,9 @@ const IrcSettings = () => {
|
||||||
const [expandNetworks, toggleExpand] = useToggle(false);
|
const [expandNetworks, toggleExpand] = useToggle(false);
|
||||||
const [addNetworkIsOpen, toggleAddNetwork] = useToggle(false);
|
const [addNetworkIsOpen, toggleAddNetwork] = useToggle(false);
|
||||||
|
|
||||||
const { data } = useSuspenseQuery({
|
const ircQuery = useSuspenseQuery(IrcQueryOptions())
|
||||||
queryKey: ircKeys.lists(),
|
|
||||||
queryFn: APIClient.irc.getNetworks,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchInterval: 3000 // Refetch every 3 seconds
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortedNetworks = useSort(data || []);
|
const sortedNetworks = useSort(ircQuery.data || []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
|
@ -168,7 +156,7 @@ const IrcSettings = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data && data.length > 0 ? (
|
{ircQuery.data && ircQuery.data.length > 0 ? (
|
||||||
<ul className="mt-6 min-w-full relative text-sm">
|
<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">
|
<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"
|
<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({
|
const updateMutation = useMutation({
|
||||||
mutationFn: (network: IrcNetwork) => APIClient.irc.updateNetwork(network).then(() => network),
|
mutationFn: (network: IrcNetwork) => APIClient.irc.updateNetwork(network).then(() => network),
|
||||||
onSuccess: (network: IrcNetwork) => {
|
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} />);
|
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({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: number) => APIClient.irc.deleteNetwork(id),
|
mutationFn: (id: number) => APIClient.irc.deleteNetwork(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ircKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: IrcKeys.lists() });
|
||||||
queryClient.invalidateQueries({ queryKey: ircKeys.detail(network.id) });
|
queryClient.invalidateQueries({ queryKey: IrcKeys.detail(network.id) });
|
||||||
|
|
||||||
toast.custom((t) => <Toast type="success" body={`Network ${network.name} was deleted`} t={t} />);
|
toast.custom((t) => <Toast type="success" body={`Network ${network.name} was deleted`} t={t} />);
|
||||||
|
|
||||||
|
@ -443,8 +431,8 @@ const ListItemDropdown = ({
|
||||||
const restartMutation = useMutation({
|
const restartMutation = useMutation({
|
||||||
mutationFn: (id: number) => APIClient.irc.restartNetwork(id),
|
mutationFn: (id: number) => APIClient.irc.restartNetwork(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ircKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: IrcKeys.lists() });
|
||||||
queryClient.invalidateQueries({ queryKey: ircKeys.detail(network.id) });
|
queryClient.invalidateQueries({ queryKey: IrcKeys.detail(network.id) });
|
||||||
|
|
||||||
toast.custom((t) => <Toast type="success" body={`${network.name} was successfully restarted`} t={t} />);
|
toast.custom((t) => <Toast type="success" body={`${network.name} was successfully restarted`} t={t} />);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,12 +3,15 @@
|
||||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
* 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 { toast } from "react-hot-toast";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import Select from "react-select";
|
import Select from "react-select";
|
||||||
|
|
||||||
import { APIClient } from "@api/APIClient";
|
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 Toast from "@components/notifications/Toast";
|
||||||
import { LogLevelOptions, SelectOption } from "@domain/constants";
|
import { LogLevelOptions, SelectOption } from "@domain/constants";
|
||||||
|
|
||||||
|
@ -56,25 +59,19 @@ const SelectWrapper = ({ id, value, onChange, options }: SelectWrapperProps) =>
|
||||||
);
|
);
|
||||||
|
|
||||||
function LogSettings() {
|
function LogSettings() {
|
||||||
const { isError, error, isLoading, data } = useSuspenseQuery({
|
const ctx = SettingsLogRoute.useRouteContext()
|
||||||
queryKey: ["config"],
|
const queryClient = ctx.queryClient
|
||||||
queryFn: APIClient.config.get,
|
|
||||||
retry: false,
|
|
||||||
refetchOnWindowFocus: false
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isError) {
|
const configQuery = useSuspenseQuery(ConfigQueryOptions())
|
||||||
console.log(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const config = configQuery.data
|
||||||
|
|
||||||
const setLogLevelUpdateMutation = useMutation({
|
const setLogLevelUpdateMutation = useMutation({
|
||||||
mutationFn: (value: string) => APIClient.config.update({ log_level: value }),
|
mutationFn: (value: string) => APIClient.config.update({ log_level: value }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.custom((t) => <Toast type="success" body={"Config successfully updated!"} t={t} />);
|
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
|
Configure log level, log size rotation, etc. You can download your old log files
|
||||||
{" "}
|
{" "}
|
||||||
<Link
|
<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"
|
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
|
on the Logs page
|
||||||
|
@ -96,9 +93,9 @@ function LogSettings() {
|
||||||
>
|
>
|
||||||
<div className="-mx-4 lg:col-span-9">
|
<div className="-mx-4 lg:col-span-9">
|
||||||
<div className="divide-y divide-gray-200 dark:divide-gray-750">
|
<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">
|
<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
|
<RowItem
|
||||||
className="sm:col-span-1"
|
className="sm:col-span-1"
|
||||||
label="Level"
|
label="Level"
|
||||||
|
@ -106,14 +103,14 @@ function LogSettings() {
|
||||||
value={
|
value={
|
||||||
<SelectWrapper
|
<SelectWrapper
|
||||||
id="log_level"
|
id="log_level"
|
||||||
value={data?.log_level}
|
value={config?.log_level}
|
||||||
options={LogLevelOptions}
|
options={LogLevelOptions}
|
||||||
onChange={(value: SelectOption) => setLogLevelUpdateMutation.mutate(value.value)}
|
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 Size" value={config?.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 Backups" value={config?.log_max_backups} title="Set in config.toml"/>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
@ -4,35 +4,33 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
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 { APIClient } from "@api/APIClient";
|
||||||
|
import { NotificationKeys } from "@api/query_keys";
|
||||||
|
import { NotificationsQueryOptions } from "@api/queries";
|
||||||
import { EmptySimple } from "@components/emptystates";
|
import { EmptySimple } from "@components/emptystates";
|
||||||
import { useToggle } from "@hooks/hooks";
|
import { useToggle } from "@hooks/hooks";
|
||||||
import { NotificationAddForm, NotificationUpdateForm } from "@forms/settings/NotificationForms";
|
import { NotificationAddForm, NotificationUpdateForm } from "@forms/settings/NotificationForms";
|
||||||
import { componentMapType } from "@forms/settings/DownloadClientForms";
|
import { componentMapType } from "@forms/settings/DownloadClientForms";
|
||||||
import Toast from "@components/notifications/Toast";
|
import Toast from "@components/notifications/Toast";
|
||||||
import toast from "react-hot-toast";
|
import {
|
||||||
import { Section } from "./_components";
|
DiscordIcon,
|
||||||
import { PlusIcon } from "@heroicons/react/24/solid";
|
GotifyIcon,
|
||||||
|
LunaSeaIcon,
|
||||||
|
NotifiarrIcon,
|
||||||
|
NtfyIcon,
|
||||||
|
PushoverIcon,
|
||||||
|
Section,
|
||||||
|
TelegramIcon
|
||||||
|
} from "./_components";
|
||||||
import { Checkbox } from "@components/Checkbox";
|
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() {
|
function NotificationSettings() {
|
||||||
const [addNotificationsIsOpen, toggleAddNotifications] = useToggle(false);
|
const [addNotificationsIsOpen, toggleAddNotifications] = useToggle(false);
|
||||||
|
|
||||||
const { data } = useSuspenseQuery({
|
const notificationsQuery = useSuspenseQuery(NotificationsQueryOptions())
|
||||||
queryKey: notificationKeys.lists(),
|
|
||||||
queryFn: APIClient.notifications.getAll,
|
|
||||||
refetchOnWindowFocus: false
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
|
@ -51,7 +49,7 @@ function NotificationSettings() {
|
||||||
>
|
>
|
||||||
<NotificationAddForm isOpen={addNotificationsIsOpen} toggle={toggleAddNotifications} />
|
<NotificationAddForm isOpen={addNotificationsIsOpen} toggle={toggleAddNotifications} />
|
||||||
|
|
||||||
{data && data.length > 0 ? (
|
{notificationsQuery.data && notificationsQuery.data.length > 0 ? (
|
||||||
<ul className="min-w-full">
|
<ul className="min-w-full">
|
||||||
<li className="grid grid-cols-12 border-b border-gray-200 dark:border-gray-700">
|
<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>
|
<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>
|
<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>
|
</li>
|
||||||
|
|
||||||
{data.map((n) => <ListItem key={n.id} notification={n} />)}
|
{notificationsQuery.data.map((n) => <ListItem key={n.id} notification={n} />)}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<EmptySimple title="No notifications" subtitle="" buttonText="Create new notification" buttonAction={toggleAddNotifications} />
|
<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),
|
mutationFn: (notification: ServiceNotification) => APIClient.notifications.update(notification).then(() => notification),
|
||||||
onSuccess: (notification: ServiceNotification) => {
|
onSuccess: (notification: ServiceNotification) => {
|
||||||
toast.custom(t => <Toast type="success" body={`${notification.name} was ${notification.enabled ? "enabled" : "disabled"} successfully.`} t={t} />);
|
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() });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -8,8 +8,8 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
|
|
||||||
import { APIClient } from "@api/APIClient";
|
import { APIClient } from "@api/APIClient";
|
||||||
|
import { ReleaseKeys } from "@api/query_keys";
|
||||||
import Toast from "@components/notifications/Toast";
|
import Toast from "@components/notifications/Toast";
|
||||||
import { releaseKeys } from "@screens/releases/ReleaseTable";
|
|
||||||
import { useToggle } from "@hooks/hooks";
|
import { useToggle } from "@hooks/hooks";
|
||||||
import { DeleteModal } from "@components/modals";
|
import { DeleteModal } from "@components/modals";
|
||||||
import { Section } from "./_components";
|
import { Section } from "./_components";
|
||||||
|
@ -74,7 +74,7 @@ function DeleteReleases() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate filters just in case, most likely not necessary but can't hurt.
|
// Invalidate filters just in case, most likely not necessary but can't hurt.
|
||||||
queryClient.invalidateQueries({ queryKey: releaseKeys.lists() });
|
queryClient.invalidateQueries({ queryKey: ReleaseKeys.lists() });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -3,13 +3,8 @@
|
||||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { newRidgeState } from "react-ridge-state";
|
|
||||||
import type { StateWithValue } from "react-ridge-state";
|
import type { StateWithValue } from "react-ridge-state";
|
||||||
|
import { newRidgeState } from "react-ridge-state";
|
||||||
interface AuthInfo {
|
|
||||||
username: string;
|
|
||||||
isLoggedIn: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SettingsType {
|
interface SettingsType {
|
||||||
debug: boolean;
|
debug: boolean;
|
||||||
|
@ -26,11 +21,16 @@ export type FilterListState = {
|
||||||
status: string;
|
status: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// interface AuthInfo {
|
||||||
|
// username: string;
|
||||||
|
// isLoggedIn: boolean;
|
||||||
|
// }
|
||||||
|
|
||||||
// Default values
|
// Default values
|
||||||
const AuthContextDefaults: AuthInfo = {
|
// const AuthContextDefaults: AuthInfo = {
|
||||||
username: "",
|
// username: "",
|
||||||
isLoggedIn: false
|
// isLoggedIn: false
|
||||||
};
|
// };
|
||||||
|
|
||||||
const SettingsContextDefaults: SettingsType = {
|
const SettingsContextDefaults: SettingsType = {
|
||||||
debug: false,
|
debug: false,
|
||||||
|
@ -53,7 +53,7 @@ function ContextMerger<T extends {}>(
|
||||||
defaults: T,
|
defaults: T,
|
||||||
ctxState: StateWithValue<T>
|
ctxState: StateWithValue<T>
|
||||||
) {
|
) {
|
||||||
let values = defaults;
|
let values = structuredClone(defaults);
|
||||||
|
|
||||||
const storage = localStorage.getItem(key);
|
const storage = localStorage.getItem(key);
|
||||||
if (storage) {
|
if (storage) {
|
||||||
|
@ -62,7 +62,7 @@ function ContextMerger<T extends {}>(
|
||||||
if (json === null) {
|
if (json === null) {
|
||||||
console.warn(`JSON localStorage value for '${key}' context state is null`);
|
console.warn(`JSON localStorage value for '${key}' context state is null`);
|
||||||
} else {
|
} else {
|
||||||
values = { ...defaults, ...json };
|
values = { ...values, ...json };
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Failed to merge ${key} context state: ${e}`);
|
console.error(`Failed to merge ${key} context state: ${e}`);
|
||||||
|
@ -72,15 +72,18 @@ function ContextMerger<T extends {}>(
|
||||||
ctxState.set(values);
|
ctxState.set(values);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SettingsKey = "autobrr_settings";
|
||||||
|
const FilterListKey = "autobrr_filter_list";
|
||||||
|
|
||||||
export const InitializeGlobalContext = () => {
|
export const InitializeGlobalContext = () => {
|
||||||
ContextMerger<AuthInfo>("auth", AuthContextDefaults, AuthContext);
|
// ContextMerger<AuthInfo>(localStorageUserKey, AuthContextDefaults, AuthContextt);
|
||||||
ContextMerger<SettingsType>(
|
ContextMerger<SettingsType>(
|
||||||
"settings",
|
SettingsKey,
|
||||||
SettingsContextDefaults,
|
SettingsContextDefaults,
|
||||||
SettingsContext
|
SettingsContext
|
||||||
);
|
);
|
||||||
ContextMerger<FilterListState>(
|
ContextMerger<FilterListState>(
|
||||||
"filterList",
|
FilterListKey,
|
||||||
FilterListContextDefaults,
|
FilterListContextDefaults,
|
||||||
FilterListContext
|
FilterListContext
|
||||||
);
|
);
|
||||||
|
@ -98,16 +101,16 @@ function DefaultSetter<T>(name: string, newState: T, prevState: T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthContext = newRidgeState<AuthInfo>(AuthContextDefaults, {
|
// export const AuthContextt = newRidgeState<AuthInfo>(AuthContextDefaults, {
|
||||||
onSet: (newState, prevState) => DefaultSetter("auth", newState, prevState)
|
// onSet: (newState, prevState) => DefaultSetter(localStorageUserKey, newState, prevState)
|
||||||
});
|
// });
|
||||||
|
|
||||||
export const SettingsContext = newRidgeState<SettingsType>(
|
export const SettingsContext = newRidgeState<SettingsType>(
|
||||||
SettingsContextDefaults,
|
SettingsContextDefaults,
|
||||||
{
|
{
|
||||||
onSet: (newState, prevState) => {
|
onSet: (newState, prevState) => {
|
||||||
document.documentElement.classList.toggle("dark", newState.darkTheme);
|
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>(
|
export const FilterListContext = newRidgeState<FilterListState>(
|
||||||
FilterListContextDefaults,
|
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);
|
||||||
|
},
|
||||||
|
}
|
|
@ -12,7 +12,7 @@ export function sleep(ms: number) {
|
||||||
|
|
||||||
// get baseUrl sent from server rendered index template
|
// get baseUrl sent from server rendered index template
|
||||||
export function baseUrl() {
|
export function baseUrl() {
|
||||||
let baseUrl = "";
|
let baseUrl = "/";
|
||||||
if (window.APP.baseUrl) {
|
if (window.APP.baseUrl) {
|
||||||
if (window.APP.baseUrl === "{{.BaseUrl}}") {
|
if (window.APP.baseUrl === "{{.BaseUrl}}") {
|
||||||
baseUrl = "/";
|
baseUrl = "/";
|
||||||
|
@ -23,6 +23,20 @@ export function baseUrl() {
|
||||||
return 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
|
// get sseBaseUrl for SSE
|
||||||
export function sseBaseUrl() {
|
export function sseBaseUrl() {
|
||||||
if (process.env.NODE_ENV === "development")
|
if (process.env.NODE_ENV === "development")
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue