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 %}
{% if locations %}
-
- {% for location in locations %}
-
- {% endfor %}
-
-
+
+
+
+
{% 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");