From bed8d7af7b58a18e9bf472bf696147d4a0abefa5 Mon Sep 17 00:00:00 2001 From: shmyga Date: Wed, 9 Sep 2026 19:16:00 +0300 Subject: [PATCH] refactor(static): add base component types --- .../route/view/common/templates/base.html | 1 - gallery/easel/route/view/schedule/route.py | 2 +- .../route/view/schedule/templates/index.html | 38 ++-- gallery/easel/route/view/weather/route.py | 2 +- .../route/view/weather/templates/index.html | 23 +- locales/ru/LC_MESSAGES/messages.po | 9 + static/src/app/base/component.ts | 196 ++++++++++++++++++ static/src/app/base/manager.ts | 50 +++++ static/src/app/base/storage.ts | 6 +- static/src/app/schedule/component.ts | 138 ++++-------- static/src/app/schedule/index.ts | 31 +-- static/src/app/weather/component.ts | 82 +++----- static/src/app/weather/index.ts | 26 +-- 13 files changed, 368 insertions(+), 236 deletions(-) create mode 100644 static/src/app/base/component.ts create mode 100644 static/src/app/base/manager.ts diff --git a/gallery/easel/route/view/common/templates/base.html b/gallery/easel/route/view/common/templates/base.html index baf4b6f..3d7e10e 100644 --- a/gallery/easel/route/view/common/templates/base.html +++ b/gallery/easel/route/view/common/templates/base.html @@ -23,7 +23,6 @@
{% if not is_widget %}
-
{{request.query_params.widget}}
{% if channels %} -
    - {% for channel in channels %} - - {% endfor %} -
-
+
+ +
+
{% endif %} -
    -
-
    - {% for group in groups %} - - {% endfor %} -
+
+

{{_("Stored channels")}}

+ +
+
+

{{_("Channel groups")}}

+
    + {% for group in groups %} + + {% endfor %} +
