mirror of
https://github.com/stashapp/stash.git
synced 2026-05-09 05:05:29 +02:00
Merge 457cc611d4 into 01a7583364
This commit is contained in:
commit
9408f2f3c3
16 changed files with 635 additions and 126 deletions
|
|
@ -62,6 +62,18 @@ type Scene {
|
|||
play_duration: Float
|
||||
"The number ot times a scene has been played"
|
||||
play_count: Int
|
||||
"""
|
||||
Describes where the current cover image came from.
|
||||
NULL | unknown/no-cover/legacy.
|
||||
default | "Generate default thumbnail"
|
||||
clipboard | "From clipboard"
|
||||
userscript | set via GraphQL outside UI
|
||||
url:* | "From URL..."
|
||||
stash:* | StashBox imported image
|
||||
timestamp:* | "Generate thumbnail from Current"
|
||||
file:* | "From file..."
|
||||
"""
|
||||
cover_image_source: String
|
||||
|
||||
"Times a scene was played"
|
||||
play_history: [Time!]!
|
||||
|
|
@ -114,6 +126,18 @@ input SceneCreateInput {
|
|||
tag_ids: [ID!]
|
||||
"This should be a URL or a base64 encoded data URL"
|
||||
cover_image: String
|
||||
"""
|
||||
Describes where the current cover image came from.
|
||||
NULL | unknown/no-cover/legacy.
|
||||
default | "Generate default thumbnail"
|
||||
clipboard | "From clipboard"
|
||||
userscript | set via GraphQL outside UI
|
||||
url:* | "From URL..."
|
||||
stash:* | StashBox imported image
|
||||
timestamp:* | "Generate thumbnail from Current"
|
||||
file:* | "From file..."
|
||||
"""
|
||||
cover_image_source: String
|
||||
stash_ids: [StashIDInput!]
|
||||
|
||||
"""
|
||||
|
|
@ -149,6 +173,18 @@ input SceneUpdateInput {
|
|||
tag_ids: [ID!]
|
||||
"This should be a URL or a base64 encoded data URL"
|
||||
cover_image: String
|
||||
"""
|
||||
Describes where the current cover image came from.
|
||||
NULL | unknown/no-cover/legacy.
|
||||
default | "Generate default thumbnail"
|
||||
clipboard | "From clipboard"
|
||||
userscript | set via GraphQL outside UI
|
||||
url:* | "From URL..."
|
||||
stash:* | StashBox imported image
|
||||
timestamp:* | "Generate thumbnail from Current"
|
||||
file:* | "From file..."
|
||||
"""
|
||||
cover_image_source: String
|
||||
stash_ids: [StashIDInput!]
|
||||
|
||||
"The time index a scene was left at"
|
||||
|
|
|
|||
|
|
@ -20,6 +20,24 @@ import (
|
|||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
// defaults to 'userscript when image is provided and source is not provided
|
||||
// returns nil if source null and image is not provided
|
||||
// otherwise returns the source trimmed
|
||||
func applyDefaultCoverSource(
|
||||
coverImageSource *string,
|
||||
coverImageData []byte,
|
||||
) *string {
|
||||
if len(coverImageData) > 0 && coverImageSource == nil {
|
||||
defaultSource := "userscript"
|
||||
return &defaultSource
|
||||
}
|
||||
if coverImageSource == nil {
|
||||
return nil
|
||||
}
|
||||
trimmed := strings.TrimSpace(*coverImageSource)
|
||||
return &trimmed
|
||||
}
|
||||
|
||||
// used to refetch scene after hooks run
|
||||
func (r *mutationResolver) getScene(ctx context.Context, id int) (ret *models.Scene, err error) {
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
|
|
@ -103,6 +121,11 @@ func (r *mutationResolver) SceneCreate(ctx context.Context, input models.SceneCr
|
|||
}
|
||||
}
|
||||
|
||||
newScene.CoverImageSource = applyDefaultCoverSource(
|
||||
input.CoverImageSource,
|
||||
coverImageData,
|
||||
)
|
||||
|
||||
customFields := convertMapJSONNumbers(input.CustomFields)
|
||||
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
|
|
@ -187,6 +210,7 @@ func scenePartialFromInput(input models.SceneUpdateInput, translator changesetTr
|
|||
updatedScene.Code = translator.optionalString(input.Code, "code")
|
||||
updatedScene.Details = translator.optionalString(input.Details, "details")
|
||||
updatedScene.Director = translator.optionalString(input.Director, "director")
|
||||
updatedScene.CoverImageSource = translator.optionalString(input.CoverImageSource, "cover_image_source")
|
||||
updatedScene.Rating = translator.optionalInt(input.Rating100, "rating100")
|
||||
|
||||
if input.OCounter != nil {
|
||||
|
|
@ -311,6 +335,10 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("processing cover image: %w", err)
|
||||
}
|
||||
|
||||
if len(coverImageData) > 0 && !updatedScene.CoverImageSource.Set {
|
||||
updatedScene.CoverImageSource = models.NewOptionalString("userscript")
|
||||
}
|
||||
}
|
||||
|
||||
var customFields *models.CustomFieldsInput
|
||||
|
|
@ -627,6 +655,9 @@ func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("processing cover image: %w", err)
|
||||
}
|
||||
if len(coverImageData) > 0 && !values.CoverImageSource.Set {
|
||||
values.CoverImageSource = models.NewOptionalString("userscript")
|
||||
}
|
||||
}
|
||||
|
||||
if input.Values.CustomFields != nil {
|
||||
|
|
|
|||
|
|
@ -10,12 +10,13 @@ import (
|
|||
|
||||
// Scene stores the metadata for a single video scene.
|
||||
type Scene struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Code string `json:"code"`
|
||||
Details string `json:"details"`
|
||||
Director string `json:"director"`
|
||||
Date *Date `json:"date"`
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Code string `json:"code"`
|
||||
Details string `json:"details"`
|
||||
Director string `json:"director"`
|
||||
CoverImageSource *string `json:"cover_image_source"`
|
||||
Date *Date `json:"date"`
|
||||
// Rating expressed in 1-100 scale
|
||||
Rating *int `json:"rating"`
|
||||
Organized bool `json:"organized"`
|
||||
|
|
@ -70,11 +71,12 @@ type UpdateSceneInput struct {
|
|||
// ScenePartial represents part of a Scene object. It is used to update
|
||||
// the database entry.
|
||||
type ScenePartial struct {
|
||||
Title OptionalString
|
||||
Code OptionalString
|
||||
Details OptionalString
|
||||
Director OptionalString
|
||||
Date OptionalDate
|
||||
Title OptionalString
|
||||
Code OptionalString
|
||||
Details OptionalString
|
||||
Director OptionalString
|
||||
CoverImageSource OptionalString
|
||||
Date OptionalDate
|
||||
// Rating expressed in 1-100 scale
|
||||
Rating OptionalInt
|
||||
Organized OptionalBool
|
||||
|
|
@ -212,21 +214,22 @@ func (s ScenePartial) UpdateInput(id int) SceneUpdateInput {
|
|||
}
|
||||
|
||||
ret := SceneUpdateInput{
|
||||
ID: strconv.Itoa(id),
|
||||
Title: s.Title.Ptr(),
|
||||
Code: s.Code.Ptr(),
|
||||
Details: s.Details.Ptr(),
|
||||
Director: s.Director.Ptr(),
|
||||
Urls: s.URLs.Strings(),
|
||||
Date: dateStr,
|
||||
Rating100: s.Rating.Ptr(),
|
||||
Organized: s.Organized.Ptr(),
|
||||
StudioID: s.StudioID.StringPtr(),
|
||||
GalleryIds: s.GalleryIDs.IDStrings(),
|
||||
PerformerIds: s.PerformerIDs.IDStrings(),
|
||||
Movies: s.GroupIDs.SceneMovieInputs(),
|
||||
TagIds: s.TagIDs.IDStrings(),
|
||||
StashIds: stashIDs.ToStashIDInputs(),
|
||||
ID: strconv.Itoa(id),
|
||||
Title: s.Title.Ptr(),
|
||||
Code: s.Code.Ptr(),
|
||||
Details: s.Details.Ptr(),
|
||||
Director: s.Director.Ptr(),
|
||||
CoverImageSource: s.CoverImageSource.Ptr(),
|
||||
Urls: s.URLs.Strings(),
|
||||
Date: dateStr,
|
||||
Rating100: s.Rating.Ptr(),
|
||||
Organized: s.Organized.Ptr(),
|
||||
StudioID: s.StudioID.StringPtr(),
|
||||
GalleryIds: s.GalleryIDs.IDStrings(),
|
||||
PerformerIds: s.PerformerIDs.IDStrings(),
|
||||
Movies: s.GroupIDs.SceneMovieInputs(),
|
||||
TagIds: s.TagIDs.IDStrings(),
|
||||
StashIds: stashIDs.ToStashIDInputs(),
|
||||
}
|
||||
|
||||
return ret
|
||||
|
|
|
|||
|
|
@ -190,8 +190,9 @@ type SceneCreateInput struct {
|
|||
Groups []SceneGroupInput `json:"groups"`
|
||||
TagIds []string `json:"tag_ids"`
|
||||
// This should be a URL or a base64 encoded data URL
|
||||
CoverImage *string `json:"cover_image"`
|
||||
StashIds []StashIDInput `json:"stash_ids"`
|
||||
CoverImage *string `json:"cover_image"`
|
||||
CoverImageSource *string `json:"cover_image_source"`
|
||||
StashIds []StashIDInput `json:"stash_ids"`
|
||||
// The first id will be assigned as primary.
|
||||
// Files will be reassigned from existing scenes if applicable.
|
||||
// Files must not already be primary for another scene.
|
||||
|
|
@ -219,13 +220,14 @@ type SceneUpdateInput struct {
|
|||
Groups []SceneGroupInput `json:"groups"`
|
||||
TagIds []string `json:"tag_ids"`
|
||||
// This should be a URL or a base64 encoded data URL
|
||||
CoverImage *string `json:"cover_image"`
|
||||
StashIds []StashIDInput `json:"stash_ids"`
|
||||
ResumeTime *float64 `json:"resume_time"`
|
||||
PlayDuration *float64 `json:"play_duration"`
|
||||
PlayCount *int `json:"play_count"`
|
||||
PrimaryFileID *string `json:"primary_file_id"`
|
||||
CustomFields *CustomFieldsInput
|
||||
CoverImage *string `json:"cover_image"`
|
||||
CoverImageSource *string `json:"cover_image_source"`
|
||||
StashIds []StashIDInput `json:"stash_ids"`
|
||||
ResumeTime *float64 `json:"resume_time"`
|
||||
PlayDuration *float64 `json:"play_duration"`
|
||||
PlayCount *int `json:"play_count"`
|
||||
PrimaryFileID *string `json:"primary_file_id"`
|
||||
CustomFields *CustomFieldsInput
|
||||
}
|
||||
|
||||
type SceneDestroyInput struct {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ const (
|
|||
cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE"
|
||||
)
|
||||
|
||||
var appSchemaVersion uint = 85
|
||||
var appSchemaVersion uint = 86
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsBox embed.FS
|
||||
|
|
|
|||
10
pkg/sqlite/migrations/86_scene_cover_image_source.up.sql
Normal file
10
pkg/sqlite/migrations/86_scene_cover_image_source.up.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
ALTER TABLE `scenes` ADD COLUMN `cover_image_source` text CHECK (
|
||||
`cover_image_source` IS NULL
|
||||
OR `cover_image_source` = 'default'
|
||||
OR `cover_image_source` = 'clipboard'
|
||||
OR `cover_image_source` = 'userscript'
|
||||
OR `cover_image_source` LIKE 'url:%'
|
||||
OR `cover_image_source` LIKE 'stash:%'
|
||||
OR `cover_image_source` LIKE 'timestamp:%'
|
||||
OR `cover_image_source` LIKE 'file:%'
|
||||
);
|
||||
|
|
@ -77,13 +77,14 @@ ORDER BY files.size DESC;
|
|||
`
|
||||
|
||||
type sceneRow struct {
|
||||
ID int `db:"id" goqu:"skipinsert"`
|
||||
Title zero.String `db:"title"`
|
||||
Code zero.String `db:"code"`
|
||||
Details zero.String `db:"details"`
|
||||
Director zero.String `db:"director"`
|
||||
Date NullDate `db:"date"`
|
||||
DatePrecision null.Int `db:"date_precision"`
|
||||
ID int `db:"id" goqu:"skipinsert"`
|
||||
Title zero.String `db:"title"`
|
||||
Code zero.String `db:"code"`
|
||||
CoverImageSource zero.String `db:"cover_image_source"`
|
||||
Details zero.String `db:"details"`
|
||||
Director zero.String `db:"director"`
|
||||
Date NullDate `db:"date"`
|
||||
DatePrecision null.Int `db:"date_precision"`
|
||||
// expressed as 1-100
|
||||
Rating null.Int `db:"rating"`
|
||||
Organized bool `db:"organized"`
|
||||
|
|
@ -103,6 +104,7 @@ func (r *sceneRow) fromScene(o models.Scene) {
|
|||
r.Code = zero.StringFrom(o.Code)
|
||||
r.Details = zero.StringFrom(o.Details)
|
||||
r.Director = zero.StringFrom(o.Director)
|
||||
r.CoverImageSource = zero.StringFromPtr(o.CoverImageSource)
|
||||
r.Date = NullDateFromDatePtr(o.Date)
|
||||
r.DatePrecision = datePrecisionFromDatePtr(o.Date)
|
||||
r.Rating = intFromPtr(o.Rating)
|
||||
|
|
@ -124,16 +126,23 @@ type sceneQueryRow struct {
|
|||
}
|
||||
|
||||
func (r *sceneQueryRow) resolve() *models.Scene {
|
||||
var coverImageSource *string
|
||||
if r.CoverImageSource.Valid {
|
||||
v := r.CoverImageSource.String
|
||||
coverImageSource = &v
|
||||
}
|
||||
|
||||
ret := &models.Scene{
|
||||
ID: r.ID,
|
||||
Title: r.Title.String,
|
||||
Code: r.Code.String,
|
||||
Details: r.Details.String,
|
||||
Director: r.Director.String,
|
||||
Date: r.Date.DatePtr(r.DatePrecision),
|
||||
Rating: nullIntPtr(r.Rating),
|
||||
Organized: r.Organized,
|
||||
StudioID: nullIntPtr(r.StudioID),
|
||||
ID: r.ID,
|
||||
Title: r.Title.String,
|
||||
Code: r.Code.String,
|
||||
Details: r.Details.String,
|
||||
Director: r.Director.String,
|
||||
CoverImageSource: coverImageSource,
|
||||
Date: r.Date.DatePtr(r.DatePrecision),
|
||||
Rating: nullIntPtr(r.Rating),
|
||||
Organized: r.Organized,
|
||||
StudioID: nullIntPtr(r.StudioID),
|
||||
|
||||
PrimaryFileID: nullIntFileIDPtr(r.PrimaryFileID),
|
||||
OSHash: r.PrimaryFileOshash.String,
|
||||
|
|
@ -162,6 +171,7 @@ func (r *sceneRowRecord) fromPartial(o models.ScenePartial) {
|
|||
r.setNullString("code", o.Code)
|
||||
r.setNullString("details", o.Details)
|
||||
r.setNullString("director", o.Director)
|
||||
r.setNullString("cover_image_source", o.CoverImageSource)
|
||||
r.setNullDate("date", "date_precision", o.Date)
|
||||
r.setNullInt("rating", o.Rating)
|
||||
r.setBool("organized", o.Organized)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ fragment SceneData on Scene {
|
|||
last_played_at
|
||||
play_duration
|
||||
play_count
|
||||
cover_image_source
|
||||
|
||||
play_history
|
||||
o_history
|
||||
|
|
|
|||
|
|
@ -160,6 +160,10 @@ interface ISceneParams {
|
|||
id: string;
|
||||
}
|
||||
|
||||
type SceneUpdateInputWithCoverSource = GQL.SceneUpdateInput & {
|
||||
cover_image_source?: string | null;
|
||||
};
|
||||
|
||||
const ScenePageTabs = PatchContainerComponent<IProps>("ScenePage.Tabs");
|
||||
const ScenePageTabContent = PatchContainerComponent<IProps>(
|
||||
"ScenePage.TabContent"
|
||||
|
|
@ -385,12 +389,23 @@ const ScenePage: React.FC<IProps> = PatchComponent("ScenePage", (props) => {
|
|||
}
|
||||
|
||||
async function onGenerateScreenshot(at?: number) {
|
||||
const input: SceneUpdateInputWithCoverSource = {
|
||||
id: scene.id,
|
||||
cover_image_source:
|
||||
typeof at === "number" ? `timestamp:${at}` : "default",
|
||||
};
|
||||
|
||||
await generateScreenshot({
|
||||
variables: {
|
||||
id: scene.id,
|
||||
at,
|
||||
},
|
||||
});
|
||||
await updateScene({
|
||||
variables: {
|
||||
input,
|
||||
},
|
||||
});
|
||||
Toast.success(intl.formatMessage({ id: "toast.generating_screenshot" }));
|
||||
}
|
||||
|
||||
|
|
@ -468,20 +483,6 @@ const ScenePage: React.FC<IProps> = PatchComponent("ScenePage", (props) => {
|
|||
>
|
||||
<FormattedMessage id="actions.generate" />…
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
key="generate-screenshot"
|
||||
className="bg-secondary text-white"
|
||||
onClick={() => onGenerateScreenshot(getPlayerPosition())}
|
||||
>
|
||||
<FormattedMessage id="actions.generate_thumb_from_current" />
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
key="generate-default"
|
||||
className="bg-secondary text-white"
|
||||
onClick={() => onGenerateScreenshot()}
|
||||
>
|
||||
<FormattedMessage id="actions.generate_thumb_default" />
|
||||
</Dropdown.Item>
|
||||
{boxes.length > 0 && (
|
||||
<Dropdown.Item
|
||||
key="submit"
|
||||
|
|
@ -640,6 +641,10 @@ const ScenePage: React.FC<IProps> = PatchComponent("ScenePage", (props) => {
|
|||
<SceneEditPanel
|
||||
isVisible={activeTabKey === "scene-edit-panel"}
|
||||
scene={scene}
|
||||
onGenerateThumbFromCurrent={() =>
|
||||
onGenerateScreenshot(getPlayerPosition())
|
||||
}
|
||||
onGenerateThumbDefault={() => onGenerateScreenshot()}
|
||||
onSubmit={onSave}
|
||||
onDelete={() => setIsDeleteAlertOpen(true)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -64,6 +64,8 @@ interface IProps {
|
|||
initialCoverImage?: string;
|
||||
isNew?: boolean;
|
||||
isVisible: boolean;
|
||||
onGenerateThumbFromCurrent?: () => Promise<void>;
|
||||
onGenerateThumbDefault?: () => Promise<void>;
|
||||
onSubmit: (input: GQL.SceneCreateInput, andNew?: boolean) => Promise<void>;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
|
@ -73,6 +75,8 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
initialCoverImage,
|
||||
isNew = false,
|
||||
isVisible,
|
||||
onGenerateThumbFromCurrent,
|
||||
onGenerateThumbDefault,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
}) => {
|
||||
|
|
@ -145,6 +149,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
stash_ids: yup.mixed<GQL.StashIdInput[]>().defined(),
|
||||
details: yup.string().ensure(),
|
||||
cover_image: yup.string().nullable().optional(),
|
||||
cover_image_source: yup.string().nullable().optional(),
|
||||
custom_fields: yup.object().required().defined(),
|
||||
});
|
||||
|
||||
|
|
@ -165,6 +170,9 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
stash_ids: getStashIDs(scene.stash_ids),
|
||||
details: scene.details ?? "",
|
||||
cover_image: initialCoverImage,
|
||||
cover_image_source:
|
||||
(scene as { cover_image_source?: string | null }).cover_image_source ??
|
||||
null,
|
||||
custom_fields: cloneDeep(scene.custom_fields ?? {}),
|
||||
}),
|
||||
[scene, initialCoverImage]
|
||||
|
|
@ -173,12 +181,18 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
type InputValues = yup.InferType<typeof schema>;
|
||||
|
||||
const [customFieldsError, setCustomFieldsError] = useState<string>();
|
||||
const [coverImageSourceDirty, setCoverImageSourceDirty] = useState(false);
|
||||
const [coverImageRefreshToken, setCoverImageRefreshToken] = useState(0);
|
||||
|
||||
function submit(values: InputValues) {
|
||||
const input = {
|
||||
...schema.cast(values),
|
||||
custom_fields: formatCustomFieldInput(isNew, values.custom_fields),
|
||||
};
|
||||
if (!coverImageSourceDirty) {
|
||||
delete (input as { cover_image_source?: string | null })
|
||||
.cover_image_source;
|
||||
}
|
||||
onSave(input);
|
||||
}
|
||||
|
||||
|
|
@ -200,12 +214,22 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
if (formImage === null && sceneImage) {
|
||||
const sceneImageURL = new URL(sceneImage);
|
||||
sceneImageURL.searchParams.set("default", "true");
|
||||
if (coverImageRefreshToken > 0) {
|
||||
sceneImageURL.searchParams.set(
|
||||
"refresh",
|
||||
coverImageRefreshToken.toString()
|
||||
);
|
||||
}
|
||||
return sceneImageURL.toString();
|
||||
} else if (formImage) {
|
||||
return formImage;
|
||||
}
|
||||
return sceneImage;
|
||||
}, [formik.values.cover_image, scene.paths?.screenshot]);
|
||||
}, [
|
||||
coverImageRefreshToken,
|
||||
formik.values.cover_image,
|
||||
scene.paths?.screenshot,
|
||||
]);
|
||||
|
||||
const groupEntries = useMemo(() => {
|
||||
return formik.values.groups
|
||||
|
|
@ -259,6 +283,11 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setCoverImageSourceDirty(false);
|
||||
setCoverImageRefreshToken(0);
|
||||
}, [scene.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const toFilter = Scrapers?.data?.listScrapers ?? [];
|
||||
|
||||
|
|
@ -298,6 +327,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
try {
|
||||
await onSubmit(input, andNew);
|
||||
formik.resetForm();
|
||||
setCoverImageSourceDirty(false);
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
}
|
||||
|
|
@ -312,18 +342,54 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
onSave(input, true);
|
||||
}
|
||||
|
||||
const encodingImage = ImageUtils.usePasteImage(onImageLoad);
|
||||
const encodingImage = ImageUtils.usePasteImage((imageData) =>
|
||||
onImageLoad(imageData, "clipboard")
|
||||
);
|
||||
|
||||
function onImageLoad(imageData: string) {
|
||||
function setCoverImageSource(source: string | null) {
|
||||
formik.setFieldValue("cover_image_source", source);
|
||||
setCoverImageSourceDirty(true);
|
||||
}
|
||||
|
||||
function onImageLoad(imageData: string, source?: string) {
|
||||
formik.setFieldValue("cover_image", imageData);
|
||||
if (source) {
|
||||
setCoverImageSource(source);
|
||||
}
|
||||
}
|
||||
|
||||
function onImageURLSource(source: "url" | "clipboard", value?: string) {
|
||||
if (source === "clipboard") {
|
||||
setCoverImageSource("clipboard");
|
||||
return;
|
||||
}
|
||||
|
||||
if (source === "url") {
|
||||
setCoverImageSource(`url:${(value ?? "").trim()}`);
|
||||
}
|
||||
}
|
||||
|
||||
function onCoverImageChange(event: React.FormEvent<HTMLInputElement>) {
|
||||
ImageUtils.onImageChange(event, onImageLoad);
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const fileName = input.files?.[0]?.name?.trim() ?? "";
|
||||
setCoverImageSource(`file:${fileName}`);
|
||||
ImageUtils.onImageChange(event, (imageData) => onImageLoad(imageData));
|
||||
}
|
||||
|
||||
function onResetCover() {
|
||||
formik.setFieldValue("cover_image", null);
|
||||
setCoverImageSource(null);
|
||||
}
|
||||
|
||||
async function onGenerateDefaultCoverImage() {
|
||||
if (!onGenerateThumbDefault) {
|
||||
return;
|
||||
}
|
||||
|
||||
await onGenerateThumbDefault();
|
||||
formik.setFieldValue("cover_image", null);
|
||||
setCoverImageSource("default");
|
||||
setCoverImageRefreshToken(Date.now());
|
||||
}
|
||||
|
||||
async function onScrapeClicked(s: GQL.ScraperSourceInput) {
|
||||
|
|
@ -536,6 +602,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
if (updatedScene.image) {
|
||||
// image is a base64 string
|
||||
formik.setFieldValue("cover_image", updatedScene.image);
|
||||
setCoverImageSource(endpoint ? `stash:${endpoint}` : "url:scraper");
|
||||
}
|
||||
|
||||
if (updatedScene.remote_site_id && endpoint) {
|
||||
|
|
@ -877,15 +944,22 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
{renderDetailsField()}
|
||||
<Form.Group controlId="cover_image">
|
||||
<Form.Label>
|
||||
<FormattedMessage id="cover_image" />
|
||||
{intl.formatMessage({ id: "cover_image" })}
|
||||
</Form.Label>
|
||||
{image}
|
||||
<ImageInput
|
||||
isEditing
|
||||
onImageChange={onCoverImageChange}
|
||||
onImageURL={onImageLoad}
|
||||
onReset={scene.id ? onResetCover : undefined}
|
||||
/>
|
||||
<div className="mt-2 d-flex align-items-center">
|
||||
{!isNew && (
|
||||
<ImageInput
|
||||
isEditing
|
||||
onImageChange={onCoverImageChange}
|
||||
onImageURL={onImageLoad}
|
||||
onImageURLSource={onImageURLSource}
|
||||
onReset={onResetCover}
|
||||
onGenerateDefault={onGenerateDefaultCoverImage}
|
||||
onGenerateCurrent={onGenerateThumbFromCurrent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Form.Group>
|
||||
|
||||
<CustomFieldsInput
|
||||
|
|
|
|||
|
|
@ -14,16 +14,50 @@ import { ReassignFilesDialog } from "src/components/Shared/ReassignFilesDialog";
|
|||
import * as GQL from "src/core/generated-graphql";
|
||||
import { mutateSceneSetPrimaryFile } from "src/core/StashService";
|
||||
import { useToast } from "src/hooks/Toast";
|
||||
import { useConfigurationContext } from "src/hooks/Config";
|
||||
import NavUtils from "src/utils/navigation";
|
||||
import TextUtils from "src/utils/text";
|
||||
import { TextField, URLField, URLsField } from "src/utils/field";
|
||||
import { IconField, TextField, URLField, URLsField } from "src/utils/field";
|
||||
import { StashIDPill } from "src/components/Shared/StashID";
|
||||
import { PatchComponent } from "../../../patch";
|
||||
import { FileSize } from "src/components/Shared/FileSize";
|
||||
import {
|
||||
faCameraRotate,
|
||||
faClipboard,
|
||||
faFileCode,
|
||||
faFile,
|
||||
faLink,
|
||||
faPhotoFilm,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIconProps } from "@fortawesome/react-fontawesome";
|
||||
|
||||
const pad2 = (value: number) => value.toString().padStart(2, "0");
|
||||
|
||||
function formatCoverTimestamp(seconds: number, frameRate?: number | null) {
|
||||
const fps = frameRate ?? 0;
|
||||
const roundedSeconds =
|
||||
Number.isFinite(fps) && fps > 0
|
||||
? Math.round(seconds * fps) / fps
|
||||
: Math.round(seconds * 100) / 100;
|
||||
|
||||
const centiseconds = Math.round(roundedSeconds * 100);
|
||||
const totalSeconds = Math.floor(centiseconds / 100);
|
||||
const cs = centiseconds % 100;
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const secs = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${pad2(minutes)}:${pad2(secs)}.${pad2(cs)}`;
|
||||
}
|
||||
|
||||
return `${minutes}:${pad2(secs)}.${pad2(cs)}`;
|
||||
}
|
||||
|
||||
interface IFileInfoPanelProps {
|
||||
sceneID: string;
|
||||
file: GQL.VideoFileDataFragment;
|
||||
coverImageSource?: string | null;
|
||||
primary?: boolean;
|
||||
ofMany?: boolean;
|
||||
onSetPrimaryFile?: () => void;
|
||||
|
|
@ -32,17 +66,120 @@ interface IFileInfoPanelProps {
|
|||
loading?: boolean;
|
||||
}
|
||||
|
||||
interface ICoverSourceData {
|
||||
icon?: FontAwesomeIconProps["icon"];
|
||||
value: string;
|
||||
url?: string | null;
|
||||
stashEndpoint?: string | null;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
|
||||
props: IFileInfoPanelProps
|
||||
) => {
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
const { configuration } = useConfigurationContext();
|
||||
const coverImageLabel = intl.formatMessage({ id: "scene_cover.image" });
|
||||
const coverSourceLabel = intl.formatMessage(
|
||||
{ id: "scene_cover.source" },
|
||||
{ image: coverImageLabel }
|
||||
);
|
||||
|
||||
// TODO - generalise fingerprints
|
||||
const oshash = props.file.fingerprints.find((f) => f.type === "oshash");
|
||||
const phash = props.file.fingerprints.find((f) => f.type === "phash");
|
||||
const checksum = props.file.fingerprints.find((f) => f.type === "md5");
|
||||
|
||||
const coverSourceField = useMemo(() => {
|
||||
const getCoverSourceTooltip = (id: string) =>
|
||||
intl.formatMessage({ id }, { image: coverImageLabel });
|
||||
|
||||
const source = props.coverImageSource;
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let coverSourceData: ICoverSourceData;
|
||||
|
||||
if (source === "default") {
|
||||
coverSourceData = {
|
||||
icon: faPhotoFilm,
|
||||
value: intl.formatMessage({ id: "scene_cover.default" }),
|
||||
tooltip: getCoverSourceTooltip("scene_cover.default_tooltip"),
|
||||
};
|
||||
} else if (source === "clipboard") {
|
||||
coverSourceData = {
|
||||
icon: faClipboard,
|
||||
value: intl.formatMessage({ id: "scene_cover.clipboard" }),
|
||||
tooltip: getCoverSourceTooltip("scene_cover.clipboard_tooltip"),
|
||||
};
|
||||
} else if (source === "userscript") {
|
||||
coverSourceData = {
|
||||
icon: faFileCode,
|
||||
value: intl.formatMessage({ id: "scene_cover.userscript" }),
|
||||
tooltip: getCoverSourceTooltip("scene_cover.userscript_tooltip"),
|
||||
};
|
||||
} else if (source.startsWith("file:")) {
|
||||
const fileName = source.slice("file:".length);
|
||||
coverSourceData = {
|
||||
icon: faFile,
|
||||
value: fileName || intl.formatMessage({ id: "scene_cover.file" }),
|
||||
tooltip: getCoverSourceTooltip("scene_cover.file_tooltip"),
|
||||
};
|
||||
} else if (source.startsWith("url:")) {
|
||||
const urlValue = source.slice("url:".length).trim();
|
||||
coverSourceData = {
|
||||
icon: faLink,
|
||||
value: urlValue || intl.formatMessage({ id: "actions.from_url" }),
|
||||
url: urlValue || null,
|
||||
tooltip: getCoverSourceTooltip("scene_cover.url_tooltip"),
|
||||
};
|
||||
} else if (source.startsWith("timestamp:")) {
|
||||
const rawTimestamp = source.slice("timestamp:".length).trim();
|
||||
const parsedTimestamp = Number.parseFloat(rawTimestamp);
|
||||
const timestampLabel = Number.isFinite(parsedTimestamp)
|
||||
? formatCoverTimestamp(parsedTimestamp, props.file.frame_rate)
|
||||
: rawTimestamp;
|
||||
coverSourceData = {
|
||||
icon: faCameraRotate,
|
||||
value: timestampLabel,
|
||||
tooltip: getCoverSourceTooltip("scene_cover.timestamp_tooltip"),
|
||||
};
|
||||
} else if (source.startsWith("stash:")) {
|
||||
const endpoint = source.slice("stash:".length).trim();
|
||||
const endpointName =
|
||||
configuration?.general.stashBoxes.find((sb) => sb.endpoint === endpoint)
|
||||
?.name ?? endpoint;
|
||||
coverSourceData = {
|
||||
value: endpointName,
|
||||
stashEndpoint: endpointName,
|
||||
tooltip: getCoverSourceTooltip("scene_cover.stash_tooltip"),
|
||||
};
|
||||
} else {
|
||||
coverSourceData = { value: source };
|
||||
}
|
||||
|
||||
return (
|
||||
<IconField
|
||||
name={coverSourceLabel}
|
||||
icon={coverSourceData.icon}
|
||||
value={coverSourceData.value}
|
||||
url={coverSourceData.url}
|
||||
stashEndpoint={coverSourceData.stashEndpoint}
|
||||
truncate={Boolean(coverSourceData.url)}
|
||||
tooltip={coverSourceData.tooltip}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
configuration?.general.stashBoxes,
|
||||
coverImageLabel,
|
||||
coverSourceLabel,
|
||||
intl,
|
||||
props.coverImageSource,
|
||||
props.file.frame_rate,
|
||||
]);
|
||||
|
||||
function onSplit() {
|
||||
history.push(
|
||||
`/scenes/new?from_scene_id=${props.sceneID}&file_id=${props.file.id}`
|
||||
|
|
@ -76,6 +213,7 @@ const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
|
|||
truncate
|
||||
internal
|
||||
/>
|
||||
{coverSourceField}
|
||||
<TextField id="path">
|
||||
<span className="d-flex align-items-center">
|
||||
<TruncatedText text={props.file.path} />
|
||||
|
|
@ -171,6 +309,9 @@ const _SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
|
|||
props: ISceneFileInfoPanelProps
|
||||
) => {
|
||||
const Toast = useToast();
|
||||
const coverImageSource =
|
||||
(props.scene as { cover_image_source?: string | null })
|
||||
.cover_image_source ?? null;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deletingFile, setDeletingFile] = useState<GQL.VideoFileDataFragment>();
|
||||
|
|
@ -232,7 +373,11 @@ const _SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
|
|||
|
||||
if (props.scene.files.length === 1) {
|
||||
return (
|
||||
<FileInfoPanel sceneID={props.scene.id} file={props.scene.files[0]} />
|
||||
<FileInfoPanel
|
||||
sceneID={props.scene.id}
|
||||
file={props.scene.files[0]}
|
||||
coverImageSource={coverImageSource}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -271,6 +416,7 @@ const _SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
|
|||
<FileInfoPanel
|
||||
sceneID={props.scene.id}
|
||||
file={file}
|
||||
coverImageSource={coverImageSource}
|
||||
primary={index === 0}
|
||||
ofMany
|
||||
onSetPrimaryFile={() => onSetPrimaryFile(file.id)}
|
||||
|
|
@ -284,7 +430,14 @@ const _SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
|
|||
))}
|
||||
</Accordion>
|
||||
);
|
||||
}, [props.scene, loading, Toast, deletingFile, reassigningFile]);
|
||||
}, [
|
||||
props.scene,
|
||||
loading,
|
||||
Toast,
|
||||
deletingFile,
|
||||
reassigningFile,
|
||||
coverImageSource,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -625,45 +625,63 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
|
|||
|
||||
// only set the cover image if it's different from the existing cover image
|
||||
const coverImage = image.useNewValue ? image.getNewValue() : undefined;
|
||||
const sourceCoverImageSource = (
|
||||
sources.find((s) => s.paths.screenshot) as unknown as
|
||||
| { cover_image_source?: string | null }
|
||||
| undefined
|
||||
)?.cover_image_source;
|
||||
const coverImageSource = image.useNewValue
|
||||
? coverImage
|
||||
? sourceCoverImageSource ?? null
|
||||
: null
|
||||
: undefined;
|
||||
|
||||
const values: GQL.SceneUpdateInput = {
|
||||
id: dest.id,
|
||||
title: title.getNewValue(),
|
||||
code: code.getNewValue(),
|
||||
urls: url.getNewValue(),
|
||||
date: date.getNewValue(),
|
||||
rating100: rating.getNewValue(),
|
||||
o_counter: oCounter.getNewValue(),
|
||||
play_count: playCount.getNewValue(),
|
||||
play_duration: playDuration.getNewValue(),
|
||||
gallery_ids: galleries.getNewValue(),
|
||||
studio_id: studio.getNewValue()?.stored_id,
|
||||
performer_ids: performers.getNewValue()?.map((p) => p.stored_id!),
|
||||
groups: groups.getNewValue()?.map((m) => {
|
||||
// find the equivalent group in the original scenes
|
||||
const found = all
|
||||
.map((s) => s.groups)
|
||||
.flat()
|
||||
.find((mm) => mm.group.id === m.stored_id);
|
||||
return {
|
||||
group_id: m.stored_id!,
|
||||
scene_index: found!.scene_index,
|
||||
};
|
||||
}),
|
||||
tag_ids: tags.getNewValue()?.map((t) => t.stored_id!),
|
||||
details: details.getNewValue(),
|
||||
organized: organized.getNewValue(),
|
||||
stash_ids: stashIDs.getNewValue(),
|
||||
cover_image: coverImage,
|
||||
custom_fields: {
|
||||
partial: Object.fromEntries(
|
||||
Array.from(customFields.entries()).flatMap(([field, v]) =>
|
||||
v.useNewValue ? [[field, v.getNewValue()]] : []
|
||||
)
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
if (coverImageSource !== undefined) {
|
||||
(
|
||||
values as unknown as { cover_image_source?: string | null }
|
||||
).cover_image_source = coverImageSource;
|
||||
}
|
||||
|
||||
return {
|
||||
values: {
|
||||
id: dest.id,
|
||||
title: title.getNewValue(),
|
||||
code: code.getNewValue(),
|
||||
urls: url.getNewValue(),
|
||||
date: date.getNewValue(),
|
||||
rating100: rating.getNewValue(),
|
||||
o_counter: oCounter.getNewValue(),
|
||||
play_count: playCount.getNewValue(),
|
||||
play_duration: playDuration.getNewValue(),
|
||||
gallery_ids: galleries.getNewValue(),
|
||||
studio_id: studio.getNewValue()?.stored_id,
|
||||
performer_ids: performers.getNewValue()?.map((p) => p.stored_id!),
|
||||
groups: groups.getNewValue()?.map((m) => {
|
||||
// find the equivalent group in the original scenes
|
||||
const found = all
|
||||
.map((s) => s.groups)
|
||||
.flat()
|
||||
.find((mm) => mm.group.id === m.stored_id);
|
||||
return {
|
||||
group_id: m.stored_id!,
|
||||
scene_index: found!.scene_index,
|
||||
};
|
||||
}),
|
||||
tag_ids: tags.getNewValue()?.map((t) => t.stored_id!),
|
||||
details: details.getNewValue(),
|
||||
organized: organized.getNewValue(),
|
||||
stash_ids: stashIDs.getNewValue(),
|
||||
cover_image: coverImage,
|
||||
custom_fields: {
|
||||
partial: Object.fromEntries(
|
||||
Array.from(customFields.entries()).flatMap(([field, v]) =>
|
||||
v.useNewValue ? [[field, v.getNewValue()]] : []
|
||||
)
|
||||
),
|
||||
},
|
||||
},
|
||||
values,
|
||||
includeViewHistory: playCount.getNewValue() !== undefined,
|
||||
includeOHistory: oCounter.getNewValue() !== undefined,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,7 +10,14 @@ import {
|
|||
import { useIntl } from "react-intl";
|
||||
import { ModalComponent } from "./Modal";
|
||||
import { Icon } from "./Icon";
|
||||
import { faClipboard, faFile, faLink } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faCameraRotate,
|
||||
faCirclePlay,
|
||||
faClipboard,
|
||||
faFile,
|
||||
faLink,
|
||||
faPhotoFilm,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { PatchComponent } from "src/patch";
|
||||
import ImageUtils from "src/utils/image";
|
||||
import { useToast } from "src/hooks/Toast";
|
||||
|
|
@ -20,8 +27,11 @@ interface IImageInput {
|
|||
text?: string;
|
||||
onImageChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onImageURL?: (url: string) => void;
|
||||
onImageURLSource?: (source: "url" | "clipboard", value?: string) => void;
|
||||
onReset?: () => void;
|
||||
acceptSVG?: boolean;
|
||||
onGenerateDefault?: () => void;
|
||||
onGenerateCurrent?: () => void;
|
||||
}
|
||||
|
||||
function acceptExtensions(acceptSVG: boolean = false) {
|
||||
|
|
@ -35,14 +45,16 @@ export const ImageInput: React.FC<IImageInput> = PatchComponent(
|
|||
text,
|
||||
onImageChange,
|
||||
onImageURL,
|
||||
onImageURLSource,
|
||||
onReset,
|
||||
acceptSVG = false,
|
||||
onGenerateDefault,
|
||||
onGenerateCurrent,
|
||||
}) => {
|
||||
const [isShowDialog, setIsShowDialog] = useState(false);
|
||||
const [url, setURL] = useState("");
|
||||
const intl = useIntl();
|
||||
const Toast = useToast();
|
||||
|
||||
if (!isEditing) return <div />;
|
||||
|
||||
if (!onImageURL) {
|
||||
|
|
@ -66,6 +78,7 @@ export const ImageInput: React.FC<IImageInput> = PatchComponent(
|
|||
const data = await ImageUtils.readClipboardImage();
|
||||
if (data && onImageURL) {
|
||||
onImageURL(data);
|
||||
onImageURLSource?.("clipboard");
|
||||
Toast.success(
|
||||
intl.formatMessage({ id: "toast.clipboard_image_pasted" })
|
||||
);
|
||||
|
|
@ -95,6 +108,7 @@ export const ImageInput: React.FC<IImageInput> = PatchComponent(
|
|||
|
||||
setIsShowDialog(false);
|
||||
onImageURL(url);
|
||||
onImageURLSource?.("url", url);
|
||||
}
|
||||
|
||||
function renderDialog() {
|
||||
|
|
@ -162,6 +176,46 @@ export const ImageInput: React.FC<IImageInput> = PatchComponent(
|
|||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{(onGenerateDefault || onGenerateCurrent) && (
|
||||
<hr className="my-2" />
|
||||
)}
|
||||
{onGenerateDefault && (
|
||||
<div>
|
||||
<Button className="minimal" onClick={onGenerateDefault}>
|
||||
<Icon icon={faPhotoFilm} className="fa-fw" />
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
id: "actions.generate_thumb_default",
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{onGenerateCurrent && (
|
||||
<div>
|
||||
<Button className="minimal" onClick={onGenerateCurrent}>
|
||||
<Icon icon={faCameraRotate} className="fa-fw" />
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
id: "actions.generate_thumb_from_current",
|
||||
})}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{onReset && (
|
||||
<>
|
||||
<hr className="my-2" />
|
||||
<div>
|
||||
<Button className="minimal" onClick={onReset}>
|
||||
<Icon icon={faCirclePlay} className="fa-solid" />
|
||||
<span>
|
||||
{intl.formatMessage({ id: "actions.clear_image" })}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
|
|
@ -180,11 +234,6 @@ export const ImageInput: React.FC<IImageInput> = PatchComponent(
|
|||
{text ?? intl.formatMessage({ id: "actions.set_image" })}
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
{onReset && (
|
||||
<Button variant="danger" className="mr-2" onClick={onReset}>
|
||||
{intl.formatMessage({ id: "actions.clear_image" })}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -383,6 +383,14 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
|||
director: resolveField("director", stashScene.director, scene.director),
|
||||
};
|
||||
|
||||
if (imgData) {
|
||||
(
|
||||
sceneCreateInput as unknown as { cover_image_source?: string }
|
||||
).cover_image_source = currentSource?.sourceInput.stash_box_endpoint
|
||||
? `stash:${currentSource.sourceInput.stash_box_endpoint}`
|
||||
: "url:scraper";
|
||||
}
|
||||
|
||||
const includeUrl = !excludedFieldList.includes("url");
|
||||
if (includeUrl && scene.urls) {
|
||||
sceneCreateInput.urls = uniq(stashScene.urls.concat(scene.urls));
|
||||
|
|
|
|||
|
|
@ -64,8 +64,8 @@
|
|||
"full_export": "Full export",
|
||||
"full_import": "Full import",
|
||||
"generate": "Generate",
|
||||
"generate_thumb_default": "Generate default thumbnail",
|
||||
"generate_thumb_from_current": "Generate thumbnail from current",
|
||||
"generate_thumb_default": "Generate default",
|
||||
"generate_thumb_from_current": "Generate from current",
|
||||
"hash_migration": "hash migration",
|
||||
"hide": "Hide",
|
||||
"hide_configuration": "Hide Configuration",
|
||||
|
|
@ -1419,6 +1419,22 @@
|
|||
"sceneTagger": "Scene Tagger",
|
||||
"scene_code": "Studio Code",
|
||||
"scene_count": "Scene Count",
|
||||
"scene_cover": {
|
||||
"clipboard": "Clipboard",
|
||||
"clipboard_tooltip": "{image} imported from clipboard by user",
|
||||
"default": "Default",
|
||||
"default_tooltip": "{image} generated from default settings",
|
||||
"file": "File",
|
||||
"file_tooltip": "{image} from file",
|
||||
"image": "Cover Image",
|
||||
"source": "{image} Source",
|
||||
"stash_tooltip": "{image} from StashBox",
|
||||
"timestamp_tooltip": "{image} extracted at timestamp",
|
||||
"url": "URL",
|
||||
"url_tooltip": "{image} from URL",
|
||||
"userscript": "Userscript",
|
||||
"userscript_tooltip": "{image} set via userscript"
|
||||
},
|
||||
"scenes_duration": "Scene Duration",
|
||||
"scenes_size": "Scene Size",
|
||||
"scene_created_at": "Scene Created At",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import React from "react";
|
||||
import { FontAwesomeIconProps } from "@fortawesome/react-fontawesome";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ExternalLink } from "src/components/Shared/ExternalLink";
|
||||
import { Icon } from "src/components/Shared/Icon";
|
||||
import { TruncatedText } from "src/components/Shared/TruncatedText";
|
||||
|
||||
interface ITextField {
|
||||
|
|
@ -155,3 +157,94 @@ export const URLsField: React.FC<IURLsField> = ({
|
|||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface IIconField {
|
||||
id?: string;
|
||||
name?: string;
|
||||
abbr?: string | null;
|
||||
icon?: FontAwesomeIconProps["icon"];
|
||||
tooltip?: string;
|
||||
value?: string | null;
|
||||
url?: string | null;
|
||||
stashEndpoint?: string | null;
|
||||
truncate?: boolean | null;
|
||||
target?: string;
|
||||
}
|
||||
|
||||
export const IconField: React.FC<IIconField> = ({
|
||||
id,
|
||||
name,
|
||||
abbr,
|
||||
icon,
|
||||
tooltip,
|
||||
value,
|
||||
url,
|
||||
stashEndpoint,
|
||||
truncate,
|
||||
target = "_blank",
|
||||
}) => {
|
||||
if (!value && !url && !stashEndpoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = (
|
||||
<>{id ? <FormattedMessage id={id} defaultMessage={name} /> : name}:</>
|
||||
);
|
||||
|
||||
const displayValue = value ?? url ?? stashEndpoint ?? "";
|
||||
|
||||
function renderValue() {
|
||||
if (stashEndpoint) {
|
||||
return (
|
||||
<span className="stash-id-pill" data-endpoint={stashEndpoint}>
|
||||
<span>{displayValue}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (url) {
|
||||
const children = truncate ? (
|
||||
<TruncatedText
|
||||
text={displayValue}
|
||||
className="d-inline-block align-middle"
|
||||
/>
|
||||
) : (
|
||||
displayValue
|
||||
);
|
||||
return (
|
||||
<ExternalLink
|
||||
href={url}
|
||||
target={target}
|
||||
title={url}
|
||||
className="flex-grow-1"
|
||||
>
|
||||
{children}
|
||||
</ExternalLink>
|
||||
);
|
||||
}
|
||||
|
||||
return displayValue;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<dt>{abbr ? <abbr title={abbr}>{message}</abbr> : message}</dt>
|
||||
<dd>
|
||||
<span
|
||||
title={tooltip}
|
||||
className={
|
||||
url
|
||||
? "d-inline-flex align-items-center w-100"
|
||||
: "d-inline-flex align-items-center"
|
||||
}
|
||||
style={url ? { minWidth: 0 } : undefined}
|
||||
>
|
||||
{icon && (
|
||||
<Icon icon={icon} className={url ? "mr-1 flex-shrink-0" : "mr-1"} />
|
||||
)}
|
||||
{renderValue()}
|
||||
</span>
|
||||
</dd>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue