From 143c867904aa931a54450e3e6a87d7640237ccfb Mon Sep 17 00:00:00 2001 From: Sebastien DUMETZ Date: Thu, 4 Jul 2024 16:51:30 +0200 Subject: [PATCH] tag support in scene history page show tags in the collections page. Add search input to navbar --- source/ui/MainView.ts | 31 ++++-- source/ui/composants/Icon.ts | 6 +- source/ui/composants/SceneCard.ts | 27 +++-- source/ui/composants/TagList.ts | 131 ++++++++++++++++++++++++ source/ui/composants/navbar/styles.scss | 2 +- source/ui/screens/Home.ts | 87 +++++++++------- source/ui/screens/List.ts | 72 +++++++------ source/ui/screens/SceneHistory.ts | 72 +++++++++++-- source/ui/screens/Tags.ts | 131 ++++++++++++++++++++++++ source/ui/state/strings.ts | 4 + source/ui/state/withScenes.ts | 1 + source/ui/styles/layout.scss | 110 ++++++++++++++++---- 12 files changed, 560 insertions(+), 114 deletions(-) create mode 100644 source/ui/composants/TagList.ts create mode 100644 source/ui/screens/Tags.ts diff --git a/source/ui/MainView.ts b/source/ui/MainView.ts index faf99c08..ef1c75e2 100644 --- a/source/ui/MainView.ts +++ b/source/ui/MainView.ts @@ -11,20 +11,23 @@ import "./composants/navbar/NavLink"; import "./composants/navbar/Navbar"; import "./composants/navbar/UserButton"; import "./composants/navbar/ChangeLocale"; +import "./composants/Modal"; + import "./screens/List"; import "./screens/Admin"; import "./screens/SceneHistory"; import "./screens/FileHistory"; import "./screens/UserSettings"; -import "./screens/Home" -import "./composants/Modal"; +import "./screens/Home"; +import "./screens/Tags"; + import Notification from "./composants/Notification"; import { updateLogin, withUser } from './state/auth'; import Modal from './composants/Modal'; import i18n from './state/translate'; -import { route, router } from './state/router'; +import { navigate, route, router } from './state/router'; @customElement("ecorpus-main") @@ -32,13 +35,15 @@ export default class MainView extends router(i18n(withUser(LitElement))){ @route() static "/ui/" =({search})=> html``; @route() - static "/ui/scenes/" =({search})=> html``; + static "/ui/tags/" = ()=>html``; + @route() + static "/ui/scenes/" =({search: qs})=> html``; @route() static "/ui/user/" = ()=> html`` @route() static "/ui/admin/.*" = ()=> html``; @route() - static "/ui/scenes/:id/" = ({parent, params}) => html``; + static "/ui/scenes/:id/" = ({params}) => html``; connectedCallback(): void { super.connectedCallback(); @@ -48,11 +53,25 @@ export default class MainView extends router(i18n(withUser(LitElement))){ }); } + onSearch = (e:Event)=>{ + e.preventDefault(); + e.stopPropagation(); + const value = (e.target as HTMLInputElement).value; + navigate(this,"/ui/scenes/", {search: value}); + } + render() { return html` - Collection +
+ + + + + +
+ Collections ${(this.user?.isAdministrator)?html`${this.t("ui.administration")}`:""}
diff --git a/source/ui/composants/Icon.ts b/source/ui/composants/Icon.ts index 93040bb8..81a80403 100644 --- a/source/ui/composants/Icon.ts +++ b/source/ui/composants/Icon.ts @@ -94,9 +94,9 @@ Icon.add("grid", html``) Icon.add("edit", html``) -Icon.add("admin", html``) -Icon.add("search", html``) -Icon.add("restore", html``) +Icon.add("admin", html``) +Icon.add("search", html``) +Icon.add("restore", html``) Icon.add("stats", html``) Icon.add("users", html``) diff --git a/source/ui/composants/SceneCard.ts b/source/ui/composants/SceneCard.ts index 2f937b94..67c246cd 100644 --- a/source/ui/composants/SceneCard.ts +++ b/source/ui/composants/SceneCard.ts @@ -60,7 +60,7 @@ import { AccessType, AccessTypes, Scene } from "../state/withScenes"; let explorer = `/ui/scenes/${encodeURIComponent(this.name)}/view`; let story = `/ui/scenes/${encodeURIComponent(this.name)}/edit`; return html` - ${(this.onChange? html` @@ -91,12 +100,13 @@ import { AccessType, AccessTypes, Scene } from "../state/withScenes"; .scene-card-inner{ background-color: var(--color-element); box-sizing: border-box; - padding: 1rem; + padding: .5rem; width: 100%; height: 100%; border-radius: 4px; border: 2px solid var(--color-highlight); transition: background 0.2s; + overflow: hidden; } .scene-card-inner:hover{ @@ -134,9 +144,11 @@ import { AccessType, AccessTypes, Scene } from "../state/withScenes"; } .tools{ + max-width: 100%; margin-top: 0.5rem; display:flex; justify-content: space-around; + overflow: hidden; } .scene-card-inner-list .tools{ @@ -144,13 +156,14 @@ import { AccessType, AccessTypes, Scene } from "../state/withScenes"; } .tools a{ - width: 100%; margin: 2px; color: #eee; text-decoration: none; + padding: 0 0.5rem; + min-width: 24px; display: flex; justify-content: center; - padding: 0 0.5rem; + flex-wrap: wrap; } .tools a:hover{ color: var(--color-secondary-light); diff --git a/source/ui/composants/TagList.ts b/source/ui/composants/TagList.ts new file mode 100644 index 00000000..00df495a --- /dev/null +++ b/source/ui/composants/TagList.ts @@ -0,0 +1,131 @@ +import { LitElement, css, customElement, html, property } from "lit-element"; + + + + +@customElement("tag-list") +export default class TagList extends LitElement{ + /** List of tag names to display */ + @property({attribute: false}) + tags :string[] = []; + + /** show add/remove elements */ + @property({attribute: true, reflect: true, type: Boolean}) + editable :boolean; + + @property({attribute: false, type: Number}) + selected :number; + + private onAdd = (e:Event)=>{ + e.preventDefault(); + e.stopPropagation(); + const target = (e.target as HTMLFormElement); + const data = new FormData(target); + this.dispatchEvent(new CustomEvent("add", {detail: data.get("tag")})); + target.reset(); + } + + + private onRemove = (e:Event)=>{ + this.dispatchEvent(new CustomEvent("remove", {detail:(e.target as HTMLButtonElement).name})); + } + + private onClick = (index :number, ev :Event)=>{ + ev.preventDefault(); + ev.stopPropagation(); + this.dispatchEvent(new CustomEvent("click", {detail: this.tags[index]})); + } + + render(){ + return html`
+ ${this.tags.map((t, index)=>html` + ${t} + ${this.editable? html``:null} + `)} + ${this.editable? html`
+ + +
`: null} +
` + } + + static styles = css` + .tags-list{ + display: flex; + gap: 2px; + } + + .tag, .add-tag{ + color: white; + padding: .125em; + border-radius: .75em; + border: 1px solid var(--color-secondary); + background: var(--color-highlight); + } + .tag:hover, .tag.selected{ + background-color: var(--color-secondary); + } + + .tag .tag-name{ + padding-left: .375em; + cursor: pointer; + } + + .tag .tag-name:last-child{ + padding-right: .375em; + } + + .tag .tag-delete{ + display: inline; + background-color: var(--color-highlight); + color: white; + border: 1px solid transparent; + border-radius: .75em; + padding: 1px 4.5px; + margin: 1px; + box-sizing: border-box; + cursor: pointer; + transition: background-color .1s ease; + } + + .tag .tag-delete:hover{ + background-color: var(--color-secondary); + border-color: var(--color-secondary-light); + } + + .add-tag{ + position: relative; + } + + .add-tag input{ + color: inherit; + border: none; + background: none; + height: 100%; + box-sizing: border-box; + } + + .add-tag input:focus-visible{ + outline: none; + } + + .add-tag input[type="text"]{ + max-width:12ch; + } + + .add-tag input[type="submit"]{ + cursor: pointer; + opacity: 0; + } + + .add-tag:valid input[type="submit"]{ + opacity: 1; + } + + .add-tag input[type="submit"]:hover{ + color: var(--color-success); + background-color: var(--color-secondary); + border-radius: .75em; + } + `; +} \ No newline at end of file diff --git a/source/ui/composants/navbar/styles.scss b/source/ui/composants/navbar/styles.scss index 5f4f4803..bcf9c665 100644 --- a/source/ui/composants/navbar/styles.scss +++ b/source/ui/composants/navbar/styles.scss @@ -52,7 +52,7 @@ nav { align-items: stretch; flex-basis: auto; justify-content: end; - ::slotted(.btn), ::slotted(.nav-link){ + ::slotted(.btn), ::slotted(.nav-link), ::slotted(.form-item){ height:100%; flex: 0 0 auto; box-sizing: border-box; diff --git a/source/ui/screens/Home.ts b/source/ui/screens/Home.ts index fb125219..4861b197 100644 --- a/source/ui/screens/Home.ts +++ b/source/ui/screens/Home.ts @@ -134,14 +134,11 @@ interface Upload{ />`; } - private renderSceneCompact(scene:Scene|Upload){ + private renderSceneCompact(scene:Scene){ return html` - ${"author" in scene? html` - ${scene.name} - ${scene.author} - ${new Date(scene.mtime).toLocaleString(this.language)} - `:scene.name} + ${scene.name} + ${new Date(scene.mtime).toLocaleString(this.language)} `; } @@ -165,41 +162,53 @@ interface Upload{ return html`

${this.t("info.homeHeader")}

-
- - ${this.t("ui.upload")} - - ${this.t("info.useStandalone")} -
-
-

${this.t("ui.myScenes")}

- ${uploads.length !== 0? html``: (myScenes.length > 0) ? - html` -
- ${myScenes.map((scene)=>this.renderScene(mode, scene))} +
+
+ +
+ +
+
+

Tools

+ ${this.t("ui.searchScene")} + + ${this.t("ui.upload")} + + ${this.t("info.useStandalone")} +
+
+

${this.t("ui.mtimeSection")}

+
+ + ${this.t("ui.name")} + ${this.t("ui.mtime")} + + ${repeat([ + ...scenes.slice(0, 8), + ],({name})=>name , (scene)=>this.renderSceneCompact(scene))}
- `: null} -
- ${(recentScenes.some(s=>myScenes.indexOf(s) == -1))? html`
-

${this.t("ui.ctimeSection")}

-
- ${repeat([ - ...recentScenes, - ],({name})=>name , (scene)=>this.renderScene(mode, scene))}
-
`: null} - -
-

${this.t("ui.mtimeSection")}

-
- - ${this.t("ui.name")} - ${this.t("ui.author")} - ${this.t("ui.mtime")} - - ${repeat([ - ...scenes.slice(0, 8), - ],({name})=>name , (scene)=>this.renderSceneCompact(scene))} + +
+
+
+

${this.t("ui.myScenes")}

+ ${uploads.length !== 0? html``: (myScenes.length > 0) ? + html` +
+ ${myScenes.map((scene)=>this.renderScene(mode, scene))} +
+ `: null} +
+ ${(recentScenes.some(s=>myScenes.indexOf(s) == -1))? html`
+

${this.t("ui.ctimeSection")}

+
+ ${repeat([ + ...recentScenes, + ],({name})=>name , (scene)=>this.renderScene(mode, scene))} +
+
`: null} +
`} diff --git a/source/ui/screens/List.ts b/source/ui/screens/List.ts index 06dbda83..151d1713 100644 --- a/source/ui/screens/List.ts +++ b/source/ui/screens/List.ts @@ -6,6 +6,7 @@ import "../composants/UploadButton"; import "./LandingPage"; import "../composants/SceneCard"; import "../composants/ListItem"; +import "../composants/TagList"; import spinnerImage from "../assets/images/spinner.svg"; @@ -15,6 +16,7 @@ import { repeat } from "lit-html/directives/repeat"; import "../composants/TaskButton"; import { withScenes, Scene, sorts, OrderBy } from "../state/withScenes"; +import { navigate } from "../state/router"; interface Upload{ name :string; @@ -28,6 +30,9 @@ interface Upload{ export default class List extends withScenes( withUser( i18n( LitElement ))) { + @property({attribute:true, type: String}) + search ?:string; + @property({type: Object}) uploads :{[name :string]:{ error ?:{code?:number,message:string}, @@ -133,6 +138,7 @@ interface Upload{ } protected render() :TemplateResult { + console.log("Render :", window.location.href); if(!this.isUser){ return html``; } @@ -156,40 +162,44 @@ interface Upload{ return html` -
-
-
- +
+
+
+
-
-

${this.t("ui.newScene")}

- - ${this.t("ui.upload")} - - - ${this.t("info.useStandalone")} +
+ ${this.t("ui.sortBy")} +
- ${(this.selection.length)?html` -
-

${this.t("ui.tools")}

- - Download Zip - -
`: null} -
-
-
-
- ${this.t("ui.sortBy")} -
+ +
+
+
+

${this.t("ui.newScene")}

+ + ${this.t("ui.upload")} + + + ${this.t("info.useStandalone")} +
-
+ ${(this.selection.length)?html` +
+

${this.t("ui.tools")}

+ + Download Zip + +
`: null} +
+
+ +
${this.error? html`

Error

${this.error} @@ -205,6 +215,7 @@ interface Upload{
`}
+
`; } @@ -251,8 +262,7 @@ interface Upload{ onSearchChange = (ev)=>{ ev.preventDefault(); - this.match = ev.target.value; - this.fetchScenes() + navigate(this, null, {search: ev.target.value}); console.log("list items find : ",this.list) } diff --git a/source/ui/screens/SceneHistory.ts b/source/ui/screens/SceneHistory.ts index 03b489ed..388c9127 100644 --- a/source/ui/screens/SceneHistory.ts +++ b/source/ui/screens/SceneHistory.ts @@ -5,6 +5,7 @@ import Notification from "../composants/Notification"; import "../composants/Button"; import "../composants/Spinner"; import "../composants/Size"; +import "../composants/TagList"; import { nothing } from "lit-html"; import i18n from "../state/translate"; @@ -12,6 +13,7 @@ import { withUser } from "../state/auth"; import { navigate } from "../state/router"; import Modal from "../composants/Modal"; import { AccessType, Scene } from "state/withScenes"; +import HttpError from "../state/HttpError"; const AccessTypes = [ @@ -196,10 +198,19 @@ class SceneVersion{ let scene = encodeURIComponent(this.name); return html`

${this.name}

-
-
-

Total size:

-

${articles.size} article${(1 < articles.size?"s":"")}

+ +
+ +
+
+ ${this.renderTags()} +
+ +
+

Total size:

+

${articles.size} article${(1 < articles.size?"s":"")}

+
+
-
+ +
${this.renderPermissions()}
+
@@ -223,8 +236,55 @@ class SceneVersion{
`; } + + async setTags(tags :string[]){ + return await fetch(`/api/v1/scenes/${encodeURIComponent(this.name)}`, { + method: "PATCH", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({tags}) + }).then(async r =>{ + await HttpError.okOrThrow(r); + this.scene = await r.json(); + }); + } + + + renderTags(){ + + const addTag = (e:CustomEvent)=>{ + const tag = e.detail.toLowerCase(); //Will get sanitized server-side but it won't hurt to fix it here + const currentTags = new Set([...this.scene.tags]); + console.log("Tags : ", tag, currentTags.has(tag), currentTags); + if(currentTags.has(tag)){ + return Notification.show(`The scene already has a tag named ${tag}`, "warning", 4000); + } + currentTags.add(tag); + this.scene = {...this.scene, tags: Array.from(currentTags)}; + this.setTags(this.scene.tags).catch(e=>{ + console.error(e); + Notification.show(`Failed to add scene tag ${tag}: ${e.message}`, "error", 6000); + const tags = new Set(this.scene.tags); + tags.delete(tag); + this.scene = {...this.scene, tags: Array.from(tags) }; + }); + } + const rmTag = (e:CustomEvent)=>{ + const tag = e.detail; + const currentTags = new Set([...this.scene.tags]); + currentTags.delete(tag); + this.scene = {...this.scene, tags: Array.from(currentTags)}; + this.setTags(this.scene.tags).catch(e=>{ + console.error(e); + Notification.show(`Failed to remove scene tag ${tag}: ${e.message}`, "error", 6000); + const tags = new Set(this.scene.tags); + tags.add(tag); + this.scene = {...this.scene, tags: Array.from(tags) }; + }); + } + return html`` + } + renderPermissions(){ - console.log("Can : ", this.can("admin"), this.scene); return html`

${this.t("ui.access")}

diff --git a/source/ui/screens/Tags.ts b/source/ui/screens/Tags.ts new file mode 100644 index 00000000..97c524fc --- /dev/null +++ b/source/ui/screens/Tags.ts @@ -0,0 +1,131 @@ +import { LitElement, customElement, html, property } from "lit-element"; + +import Notification from "../composants/Notification"; + +import "../composants/TagList"; +import "../composants/SceneCard"; +import Notifications from "../composants/Notification"; +import { Scene } from "../state/withScenes"; +import { withUser } from "../state/auth"; + +export interface Tag{ + name :string; + size :number; +} + + +@customElement("tags-screen") +export default class TagsScreen extends withUser(LitElement){ + + @property({attribute: false, type: Array}) + tags :Tag[] = []; + + @property({attribute:false, type: Number}) + selected :number = -1; + + @property({attribute:false, type: Array}) + scenes :Scene[] = []; + + #c:AbortController = new AbortController(); + + constructor() + { + super(); + } + createRenderRoot() { + return this; + } + + public connectedCallback(): void { + super.connectedCallback(); + this.fetchTags(); + } + + + public disconnectedCallback(): void { + this.#c.abort(); + } + + + public update(changedProperties: Map): void { + if(changedProperties.has("selected")){ + this.fetchTag(); + } + super.update(changedProperties); + } + + + async fetchTags(){ + const signal = this.#c.signal; + await fetch(`/api/v1/tags`, {signal}).then(async (r)=>{ + if(!r.ok) throw new Error(`[${r.status}]: ${r.statusText}`); + let body = await r.json(); + if(signal.aborted) return; + this.tags = body; + }).catch((e)=> { + if(e.name == "AbortError") return; + console.error(e); + Notification.show(`Failed to fetch scene scene: ${e.message}`, "error"); + }); + } + + async fetchTag(){ + this.scenes = []; + if(this.selected === -1 ) return; + const signal = this.#c.signal; + await fetch(`/api/v1/tags/${encodeURIComponent(this.tags[this.selected].name)}`, {signal}).then(async (r)=>{ + if(!r.ok) throw new Error(`[${r.status}]: ${r.statusText}`); + let body = await r.json(); + if(signal.aborted) return; + this.scenes = body; + console.log("Scenes : ", this.scenes); + }).catch((e)=> { + if(e.name == "AbortError") return; + console.error(e); + Notification.show(`Failed to fetch scene scene: ${e.message}`, "error"); + }); + } + + handleTagClick = (e:CustomEvent)=>{ + const tagText = e.detail; + const tagIndex = this.tags.findIndex((t)=> this.formatTag(t) === tagText); + if(tagIndex === -1) return Notifications.show(`Can't find tag matching ${tagText}`, "error"); + if(this.selected === tagIndex){ + this.selected = -1; + return; + } + this.selected = tagIndex; + } + + formatTag({name, size}:Tag){ + return `${name} (${size})`; + } + + render(){ + console.log("Tags :", this.tags); + return html`
+

Tags

+
+ ${this.tags.length?html` + + `:html`No tags found on this instance. Head over to the search page to create tags`} +
+ + ${this.selected != -1? html`
+

${this.tags[this.selected].name}

+
+ ${this.scenes.map(scene=>{ + return html`` + })} +
+
`:null} +
`; + } +} \ No newline at end of file diff --git a/source/ui/state/strings.ts b/source/ui/state/strings.ts index 90f5b095..97912b82 100644 --- a/source/ui/state/strings.ts +++ b/source/ui/state/strings.ts @@ -221,6 +221,10 @@ export default { fr: "Envoyer", en: "Send", }, + tag: { + fr: "tag{plural=s}", + en: "tag{plural=s}" + } }, info:{ noData:{ diff --git a/source/ui/state/withScenes.ts b/source/ui/state/withScenes.ts index a2d5109f..2f7940ff 100644 --- a/source/ui/state/withScenes.ts +++ b/source/ui/state/withScenes.ts @@ -12,6 +12,7 @@ export interface Scene { id :number; name :string; thumb ?:string; + tags :string[]; access: { user ?:AccessType, any :AccessType, diff --git a/source/ui/styles/layout.scss b/source/ui/styles/layout.scss index bf299b2d..327956cf 100644 --- a/source/ui/styles/layout.scss +++ b/source/ui/styles/layout.scss @@ -4,6 +4,7 @@ --caret-width: 2px; position: relative; background: var(--color-section); + &::before, &::after{ content: ""; position: absolute; @@ -28,6 +29,11 @@ padding: 1rem; margin-bottom: 1rem; + + >.flush{ + margin: 0 -1rem; + } + h1, h2, h3, h4, h5, h6{ &:first-child{ margin-top: 0; @@ -35,14 +41,14 @@ } } + corpus-list, home-page { display: block; padding: 1rem; - .list-grid{ - display: flex; - flex-direction: row; - flex-wrap: wrap; + @media screen and (max-width: 992px){ + padding: 1rem 4px; } + .drag-overlay{ position: absolute; top:0; @@ -57,7 +63,41 @@ corpus-list, home-page { } } + +.main-grid{ + display: grid; + gap: .5rem; + grid-auto-rows: auto; + grid-template-columns: minmax(auto, 300px) 1fr; + grid-template-areas: + '. header' + 'toolbar content'; + + @media screen and (max-width: 992px){ + grid-template-columns: auto; + grid-template-areas: + 'toolbar' + 'header' + 'content'; + } + .grid-header{ + grid-area: header; + } + .grid-toolbar{ + grid-area: toolbar; + .section{ + display: flex; + flex-direction: column; + gap: 2px; + } + } + .grid-content{ + grid-area: content; + } +} + home-page{ + .list-tasks{ display: flex; justify-content: flex-start; @@ -67,20 +107,58 @@ home-page{ flex: 0 0 auto; } } + .list-grid{ + display:grid; + gap: 5px; + grid-template-columns: auto; + + @media (min-width: 610px){ + grid-template-columns: repeat(2, 1fr); + } + @media (min-width: 1600px){ + grid-template-columns: repeat(4, 1fr); + } + } } corpus-list{ - display:flex; - gap: 10px; - .list-tasks{ - padding: 0.5rem; - width: 300px; + + .list-header{ + grid-area: header; + display: flex; + } + .toolbar{ + grid-area: toolbar; .section{ display: flex; flex-direction: column; - gap: .5rem; + gap: 2px; } } + + .list-items{ + grid-area: content; + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 2px; + } + + .search-box-input { + flex-grow: 1; + padding-right: 100px; + margin-right: -100px; + border-bottom: 1px solid var(--color-secondary-light); + + .btn-addon{ + width: 100px; + border-bottom: 1px solid var(--color-secondary-light); + } + &:focus + .btn-addon{ + border-bottom-color: transparent; + } + + + } .toolbar, .list-items{ height: fit-content; } @@ -100,17 +178,7 @@ corpus-list{ scene-card{ &.card-grid{ - padding: 0.5rem; - - @media (min-width: 576px){ - width: calc(100% / 2); - } - @media (min-width: 992px){ - width: calc(100% / 3); - } - @media (min-width: 1200px){ - width: calc(100% / 4); - } + padding :0; } &.card-list{