+
diff --git a/gallery/easel/route/view/weather/route.py b/gallery/easel/route/view/weather/route.py index 0990be8..fdc2cab 100644 --- a/gallery/easel/route/view/weather/route.py +++ b/gallery/easel/route/view/weather/route.py @@ -53,7 +53,7 @@ async def get_weather_index(request: AppRequest, provider: str | None = None, qu return templates.TemplateResponse( request=request, name="index.html", - context={"locations": locations}, + context={"locations": [item.model_dump() for item in locations]}, ) diff --git a/gallery/easel/route/view/weather/templates/index.html b/gallery/easel/route/view/weather/templates/index.html index 21ea15a..e85c0ab 100644 --- a/gallery/easel/route/view/weather/templates/index.html +++ b/gallery/easel/route/view/weather/templates/index.html @@ -35,17 +35,18 @@
{% if locations %} - -
+
+ +
+
{% endif %} - +
+

{{_("Stored locations")}}

+ +
diff --git a/locales/ru/LC_MESSAGES/messages.po b/locales/ru/LC_MESSAGES/messages.po index 16bfb0b..1f50e16 100644 --- a/locales/ru/LC_MESSAGES/messages.po +++ b/locales/ru/LC_MESSAGES/messages.po @@ -69,9 +69,18 @@ msgstr "Давление, мм рт. ст." msgid "Humidity, %" msgstr "Влажность, %" +msgid "Stored locations" +msgstr "Сохранённые локации" + # tv msgid "TV program" msgstr "Телепрограмма" msgid "Live broadcasts" msgstr "Прямые трансляции" + +msgid "Stored channels" +msgstr "Сохранённые каналы" + +msgid "Channel groups" +msgstr "Группы каналов" \ No newline at end of file diff --git a/static/src/app/base/component.ts b/static/src/app/base/component.ts new file mode 100644 index 0000000..a625f3d --- /dev/null +++ b/static/src/app/base/component.ts @@ -0,0 +1,196 @@ +export interface Type extends Function { + new (...args: any[]): T; +} + +export abstract class ValueElement extends HTMLElement { + static observedAttributes = ["value"]; + + private _value: T | null = null; + + get value(): T | null { + return this._value; + } + + set value(value: T | null) { + if (value) { + this.setAttribute("value", JSON.stringify(value)); + } else if (this.hasAttribute("value")) { + this.removeAttribute("value"); + } + } + + options: O | null; + + constructor(value: T | null = null, options: O | null = null) { + super(); + if (value) { + this.value = value; + } + this.options = options; + this.onInit(); + } + + onInit() {} + + beforeRefresh() {} + + onRefresh() {} + + abstract buildHTML(): string; + + refresh() { + const rawValue = this.getAttribute("value"); + if (rawValue) { + this._value = JSON.parse(rawValue); + } else { + this._value = null; + } + if (this.className) { + this.classList = this.className; + } + this.beforeRefresh(); + this.innerHTML = this.buildHTML(); + this.onRefresh(); + } + + connectedCallback() { + this.refresh(); + } + + attributeChangedCallback(name: string, oldValue: any, newValue: any) { + setTimeout(() => this.refresh()); + } +} + +export interface ListOptions { + onClick?: (element: ListItemElement) => void; + onAdd?: (element: ListItemElement) => void; + onRemove?: (element: ListItemElement) => void; +} + +export abstract class ListItemElement extends ValueElement> { + abstract getHref(value: T): string; + + abstract getContent(value: T): string; + + className = "list-group-item list-group-item-action"; + + onInit(): void { + this.handleClick = this.handleClick.bind(this); + this.handleRemoveClick = this.handleRemoveClick.bind(this); + this.handleAddClick = this.handleAddClick.bind(this); + } + + buildHTML(): string { + if (!this.value) { + return ""; + } + const href = this.getHref(this.value); + const content = this.getContent(this.value); + return ` + + + + + + + ${content} + + + + ✕ + + + + `; + } + + onRefresh(): void { + this.addEventListener("click", this.handleClick); + if (this.options?.onAdd) { + this.querySelector("[data-add]")?.addEventListener("click", this.handleAddClick); + } else { + this.querySelector("[data-add]")?.remove(); + } + if (this.options?.onRemove) { + this.querySelector("[data-remove]")?.addEventListener("click", this.handleRemoveClick); + } else { + this.querySelector("[data-remove]")?.remove(); + } + } + + handleClick(event: MouseEvent) { + if (this.value && this.options?.onClick) { + this.options.onClick(this); + } + } + + handleAddClick(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + if (this.value && this.options?.onAdd) { + this.options.onAdd(this); + } + } + + handleRemoveClick(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + if (this.value && this.options?.onRemove) { + this.options.onRemove(this); + } + } + + disconnectedCallback() { + this.removeEventListener("click", this.handleClick); + this.querySelector("[data-add]")?.removeEventListener("click", this.handleAddClick); + this.querySelector("[data-remove]")?.removeEventListener("click", this.handleRemoveClick); + } +} + +export abstract class ListElement = ListItemElement> extends ValueElement< + T[], + ListOptions +> { + static observedAttributes = ["value", "onadd", "onremove", "onclick"]; + + abstract childType: Type; + + className = "list-group"; + + buildHTML(): string { + return ""; + } + + beforeRefresh() { + if (this.hasAttribute("onclick")) { + const onclick = new Function("element", this.getAttribute("onclick")!) as ListOptions["onClick"]; + if (!this.options) { + this.options = {}; + } + this.options.onClick = onclick; + } + if (this.hasAttribute("onadd")) { + const onadd = new Function("element", this.getAttribute("onadd")!) as ListOptions["onAdd"]; + if (!this.options) { + this.options = {}; + } + this.options.onAdd = onadd; + } + if (this.hasAttribute("onremove")) { + const onremove = new Function("element", this.getAttribute("onremove")!) as ListOptions["onRemove"]; + if (!this.options) { + this.options = {}; + } + this.options.onRemove = onremove; + } + } + + onRefresh() { + if (this.value) { + for (const item of this.value) { + this.appendChild(new this.childType(item, this.options)); + } + } + } +} diff --git a/static/src/app/base/manager.ts b/static/src/app/base/manager.ts new file mode 100644 index 0000000..22f004d --- /dev/null +++ b/static/src/app/base/manager.ts @@ -0,0 +1,50 @@ +import { ListElement, ListItemElement } from "./component"; +import { DataStorage, StorageValue } from "./storage"; + +export class DataManager { + private storage: DataStorage; + + private get dataList(): ListElement { + return document.querySelector(this.dataListSelector)!; + } + + constructor( + listComponentType: typeof ListElement, //prevent removing as unusing + storageKey: string, + private dataListSelector: string, + ) { + this.storage = new DataStorage(storageKey); + this.load = this.load.bind(this); + this.store = this.store.bind(this); + this.add = this.add.bind(this); + this.remove = this.remove.bind(this); + } + + load() { + this.dataList.value = this.storage.getAll(); + } + + store(element: ListItemElement) { + if (element.value) { + this.storage.add(element.value); + } + } + + add(element: ListItemElement) { + const value = element.value; + value; + if (value && this.storage.add(value)) { + const clone: ListItemElement = element.cloneNode(true) as ListItemElement; + clone.options = { onRemove: this.remove }; + this.dataList.appendChild(clone); + } + } + + remove(element: ListItemElement) { + const value = element.value; + if (value) { + this.storage.remove(value.id); + element.remove(); + } + } +} diff --git a/static/src/app/base/storage.ts b/static/src/app/base/storage.ts index 3edd8fb..937442f 100644 --- a/static/src/app/base/storage.ts +++ b/static/src/app/base/storage.ts @@ -1,4 +1,8 @@ -export class DataStorage { +export interface StorageValue { + id: string; +} + +export class DataStorage { constructor(private storageKey: string) {} private readValues(): T[] { diff --git a/static/src/app/schedule/component.ts b/static/src/app/schedule/component.ts index 9270d5a..d7f1ee5 100644 --- a/static/src/app/schedule/component.ts +++ b/static/src/app/schedule/component.ts @@ -1,110 +1,52 @@ +import { ListElement, ListItemElement, ValueElement } from "../base/component"; import { Channel } from "./model"; -export class ScheduleChannelElement extends HTMLElement { - static observedAttributes = ["channel", "removable"]; +export class ScheduleChannelGroup extends ValueElement { + className: string = "list-group-item list-group-item-action"; - channel: Channel | null = null; - - constructor() { - super(); - this.handleClick = this.handleClick.bind(this); - this.handleRemoveClick = this.handleRemoveClick.bind(this); - this.handleAddClick = this.handleAddClick.bind(this); - } - - connectedCallback() { - this.channel = JSON.parse(this.getAttribute("channel") || "{}"); - this.innerHTML = ` - `${item.provider}:${item.id}`).join(",") + : this.value || ""; + return ` + - - + - - ${this.channel?.provider} - ${this.channel?.name} - - - - ✕ - + ${groupName} `; - this.classList = "list-group-item list-group-item-action"; - this.addEventListener("click", this.handleClick); - if (this.hasAttribute("removable")) { - this.querySelector("[data-remove]")?.addEventListener("click", this.handleRemoveClick); + } +} + +customElements.define("schedule-channel-group", ScheduleChannelGroup); + +export class ScheduleChannelItem extends ListItemElement { + getHref(value: Channel): string { + return `${value.provider}/${value.id}`; + } + + getContent(value: Channel): string { + return ` + ${value.provider} + ${value.name} + `; + } +} + +export class ScheduleChannelList extends ListElement { + childType = ScheduleChannelItem; + + public onRefresh(): void { + super.onRefresh(); + if (this.value?.length) { + const groupElement = new ScheduleChannelGroup(this.value); + this.prepend(groupElement); } else { - this.querySelector("[data-remove]")?.remove(); - } - if (this.hasAttribute("addeable")) { - this.querySelector("[data-add]")?.addEventListener("click", this.handleAddClick); - } else { - this.querySelector("[data-add]")?.remove(); - } - } - - disconnectedCallback() { - this.removeEventListener("click", this.handleClick); - this.querySelector("[data-remove]")?.removeEventListener("click", this.handleRemoveClick); - } - - handleClick(event: MouseEvent) { - if (this.channel) { - window.scheduleChannelStorage.add(this.channel); - } - } - - handleRemoveClick(event: MouseEvent) { - event.preventDefault(); - event.stopPropagation(); - if (this.channel) { - window.scheduleChannelStorage.remove(this.channel.id); - } - this.remove(); - } - - handleAddClick(event: MouseEvent) { - event.preventDefault(); - event.stopPropagation(); - if (this.channel) { - if (window.scheduleChannelStorage.add(this.channel)) { - const storedChannels = document.querySelector("#storedChannels"); - if (storedChannels) { - const clone: Element = this.cloneNode(true) as Element; - clone.removeAttribute("addeable"); - clone.setAttribute("removable", ""); - storedChannels.appendChild(clone); - } - } + this.prepend("Empty"); } } } -export class ScheduleChannelGroupElement extends HTMLElement { - static observedAttributes = ["group", "channels"]; - - group: string | null = null; - channels: Channel[] | null = null; - - constructor() { - super(); - } - - connectedCallback() { - this.channels = JSON.parse(this.getAttribute("channels") || "[]"); - this.group = this.getAttribute("group"); - const groupId = this.channels?.map((item) => `${item.provider}:${item.id}`).join(",") || this.group || "n/a"; - this.innerHTML = ` - - - ${this.group || "Open group"} - - `; - this.classList = "list-group-item list-group-item-action"; - } -} - -customElements.define("schedule-channel", ScheduleChannelElement); -customElements.define("schedule-channel-group", ScheduleChannelGroupElement); +customElements.define("schedule-channel-item", ScheduleChannelItem); +customElements.define("schedule-channel-list", ScheduleChannelList); diff --git a/static/src/app/schedule/index.ts b/static/src/app/schedule/index.ts index d51fb25..1cf45dc 100644 --- a/static/src/app/schedule/index.ts +++ b/static/src/app/schedule/index.ts @@ -1,34 +1,11 @@ -import { DataStorage } from "../base/storage"; -import { ScheduleChannelElement, ScheduleChannelGroupElement } from "./component"; +import { DataManager } from "../base/manager"; +import { ScheduleChannelList } from "./component"; import { Channel } from "./model"; -export class ScheduleChannelManager { - loadChannels(selector: string) { - const channels = window.scheduleChannelStorage.getAll(); - const container = document.querySelector(selector); - if (container) { - container.innerHTML = ""; - if (channels.length > 0) { - const groupElement = new ScheduleChannelGroupElement(); - groupElement.setAttribute("channels", JSON.stringify(channels)); - container.append(groupElement); - } - for (const channel of channels) { - const element = new ScheduleChannelElement(); - element.setAttribute("channel", JSON.stringify(channel)); - element.setAttribute("removable", ""); - container.appendChild(element); - } - } - } -} - declare global { interface Window { - scheduleChannelStorage: DataStorage; - scheduleChannelManager: ScheduleChannelManager; + scheduleChannelManager: DataManager; } } -window.scheduleChannelStorage = new DataStorage("schedule:channels"); -window.scheduleChannelManager = new ScheduleChannelManager(); +window.scheduleChannelManager = new DataManager(ScheduleChannelList, "schedule:channels", "#storedChannels"); diff --git a/static/src/app/weather/component.ts b/static/src/app/weather/component.ts index 491aedf..d8b75f9 100644 --- a/static/src/app/weather/component.ts +++ b/static/src/app/weather/component.ts @@ -1,63 +1,33 @@ +import { ListElement, ListItemElement } from "../base/component"; import { Location } from "./model"; -export class WeatherLocationElement extends HTMLElement { - static observedAttributes = ["location", "removable"]; - - location: Location | null = null; - - constructor() { - super(); - this.handleClick = this.handleClick.bind(this); - this.handleRemoveClick = this.handleRemoveClick.bind(this); +export class WheatherLocationItem extends ListItemElement { + getHref(value: Location): string { + return `${value.provider}/${value.id}`; } - connectedCallback() { - this.location = JSON.parse(this.getAttribute("location") || "{}"); - this.innerHTML = ` - - - ${this.location?.provider} - - ${this.location?.name} - - ${this.location?.country}, ${this.location?.district}, ${this.location?.subdistrict} - - - - - ✕ - - - `; - this.classList = "list-group-item list-group-item-action"; - this.addEventListener("click", this.handleClick); - if (this.hasAttribute("removable")) { - this.querySelector("[data-remove]")?.addEventListener("click", this.handleRemoveClick); - } else { - this.querySelector("[data-remove]")?.remove(); - } - } - - disconnectedCallback() { - this.removeEventListener("click", this.handleClick); - this.querySelector("[data-remove]")?.removeEventListener("click", this.handleRemoveClick); - } - - handleClick(event: MouseEvent) { - if (this.location) { - window.weatherLocationStorage.add(this.location); - } - } - - handleRemoveClick(event: MouseEvent) { - event.preventDefault(); - event.stopPropagation(); - if (this.location) { - window.weatherLocationStorage.remove(this.location.id); - } - this.remove(); + getContent(value: Location): string { + return ` + ${value.provider} + + ${value.name} + + ${value.country}, ${value.district}, ${value.subdistrict} + + `; } } -customElements.define("weather-location", WeatherLocationElement); +export class WeatherLocationList extends ListElement { + childType = WheatherLocationItem; + + public onRefresh(): void { + super.onRefresh(); + if (!this.value?.length) { + this.prepend("Empty"); + } + } +} + +customElements.define("weather-location-item", WheatherLocationItem); +customElements.define("weather-location-list", WeatherLocationList); diff --git a/static/src/app/weather/index.ts b/static/src/app/weather/index.ts index 5ff05e8..ca6871a 100644 --- a/static/src/app/weather/index.ts +++ b/static/src/app/weather/index.ts @@ -1,29 +1,11 @@ -import { DataStorage } from "../base/storage"; -import { WeatherLocationElement } from "./component"; +import { DataManager } from "../base/manager"; +import { WeatherLocationList } from "./component"; import { Location } from "./model"; -export class WeatherLocationManager { - loadLocations(selector: string) { - const locations = window.weatherLocationStorage.getAll(); - const container = document.querySelector(selector); - if (container) { - container.innerHTML = ""; - for (const location of locations) { - const element = new WeatherLocationElement(); - element.setAttribute("location", JSON.stringify(location)); - element.setAttribute("removable", ""); - container.appendChild(element); - } - } - } -} - declare global { interface Window { - weatherLocationStorage: DataStorage; - weatherLocationManager: WeatherLocationManager; + weatherLocationManager: DataManager; } } -window.weatherLocationStorage = new DataStorage("weather:locations"); -window.weatherLocationManager = new WeatherLocationManager(); +window.weatherLocationManager = new DataManager(WeatherLocationList, "weather:locations", "#storedLocations");