-
-
-
-
-
-
- Total:{" "}
-
-
-
+ if (filter.displayMode === DisplayMode.Grid) {
+ return (
+
+ {result.data.findTags.tags.map((tag) => (
+
+ ))}
-
- );
- });
+ );
+ }
+ if (filter.displayMode === DisplayMode.List) {
+ const tagElements = result.data.findTags.tags.map((tag) => {
+ return (
+
+
{tag.name}
- return (
-
-
+
+
+
+
+
+ Total:{" "}
+
+
+
+
+
+ );
+ });
-
setEditingTag(null)}
- accept={{
- onClick: onEdit,
- variant: "danger",
- text: editingTag?.id ? "Update" : "Create",
- }}
- >
-
- Name
- ) =>
- setName(newValue.currentTarget.value)
- }
- defaultValue={(editingTag && editingTag.name) || ""}
- />
-
-
+ return (
+
+ {tagElements}
+ {deleteAlert}
+
+ );
+ }
+ if (filter.displayMode === DisplayMode.Wall) {
+ return
TODO
;
+ }
+ }
- {tagElements}
- {deleteAlert}
-
- );
+ return listData.template;
};
diff --git a/ui/v2.5/src/components/Tags/Tags.tsx b/ui/v2.5/src/components/Tags/Tags.tsx
new file mode 100644
index 000000000..4865972f1
--- /dev/null
+++ b/ui/v2.5/src/components/Tags/Tags.tsx
@@ -0,0 +1,13 @@
+import React from "react";
+import { Route, Switch } from "react-router-dom";
+import { Tag } from "./TagDetails/Tag";
+import { TagList } from "./TagList";
+
+const Tags = () => (
+
+
+
+
+);
+
+export default Tags;
diff --git a/ui/v2.5/src/components/Tags/styles.scss b/ui/v2.5/src/components/Tags/styles.scss
index 78bacf7d0..f7371dd27 100644
--- a/ui/v2.5/src/components/Tags/styles.scss
+++ b/ui/v2.5/src/components/Tags/styles.scss
@@ -18,3 +18,21 @@
min-width: 6rem;
}
}
+
+.tag-card {
+ padding: 0.5rem;
+
+ &-image {
+ display: block;
+ margin: 0 auto;
+ object-fit: contain;
+ }
+}
+
+.tag-details {
+ .logo {
+ margin-bottom: 4rem;
+ max-height: 50vh;
+ max-width: 100%;
+ }
+}
diff --git a/ui/v2.5/src/components/Wall/styles.scss b/ui/v2.5/src/components/Wall/styles.scss
index c88b5558a..da38cb5f0 100644
--- a/ui/v2.5/src/components/Wall/styles.scss
+++ b/ui/v2.5/src/components/Wall/styles.scss
@@ -16,6 +16,7 @@
@media (max-width: 576px) {
height: inherit;
max-width: 100%;
+ min-height: 210px;
width: 100%;
}
@@ -36,8 +37,13 @@
align-items: center;
color: $text-color;
display: flex;
- font-size: 1.5rem;
+ font-size: 1vw;
justify-content: center;
+ text-align: center;
+
+ @media (max-width: 576px) {
+ font-size: 6vw;
+ }
}
&-preview {
diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts
index adee07ec7..ca3f6bfb1 100644
--- a/ui/v2.5/src/core/StashService.ts
+++ b/ui/v2.5/src/core/StashService.ts
@@ -95,6 +95,14 @@ export const useFindPerformers = (filter: ListFilterModel) =>
},
});
+export const useFindTags = (filter: ListFilterModel) =>
+ GQL.useFindTagsQuery({
+ variables: {
+ filter: filter.makeFindFilter(),
+ tag_filter: filter.makeTagFilter(),
+ },
+ });
+
export const queryFindPerformers = (filter: ListFilterModel) =>
client.query
({
query: GQL.FindPerformersDocument,
@@ -120,6 +128,10 @@ export const useFindMovie = (id: string) => {
const skip = id === "new";
return GQL.useFindMovieQuery({ variables: { id }, skip });
};
+export const useFindTag = (id: string) => {
+ const skip = id === "new";
+ return GQL.useFindTagQuery({ variables: { id }, skip });
+};
// TODO - scene marker manipulation functions are handled differently
export const sceneMarkerMutationImpactedQueries = [
diff --git a/ui/v2.5/src/docs/en/KeyboardShortcuts.md b/ui/v2.5/src/docs/en/KeyboardShortcuts.md
index 6ab9145dd..0a5b65fa7 100644
--- a/ui/v2.5/src/docs/en/KeyboardShortcuts.md
+++ b/ui/v2.5/src/docs/en/KeyboardShortcuts.md
@@ -153,3 +153,11 @@
|-------------------|--------|
| `n` | New Tag |
+## Tag Page shortcuts
+
+| Keyboard sequence | Action |
+|-------------------|--------|
+| `e` | Edit Tag |
+| `s s` | Save Tag |
+| `d d` | Delete Tag |
+| `Ctrl + v` | Paste Tag image |
\ No newline at end of file
diff --git a/ui/v2.5/src/hooks/ListHook.tsx b/ui/v2.5/src/hooks/ListHook.tsx
index 87e7d6e4b..1d615f54c 100644
--- a/ui/v2.5/src/hooks/ListHook.tsx
+++ b/ui/v2.5/src/hooks/ListHook.tsx
@@ -16,6 +16,8 @@ import {
FindPerformersQueryResult,
FindMoviesQueryResult,
MovieDataFragment,
+ FindTagsQueryResult,
+ TagDataFragment,
} from "src/core/generated-graphql";
import {
useInterfaceLocalForage,
@@ -31,6 +33,7 @@ import {
useFindStudios,
useFindGalleries,
useFindPerformers,
+ useFindTags,
} from "src/core/StashService";
import { ListFilterModel } from "src/models/list-filter/filter";
import { FilterMode } from "src/models/list-filter/types";
@@ -54,6 +57,7 @@ interface IListHookOptions {
subComponent?: boolean;
filterHook?: (filter: ListFilterModel) => ListFilterModel;
zoomable?: boolean;
+ defaultZoomIndex?: number;
otherOperations?: IListHookOperation[];
renderContent: (
result: T,
@@ -111,7 +115,9 @@ const useList = (
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState>(new Set());
const [lastClickedId, setLastClickedId] = useState();
- const [zoomIndex, setZoomIndex] = useState(1);
+ const [zoomIndex, setZoomIndex] = useState(
+ options.defaultZoomIndex ?? 1
+ );
const result = options.useData(getFilter());
const totalCount = options.getCount(result);
@@ -573,3 +579,18 @@ export const useMoviesList = (
selectedIds: Set
) => getSelectedData(result?.data?.findMovies?.movies ?? [], selectedIds),
});
+
+export const useTagsList = (
+ props: IListHookOptions
+) =>
+ useList({
+ ...props,
+ filterMode: FilterMode.Tags,
+ useData: useFindTags,
+ getData: (result: FindTagsQueryResult) =>
+ result?.data?.findTags?.tags ?? [],
+ getCount: (result: FindTagsQueryResult) =>
+ result?.data?.findTags?.count ?? 0,
+ getSelectedData: (result: FindTagsQueryResult, selectedIds: Set) =>
+ getSelectedData(result?.data?.findTags?.tags ?? [], selectedIds),
+ });
diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss
index 095b7e277..66dc5b5d4 100755
--- a/ui/v2.5/src/index.scss
+++ b/ui/v2.5/src/index.scss
@@ -100,7 +100,8 @@ textarea.text-input {
width: 240px;
.scene-card-video,
- .gallery-card-image {
+ .gallery-card-image,
+ .tag-card-image {
max-height: 180px;
}
@@ -113,7 +114,8 @@ textarea.text-input {
width: 320px;
.scene-card-video,
- .gallery-card-image {
+ .gallery-card-image,
+ .tag-card-image {
max-height: 240px;
}
@@ -126,7 +128,8 @@ textarea.text-input {
width: 480px;
.scene-card-video,
- .gallery-card-image {
+ .gallery-card-image,
+ .tag-card-image {
max-height: 360px;
}
@@ -139,7 +142,8 @@ textarea.text-input {
width: 640px;
.scene-card-video,
- .gallery-card-image {
+ .gallery-card-image,
+ .tag-card-image {
max-height: 480px;
}
@@ -150,7 +154,8 @@ textarea.text-input {
}
.scene-card-video,
-.gallery-card-image {
+.gallery-card-image,
+.tag-card-image {
height: auto;
width: 100%;
}
diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts
index a10433844..02164b8b5 100644
--- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts
+++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts
@@ -15,6 +15,7 @@ export type CriterionType =
| "sceneIsMissing"
| "performerIsMissing"
| "galleryIsMissing"
+ | "tagIsMissing"
| "tags"
| "sceneTags"
| "performers"
@@ -33,7 +34,9 @@ export type CriterionType =
| "piercings"
| "aliases"
| "gender"
- | "parent_studios";
+ | "parent_studios"
+ | "scene_count"
+ | "marker_count";
type Option = string | number | IOptionType;
export type CriterionValue = string | number | ILabeledId[];
@@ -56,10 +59,9 @@ export abstract class Criterion {
case "hasMarkers":
return "Has Markers";
case "sceneIsMissing":
- return "Is Missing";
case "performerIsMissing":
- return "Is Missing";
case "galleryIsMissing":
+ case "tagIsMissing":
return "Is Missing";
case "tags":
return "Tags";
@@ -99,6 +101,10 @@ export abstract class Criterion {
return "Gender";
case "parent_studios":
return "Parent Studios";
+ case "scene_count":
+ return "Scene Count";
+ case "marker_count":
+ return "Marker Count";
}
}
diff --git a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts
index 7e537d0fd..c877bde1e 100644
--- a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts
+++ b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts
@@ -63,3 +63,13 @@ export class GalleryIsMissingCriterionOption implements ICriterionOption {
public label: string = Criterion.getLabel("galleryIsMissing");
public value: CriterionType = "galleryIsMissing";
}
+
+export class TagIsMissingCriterion extends IsMissingCriterion {
+ public type: CriterionType = "tagIsMissing";
+ public options: string[] = ["image"];
+}
+
+export class TagIsMissingCriterionOption implements ICriterionOption {
+ public label: string = Criterion.getLabel("tagIsMissing");
+ public value: CriterionType = "tagIsMissing";
+}
diff --git a/ui/v2.5/src/models/list-filter/criteria/utils.ts b/ui/v2.5/src/models/list-filter/criteria/utils.ts
index 1ccf12ea6..8b1cf9725 100644
--- a/ui/v2.5/src/models/list-filter/criteria/utils.ts
+++ b/ui/v2.5/src/models/list-filter/criteria/utils.ts
@@ -13,6 +13,7 @@ import {
PerformerIsMissingCriterion,
SceneIsMissingCriterion,
GalleryIsMissingCriterion,
+ TagIsMissingCriterion,
} from "./is-missing";
import { NoneCriterion } from "./none";
import { PerformersCriterion } from "./performers";
@@ -30,6 +31,8 @@ export function makeCriteria(type: CriterionType = "none") {
case "rating":
return new RatingCriterion();
case "o_counter":
+ case "scene_count":
+ case "marker_count":
return new NumberCriterion(type, type);
case "resolution":
return new ResolutionCriterion();
@@ -45,6 +48,8 @@ export function makeCriteria(type: CriterionType = "none") {
return new PerformerIsMissingCriterion();
case "galleryIsMissing":
return new GalleryIsMissingCriterion();
+ case "tagIsMissing":
+ return new TagIsMissingCriterion();
case "tags":
return new TagsCriterion("tags");
case "sceneTags":
diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts
index 9320e6644..0458e5bca 100644
--- a/ui/v2.5/src/models/list-filter/filter.ts
+++ b/ui/v2.5/src/models/list-filter/filter.ts
@@ -9,6 +9,7 @@ import {
MovieFilterType,
StudioFilterType,
GalleryFilterType,
+ TagFilterType,
} from "src/core/generated-graphql";
import { stringToGender } from "src/core/StashService";
import {
@@ -33,6 +34,7 @@ import {
PerformerIsMissingCriterionOption,
SceneIsMissingCriterionOption,
GalleryIsMissingCriterionOption,
+ TagIsMissingCriterionOption,
} from "./criteria/is-missing";
import { NoneCriterionOption } from "./criteria/none";
import {
@@ -207,6 +209,17 @@ export class ListFilterModel {
new PerformersCriterionOption(),
];
break;
+ case FilterMode.Tags:
+ this.sortBy = "name";
+ this.sortByOptions = ["name", "scenes_count", "scene_markers_count"];
+ this.displayModeOptions = [DisplayMode.Grid, DisplayMode.List];
+ this.criterionOptions = [
+ new NoneCriterionOption(),
+ new TagIsMissingCriterionOption(),
+ ListFilterModel.createCriterionOption("scene_count"),
+ ListFilterModel.createCriterionOption("marker_count"),
+ ];
+ break;
default:
this.sortByOptions = [];
this.displayModeOptions = [];
@@ -623,4 +636,34 @@ export class ListFilterModel {
return result;
}
+
+ public makeTagFilter(): TagFilterType {
+ const result: TagFilterType = {};
+ this.criteria.forEach((criterion) => {
+ switch (criterion.type) {
+ case "tagIsMissing":
+ result.is_missing = (criterion as IsMissingCriterion).value;
+ break;
+ case "scene_count": {
+ const countCrit = criterion as NumberCriterion;
+ result.scene_count = {
+ value: countCrit.value,
+ modifier: countCrit.modifier,
+ };
+ break;
+ }
+ case "marker_count": {
+ const countCrit = criterion as NumberCriterion;
+ result.marker_count = {
+ value: countCrit.value,
+ modifier: countCrit.modifier,
+ };
+ break;
+ }
+ // no default
+ }
+ });
+
+ return result;
+ }
}
diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts
index a9a35bea9..11e66596f 100644
--- a/ui/v2.5/src/models/list-filter/types.ts
+++ b/ui/v2.5/src/models/list-filter/types.ts
@@ -13,6 +13,7 @@ export enum FilterMode {
Galleries,
SceneMarkers,
Movies,
+ Tags,
}
export interface ILabeledId {