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

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

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

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

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

* fix test

* fix(web): api client handle 403

* refactor(http): auth_test use testify.assert

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

* refactor(http): send more client headers

* fix(http): test

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

* refactor(web): use route loaders and suspense

* refactor(web): useSuspense for settings

* refactor(web): invalidate cookie in middleware

* fix: loclfile

* fix: load filter/id

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

* fix(web): filter load

* fix(web): build errors

* fix(web): ts-expect-error

* fix(tests): filter_test.go

* fix(filters): tests

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

* fix: refactor missed SectionLoader to RingResizeSpinner

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

* fix(api): action status URL param

* revert: action status URL param
add comment

* fix(routing): notfound handling and split files

* fix(filters): notfound get params

* fix(queries): colon

* fix(queries): comments ts-ignore

* fix(queries): extract queryKeys

* fix(queries): remove err

* fix(routes): move zob schema inline

* fix(auth): middleware and redirect to login

* fix(auth): failing test

* fix(logs): invalidate correct key

* fix(logs): invalidate correct key

* fix(logs): invalidate correct key

* fix: JSX element stealing focus from searchbar

* reimplement empty release table state text

* fix(context): use deep-copy

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

* fix(releases): empty states

* fix(auth): onboarding

* fix(cache): invalidate queries

---------

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

View file

@ -12,13 +12,11 @@ import (
"github.com/autobrr/autobrr/internal/domain" "github.com/autobrr/autobrr/internal/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)
} }

View file

@ -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
}) })
} }

View file

@ -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"`

View file

@ -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) {

View file

@ -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")

View file

@ -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)
} }

View file

@ -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
} }

View file

@ -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
} }

View file

@ -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
View file

@ -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

View file

@ -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>
); );
} }

View file

@ -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);

View file

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

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

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

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

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

View file

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

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later * 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";
@ -14,6 +14,9 @@ export const NotFound = () => {
<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>

View file

@ -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 { FilterKeys } from "@api/query_keys";
import { classNames, humanFileSize, simplifyDate } from "@utils"; import { classNames, humanFileSize, simplifyDate } from "@utils";
import { filterKeys } from "@screens/filters/List"; 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} />

View file

@ -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)
}

View file

@ -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>

View file

@ -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"

View file

@ -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) => {

View file

@ -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"

View file

@ -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" },

View file

@ -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

View file

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

View file

@ -5,17 +5,18 @@
import { Fragment } from "react"; import { 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 }})
} }
} }
}); });

View file

@ -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}/>);

View file

@ -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();

View file

@ -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} />);
} }

View file

@ -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} />);

View file

@ -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} />);

View file

@ -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
View file

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

View file

@ -23,7 +23,6 @@ import { EmptySimple } from "@components/emptystates";
import { RingResizeSpinner } from "@components/Icons"; import { 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">

View file

@ -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>

View file

@ -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">

View file

@ -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 (

View file

@ -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">
&nbsp; 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>
</>
);
};

View file

@ -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>
); );

View file

@ -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>

View file

@ -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() });
} }
}; };

View file

@ -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}
> >
<> <>

View file

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

View file

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

View file

@ -7,7 +7,7 @@ import { useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast"; import { 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: "",

View file

@ -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."
> >
@ -91,10 +94,14 @@ 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."
> >
@ -124,10 +131,14 @@ 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)"
> >
@ -157,10 +168,14 @@ 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)"
> >
@ -220,10 +235,14 @@ 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)"
> >
@ -254,10 +273,14 @@ 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)"
> >
@ -275,10 +298,14 @@ const Language = ({ values }: ValueConsumer) => (
/> />
</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)"
> >
@ -296,10 +323,14 @@ const Origins = ({ values }: ValueConsumer) => (
/> />
</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)"
> >
@ -348,10 +379,13 @@ 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</>
@ -443,10 +477,13 @@ 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={
<> <>
@ -485,18 +522,21 @@ const RawReleaseTags = ({ values }: ValueConsumer) => (
/> />
</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>
); );
}

View file

@ -1,25 +1,23 @@
import { useQuery } from "@tanstack/react-query"; import { useSuspenseQuery } from "@tanstack/react-query";
import { APIClient } from "@api/APIClient";
import { downloadsPerUnitOptions } from "@domain/constants"; import { 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>

View file

@ -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>
@ -185,3 +188,4 @@ export const Music = ({ values }: { values: FormikValues; }) => (
</Components.Section> </Components.Section>
</Components.Page> </Components.Page>
); );
}

View file

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

View file

@ -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) => (

View file

@ -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 ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
<tr
className="flex justify-between py-4 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
<tr
className="flex justify-between py-4 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap">&nbsp;</td>
</tr>
<tr
className="flex justify-between py-4 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
<tr className="justify-between py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap text-center">
<p className="text-black dark:text-white">Loading release table...</p>
</td>
</tr>
<tr
className="flex justify-between py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
<tr
className="flex justify-between py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
<tr
className="flex justify-between py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
<tr
className="flex justify-between py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
<tr
className="flex justify-between py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
</tbody>
</table> </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,6 +273,9 @@ 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">
@ -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>
@ -542,6 +425,7 @@ export const ReleaseTable = () => {
</button> </button>
</div> </div>
</div> </div>
)}
</div> </div>
</div> </div>
); );

View file

@ -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: "",

View file

@ -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

View file

@ -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();
} }
}); });

View file

@ -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

View file

@ -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();
}, },

View file

@ -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

View file

@ -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} />);
} }

View file

@ -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>
)} )}

View file

@ -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() });
} }
}); });

View file

@ -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() });
} }
}); });

View file

@ -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);
},
}

View file

@ -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")