This commit is contained in:
euzu
2025-07-03 20:17:00 +02:00
parent 8333a2d8eb
commit 280bdcda8f
71 changed files with 954 additions and 390 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ button {
//}
}
.hidden {
.tp__hidden {
display: none !important;
}
+8 -3
View File
@@ -78,6 +78,11 @@ pub struct CompiledRegex {
pub restr: String,
pub re: regex::Regex,
}
impl PartialEq for CompiledRegex {
fn eq(&self, other: &Self) -> bool {
self.restr == other.restr
}
}
#[derive(Parser)]
#[grammar_inline = r#"
@@ -105,12 +110,12 @@ main = _{ SOI ~ stmt ~ EOI }
"#]
struct FilterParser;
#[derive(Debug, Copy, Clone)]
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum UnaryOperator {
Not,
}
#[derive(Debug, Copy, Clone)]
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum BinaryOperator {
And,
Or,
@@ -130,7 +135,7 @@ impl std::fmt::Display for BinaryOperator {
}
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq)]
pub enum Filter {
Group(Box<Filter>),
FieldComparison(ItemField, CompiledRegex),
+1 -1
View File
@@ -2,7 +2,7 @@ use crate::error::{create_tuliprox_error_result, TuliproxError, TuliproxErrorKin
use crate::foundation::filter::apply_templates_to_pattern_single;
use crate::model::{ItemField, PatternTemplate};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ConfigRenameDto {
pub field: ItemField,
+23 -7
View File
@@ -1,8 +1,8 @@
use regex::Regex;
use crate::{create_tuliprox_error, handle_tuliprox_error_result_list};
use crate::error::{TuliproxError, TuliproxErrorKind};
use crate::foundation::filter::{apply_templates_to_pattern, apply_templates_to_pattern_single};
use crate::model::{ItemField, PatternTemplate, TemplateValue};
use crate::{create_tuliprox_error, handle_tuliprox_error_result_list};
use regex::Regex;
fn compile_regex_vec(patterns: Option<&Vec<String>>) -> Result<Option<Vec<Regex>>, TuliproxError> {
patterns.as_ref()
@@ -16,7 +16,7 @@ fn compile_regex_vec(patterns: Option<&Vec<String>>) -> Result<Option<Vec<Regex>
.transpose() // convert Option<Result<...>> to Result<Option<...>>
}
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
pub enum SortOrder {
#[serde(rename = "asc")]
Asc,
@@ -34,8 +34,14 @@ pub struct ConfigSortGroupDto {
pub t_sequence: Option<Vec<Regex>>,
}
impl ConfigSortGroupDto {
impl PartialEq for ConfigSortGroupDto {
fn eq(&self, other: &Self) -> bool {
self.order == other.order
&& self.sequence == other.sequence
}
}
impl ConfigSortGroupDto {
pub fn prepare(&mut self, templates: Option<&Vec<PatternTemplate>>) -> Result<(), TuliproxError> {
let processed_sequence = match (&self.sequence, templates) {
(Some(seqs), Some(_templs)) => {
@@ -47,7 +53,7 @@ impl ConfigSortGroupDto {
}
}
Some(result)
},
}
(Some(seqs), None) => Some(seqs.clone()),
(None, _) => None,
};
@@ -71,6 +77,16 @@ pub struct ConfigSortChannelDto {
pub t_sequence: Option<Vec<Regex>>,
}
impl PartialEq for ConfigSortChannelDto {
fn eq(&self, other: &Self) -> bool {
self.field == other.field
&& self.group_pattern == other.group_pattern
&& self.order == other.order
&& self.sequence == other.sequence
}
}
impl ConfigSortChannelDto {
pub fn prepare(&mut self, templates: Option<&Vec<PatternTemplate>>) -> Result<(), TuliproxError> {
self.group_pattern = apply_templates_to_pattern_single(&self.group_pattern, templates)?;
@@ -90,7 +106,7 @@ impl ConfigSortChannelDto {
}
}
Some(result)
},
}
(Some(seqs), None) => Some(seqs.clone()),
(None, _) => None,
};
@@ -101,7 +117,7 @@ impl ConfigSortChannelDto {
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ConfigSortDto {
#[serde(default)]
+7 -7
View File
@@ -3,7 +3,7 @@ use crate::{create_tuliprox_error_result, handle_tuliprox_error_result_list, inf
use crate::foundation::filter::{get_filter, Filter};
use crate::model::{ClusterFlags, ConfigRenameDto, ConfigSortDto, PatternTemplate, ProcessingOrder, StrmExportStyle, TargetType, TraktConfigDto};
use crate::utils::{default_as_true, default_resolve_delay_secs, default_as_default};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ConfigTargetOptions {
#[serde(default)]
@@ -17,7 +17,7 @@ pub struct ConfigTargetOptions {
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct XtreamTargetOutputDto {
#[serde(default = "default_as_true")]
@@ -46,7 +46,7 @@ impl XtreamTargetOutputDto {
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct M3uTargetOutputDto {
#[serde(skip_serializing_if = "Option::is_none")]
@@ -57,7 +57,7 @@ pub struct M3uTargetOutputDto {
pub mask_redirect_url: bool,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct StrmTargetOutputDto {
pub directory: String,
@@ -74,7 +74,7 @@ pub struct StrmTargetOutputDto {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strm_props: Option<Vec<String>>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct HdHomeRunTargetOutputDto {
pub device: String,
@@ -83,7 +83,7 @@ pub struct HdHomeRunTargetOutputDto {
pub use_output: Option<TargetType>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(deny_unknown_fields, tag = "type", rename_all = "lowercase")]
pub enum TargetOutputDto {
Xtream(XtreamTargetOutputDto),
@@ -103,7 +103,7 @@ impl TargetOutputDto {
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ConfigTargetDto {
#[serde(skip)]
+3 -3
View File
@@ -21,7 +21,7 @@ impl Default for TraktContentType {
}
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct TraktApiConfigDto {
#[serde(default)]
@@ -44,7 +44,7 @@ impl TraktApiConfigDto {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct TraktListConfigDto {
pub user: String,
@@ -55,7 +55,7 @@ pub struct TraktListConfigDto {
pub fuzzy_match_threshold: u8, // Percentage (0-100)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct TraktConfigDto {
#[serde(default)]
+21 -3
View File
@@ -107,8 +107,8 @@
"BUILD_TIME": "Build time",
"MEMORY": "Memory",
"ACTIVE_USERS": "Active users",
"ACTIVE_USER_CONNECTIONS": "Active user conn...",
"ACTIVE_PROVIDER_CONNECTIONS": "Active provider conn...",
"ACTIVE_USER_CONNECTIONS": "Active user connections",
"ACTIVE_PROVIDER_CONNECTIONS": "Active provider connections",
"IP_CHECK": "IP Check",
"URL_IPV4": "Url IPv4",
"URL_IPV6": "Url IPv6",
@@ -130,7 +130,25 @@
"DOCUMENTATION": "Documentation",
"DOCUMENTATION_CONTENT": "Get started with tuliprox: features, setup, and examples",
"OPEN_DOCUMENTATION": "Documentation",
"NOT_ACTIVATED": "Feature is not activated"
"NOT_ACTIVATED": "Feature is not activated",
"CREATE": "Create",
"PLAYLIST": "Playlist",
"PLAYLISTS": "Playlists",
"LIST": "List",
"NEW_PLAYLIST": "New Playlist"
},
"TABLE": {
"ID": "Id",
"ENABLED": "Enabled",
"NAME": "Name",
"OPTIONS": "Options",
"SORT": "Sort",
"FILTER": "Filter",
"OUTPUT": "Output",
"RENAME": "Rename",
"MAPPING": "Mapping",
"PROCESSING_ORDER": "Processing order",
"WATCH": "Watch"
},
"TITLE": {
"USER_BOUQUET_EDITOR": "User group editor"
+24
View File
@@ -89,6 +89,12 @@
],
"path": "M8 5v14l11-7z"
},
{
"keys": [
"PlayArrowOutline"
],
"path": "m 6.9091055,7.1999977 7.6654235,4.7999983 -7.6654235,4.8 z M 4,2 V 22 L 20,11.999996 Z"
},
{
"keys": [
"ArrowLeft"
@@ -185,6 +191,12 @@
],
"path": "M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"
},
{
"keys": [
"UserOutline"
],
"path": "m 12,4.5 c 1.375,0 2.5,1.125 2.5,2.5 0,1.375 -1.125,2.5 -2.5,2.5 C 10.625,9.5 9.5,8.375 9.5,7 9.5,5.625 10.625,4.5 12,4.5 M 12,17 c 3.375,0 7.25,1.6125 7.5,2.5 H 4.5 C 4.7875,18.6 8.6375,17 12,17 M 12,2 C 9.2375,2 7,4.2375 7,7 c 0,2.7625 2.2375,5 5,5 2.7625,0 5,-2.2375 5,-5 0,-2.7625 -2.2375,-5 -5,-5 m 0,12.5 c -3.3375,0 -10,1.675 -10,5 V 22 h 20 v -2.5 c 0,-3.325 -6.6625,-5 -10,-5"
},
{
"keys": [
"Refresh"
@@ -299,6 +311,10 @@
"keys": ["Dashboard"],
"path": "M11 21H5c-1.1 0-2-.9-2-2V5c0-1.1.9-2 2-2h6zm2 0h6c1.1 0 2-.9 2-2v-7h-8zm8-11V5c0-1.1-.9-2-2-2h-6v7z"
},
{
"keys": ["DashboardOutline"],
"path": "M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2M5 19V5h6v14zm14 0h-6v-7h6zm0-9h-6V5h6z"
},
{
"keys": ["Settings"],
"path": "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6"
@@ -318,5 +334,13 @@
{
"keys": ["Router", "Network"],
"path": "m20.2 5.9.8-.8C19.6 3.7 17.8 3 16 3s-3.6.7-5 2.1l.8.8C13 4.8 14.5 4.2 16 4.2s3 .6 4.2 1.7m-.9.8c-.9-.9-2.1-1.4-3.3-1.4s-2.4.5-3.3 1.4l.8.8c.7-.7 1.6-1 2.5-1s1.8.3 2.5 1zM19 13h-2V9h-2v4H5c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-4c0-1.1-.9-2-2-2M8 18H6v-2h2zm3.5 0h-2v-2h2zm3.5 0h-2v-2h2z"
},
{
"keys": ["PlaylistAdd"],
"path": "M 13.578946,8.2857137 H 2 V 10.428572 H 13.578946 Z M 13.578946,4 H 2 v 2.1428548 h 11.578946 z m 4.210508,8.571428 V 8.2857137 h -2.105235 v 4.2857143 h -4.210547 v 2.142859 h 4.210547 V 19 h 2.105235 V 14.714287 H 22 V 12.571428 Z M 2,14.714287 H 9.368438 V 12.571428 H 2 Z"
},
{
"keys": ["Playlist"],
"path": "M 2,8.2666668 H 13.578948 V 10.4 H 2 Z M 2,4 H 13.578948 V 6.1333336 H 2 Z m 0,8.533334 h 7.3684211 v 2.133333 H 2 Z M 15.684211,11.466667 V 20 L 22,15.733334 Z"
}
]
+36
View File
@@ -0,0 +1,36 @@
.tp__action-card {
display: flex;
flex-flow: row nowrap;
align-content: flex-start;
flex: 1 1 auto;
gap: var(--gap-default);
fill: var(--card-color);
&__icon {
fill: inherit;
width: 3rem;
transform: translateY(-0.5rem);
img {
width: 3rem;
}
}
&__body {
display: flex;
flex-flow: column;
align-content: flex-start;
flex: 1 1 auto;
gap: var(--gap-small);
}
&__title {
color: var(--text-color);
font-weight: bold;
font-size: 1.1rem;
}
&__content {
}
}
+3 -3
View File
@@ -1,6 +1,4 @@
@use 'common';
.app {
.tp__app {
display: flex;
flex-flow: row;
overflow: hidden;
@@ -16,6 +14,8 @@
flex: 1 1 auto;
align-content: flex-start;
justify-content: flex-start;
overflow: hidden;
&__body {
display: flex;
flex-flow: column;
+2 -3
View File
@@ -1,5 +1,4 @@
.app-header {
.tp__app-header {
display: flex;
align-items: center;
justify-content: space-between;
@@ -16,7 +15,7 @@
display: flex;
align-items: center;
gap: var(--gap-small);
.icon-button {
.tp__icon-button {
width: 2.5rem;
}
}
+53
View File
@@ -0,0 +1,53 @@
.tp__breadcrumbs {
display: flex;
flex-flow: row;
align-items: center;
gap: 4px;
border-radius: 5px;
box-sizing: border-box;
ol {
display: flex;
flex-flow: row;
list-style-type: none;
padding: 0;
margin: 0;
align-items: center;
box-sizing: border-box;
li {
display: flex;
flex-flow: row;
align-items: center;
box-sizing: border-box;
}
.tp__breadcrumbs__icon {
display: flex;
flex-flow: row;
align-items: center;
box-sizing: border-box;
fill: var(--modest-text-color);
svg {
width: 1.2rem;
}
}
}
span {
box-sizing: border-box;
color: var(--modest-text-color);
}
&__active {
pointer-events: none;
}
&__selectable {
cursor: pointer;
&:hover {
color: var(--text-color);
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
.card {
.tp__card {
display: flex;
flex-flow: column;
background-color: var(--card-background-color);
+1 -1
View File
@@ -1,4 +1,4 @@
.collapse-panel {
.tp__collapse-panel {
display: flex;
flex-flow: column;
gap: 8px;
+3 -102
View File
@@ -1,105 +1,6 @@
$media-size-mobile: 600px;
@mixin container_common($justify-content: flex-start, $padding: 12px) {
display: flex;
justify-content: $justify-content;
gap: 8px;
box-sizing: border-box;
@media (max-width: $media-size-mobile) {
padding: $padding;
}
@media (min-width: $media-size-mobile) {
padding: 2px;
}
}
@mixin container_vert() {
flex-flow: column;
@include container_common();
}
@mixin container_horz() {
flex-flow: row;
@include container_common();
}
@mixin container_horz_nowrap($justify-content: flex-start, $padding: 12px) {
flex-flow: row nowrap;
@media (max-width: $media-size-mobile) {
flex-flow: column;
}
@include container_common($justify-content, $padding);
}
@mixin container_horz_no_padding() {
flex-flow: row;
@include container_common($padding: 0);
}
@mixin container_vert_no_padding($justify-content: flex-start) {
flex-flow: column;
@include container_common($justify-content, $padding: 0);
}
@mixin activeOptionButton {
box-sizing: border-box !important;
background-color: var(--option-active-background-color) !important;
color: var(--option-active-color) !important;
font-weight: bold;
svg {
color: var(--option-active-color) !important;
}
}
@mixin textButton {
border: 1px solid var(--text-button-border-color);
font-size: 1.2rem;
border-radius: var(--text-button-border-radius);
padding: 8px 16px;
background-color: var(--text-button-background-color);
color: var(--text-button-color);
&:focus,
&:hover {
background-color: var(--text-button-hover-background-color);
color: var(--text-button-hover-color);
border: 1px solid transparent;
outline: none;
}
}
@mixin list {
ul {
list-style-type: none; /* Remove bullets */
padding: 0; /* Remove padding */
margin: 0; /* Remove margins */
li {
padding: 8px;
border-radius: var(--border-radius);
}
}
}
@mixin list-hover {
ul {
list-style-type: none; /* Remove bullets */
padding: 0; /* Remove padding */
margin: 0; /* Remove margins */
li {
padding: 8px;
border-radius: var(--border-radius);
user-select: none;
cursor: pointer;
&:hover {
color: var(--popup-hover-color);
background-color: var(--popup-hover-background-color);
}
}
}
}
@keyframes pulse-bg {
@keyframes tp__pulse-bg {
0% {
background-color: var(--pulse-color-1);
}
@@ -111,6 +12,6 @@ $media-size-mobile: 600px;
}
}
.pulse {
animation: pulse-bg 500ms ease-out;
.tp__pulse {
animation: tp__pulse-bg 500ms ease-out;
}
+7
View File
@@ -2,12 +2,19 @@
@forward "menu_item";
@forward "icon_button";
@forward "text_button";
@forward "toggle_switch";
@forward "table";
@forward "app_header";
@forward "app";
@forward "panel";
@forward "card";
@forward "action_card";
@forward "status_card";
@forward "collapse_panel";
@forward "breadcrumbs";
@forward "login";
@forward "sidebar";
@forward "preferences";
@forward "dashboard_view";
@forward "playlist_view";
@forward "playlist_assistant";
+3 -56
View File
@@ -1,6 +1,6 @@
$dashboard-mobile-size: 1280px;
.dashboard {
.tp__dashboard {
display: flex;
flex-flow: column;
box-sizing: border-box;
@@ -39,64 +39,11 @@ $dashboard-mobile-size: 1280px;
}
}
.action-card {
display: flex;
flex-flow: row nowrap;
align-content: flex-start;
flex: 1 1 auto;
gap: var(--gap-default);
fill: var(--card-color);
&__icon {
fill: inherit;
width: 3rem;
transform: translateY(-0.5rem);
img {
width: 3rem;
}
}
&__body {
display: flex;
flex-flow: column;
align-content: flex-start;
flex: 1 1 auto;
gap: var(--gap-small);
}
&__title {
color: var(--text-color);
font-weight: bold;
font-size: 1.1rem;
}
&__content {
}
}
.status-card {
display: flex;
flex-flow: column;
gap: var(--gap-default);
&__title {
color: var(--modest-text-color);
}
&__body {
color: var(--text-color);
font-size: 1.1rem;
}
&__footer {
color: var(--modest-text-color);
}
}
.discord {
.tp__discord {
fill: var(--discord-color);
}
.documentation {
.tp__documentation {
fill: var(--documenation-color);
svg {
+1 -47
View File
@@ -21,47 +21,13 @@ button {
cursor: pointer;
}
.hidden {
.tp__hidden {
display: none !important;
}
.vert-group {
display: flex;
flex-flow: column;
gap: 20px;
overflow: hidden;
padding: 0 4px 4px 0;
}
select {
padding: 2px 4px;
}
//
//.tool-button {
// display: flex;
// align-items: center;
// justify-content: center;
// height: 1.1rem;
// width: 1.1rem;
// border-radius: var(--border-radius);
// background-color: var(--tool-button-background-color);
// color: var(--tool-button-color);
// cursor: pointer;
//
// svg {
// width: 1rem;
// height: 1rem;
// }
//
// &:hover {
// color: var(--tool-button-hover-color);
// background-color: var(--tool-button-hover-background-color);
// }
//
// &:active {
// transform: scale(0.9);
// }
//}
:not(input[type="text"]) {
caret-color: transparent;
@@ -69,18 +35,6 @@ select {
input[type="text"] {
caret-color: auto;
}
//
//select {
// height: 2rem;
// border-radius: var(--border-radius);
// font-size: 1.2rem;
// padding: 0 8px;
// cursor: pointer;
// &:hover {
// color: var(--text-button-hover-color);
// background-color: var(--text-button-hover-background-color);
// }
//}
option {
cursor: pointer;
+1 -1
View File
@@ -1,4 +1,4 @@
.icon-button {
.tp__icon-button {
display: flex;
justify-content: center;
align-items: center;
+1 -1
View File
@@ -1,4 +1,4 @@
.input {
.tp__input {
display: flex;
flex-flow: column;
overflow: hidden;
+1 -1
View File
@@ -1,6 +1,6 @@
@use 'common';
.login-view {
.tp__login-view {
display: flex;
flex-flow: column;
gap: 8px;
+1 -1
View File
@@ -1,4 +1,4 @@
.menu-item {
.tp__menu-item {
display: flex;
flex-flow: row nowrap;
align-items: center;
+2 -6
View File
@@ -1,10 +1,6 @@
.panel {
.tp__panel {
display: flex;
flex-flow: column;
gap: 8px;
overflow: hidden;
padding: 60px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background-color: var(--card-background-color);
background-color: transparent;
}
+3
View File
@@ -0,0 +1,3 @@
.tp__playlist-assistant {
}
+39
View File
@@ -0,0 +1,39 @@
.tp__playlist-view {
display: flex;
flex-flow: column;
box-sizing: border-box;
flex: 1 1 auto;
width: 100%;
max-width: 1280px;
padding: var(--padding-default) 0;
gap: var(--gap-default);
&__body {
display: flex;
flex-flow: column;
box-sizing: border-box;
flex: 1 1 auto;
width: 100%;
}
}
.tp__playlist-create,
.tp__playlist-list {
display: flex;
flex-flow: column;
box-sizing: border-box;
flex: 1 1 auto;
width: 100%;
max-width: 1280px;
gap: var(--gap-default);
&__header {
display: flex;
flex-flow: row nowrap;
box-sizing: border-box;
flex: 1 1 auto;
width: 100%;
justify-content: space-between;
align-items: center;
}
}
+1 -71
View File
@@ -1,94 +1,24 @@
@use 'common';
@mixin prefsRoot {
@include common.container_vert_no_padding(flex-start);
align-content: flex-start;
overflow: hidden;
}
@mixin prefsInput {
color: var(--text-color);
background-color: transparent;
padding: 0.2rem 0.5rem;
flex: 1 1 auto;
border: 1px solid var(--border-color);
&:focus,
&:hover {
outline: none;
}
}
@mixin prefsToolbar() {
&__toolbar {
display: flex;
flex-flow: row wrap;
flex: 0 0 auto;
justify-content: flex-end;
align-items: center;
gap: var(--gap-default);
background-color: var(--toolbar-bg-color);
padding: 8px 16px;
label {
flex: 1 1 auto;
font-size: 2rem;
font-weight: bold;
color: var(--title-label-color);
}
button {
@include common.textButton();
}
}
}
@mixin prefsForm() {
&__content {
display: flex;
flex-flow: column;
&-help {
display: flex;
flex-flow: column;
gap:12px;
border-top: 1px solid var(--border-light-color);
padding-top: 12px;
color: var(--label-color);
pre {
margin: 0;
}
}
}
}
.preferences {
@include common.container_horz_no_padding();
.tp__preferences {
overflow: hidden;
flex: 1 1 auto;
&__content {
@include common.container_horz_nowrap(flex-start, 0);
flex: 1 1 auto;
overflow: hidden;
gap: 0;
}
&__panels {
@include common.container_vert_no_padding(flex-start);
overflow: hidden;
flex: 1 1 auto;
.panel {
@include common.container_vert();
flex: 1 1 auto;
overflow: hidden;
.card {
@include common.container_vert();
flex: 1 1 auto;
overflow: hidden;
}
+1 -1
View File
@@ -2,7 +2,7 @@
$sidebar-width: 270px;
.app-sidebar {
.tp__app-sidebar {
display: flex;
flex-flow: column;
gap: var(--gap-default);
+15
View File
@@ -0,0 +1,15 @@
.tp__status-card {
display: flex;
flex-flow: column;
gap: var(--gap-default);
&__title {
color: var(--modest-text-color);
}
&__body {
color: var(--text-color);
font-size: 1.1rem;
}
&__footer {
color: var(--modest-text-color);
}
}
+33
View File
@@ -0,0 +1,33 @@
.tp__table {
display: flex;
flex-flow: column;
gap: var(--gap-default);
padding: var(--padding-default) 0;
background-color: var(--card-background-color);
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
overflow: hidden;
&__container {
display: flex;
flex-flow: column;
overflow-x: auto;
}
&__table {
width: 100%;
border-collapse: collapse;
thead {
tr {
th {
font-size: 1.1rem;
font-weight: bold;
text-align: center;
padding: 0.875rem;
background-color: var(--table-header-background-color);
}
}
}
}
}
+4 -2
View File
@@ -1,4 +1,4 @@
.text-button {
.tp__text-button {
display: flex;
justify-content: center;
align-items: center;
@@ -32,12 +32,14 @@
}
}
.text-button.button-primary {
.tp__text-button.tp__button-primary {
background-color: var(--text-button-primary-background-color);
color: var(--text-button-primary-color);
fill : var(--text-button-primary-color);
&:hover {
background-color: var(--text-button-primary-hover-background-color);
color: var(--text-button-primary-hover-color);
fill : var(--text-button-primary-hover-color);
outline: none;
}
}
+11 -3
View File
@@ -8,6 +8,8 @@
--padding-default: 1.5rem;
--padding-large: 2rem;
--primary-color: #124a9b;
--action-item-background-color: #242427;
--text-color: #ffffff;
@@ -17,12 +19,12 @@
--input-border-color: #454548;
--input-focus-border-color: #12499a;
--input-autofill-background-color: #323a48;
--button-background-color: #124a9b;
--button-background-color: var(--primary-color);
--border-color: #222224;
--menu-text-color: #e2e2e5;
--menu-icon-color: #707079;
--menu-item-hover-background-color: var(--action-item-background-color);
--menu-text-active-color: #124a9b;
--menu-text-active-color: var(--primary-color);
--menu-item-active-background-color: var(--menu-item-hover-background-color);
--icon-button-hover-color: #8f8f97;
--card-background-color: #18181b;
@@ -32,11 +34,17 @@
--text-button-color: #ffffff;
--text-button-border-color: #2F2F31;
--text-button-hover-color: #303032;
--text-button-primary-background-color: #124a9b;
--text-button-primary-background-color: var(--primary-color);
--text-button-primary-color: #ffffff;
--text-button-primary-hover-background-color: #2f68be;
--text-button-primary-hover-color: #ffffff;
--toggle-switch-on-background-color: var(--primary-color);
--toggle-switch-off-background-color: #888888;
--toggle-switch-toggle-color: #ffffff;
--table-header-background-color: #242427;
--discord-color: #5764f0;
--documenation-color: #2b944b;
+44
View File
@@ -0,0 +1,44 @@
$toggle-switch-width: 3.2rem;
$toggle-switch-height: 1.6rem;
$toggle-switch-toggle-padding: 3px;
$toggle-switch-toggle-width: calc($toggle-switch-height - $toggle-switch-toggle-padding * 2);
$toggle-switch-toggle-end-pos: calc($toggle-switch-width - ($toggle-switch-toggle-width + $toggle-switch-toggle-padding * 2));
.tp__toggle-switch {
position: relative;
display: inline-block;
width: $toggle-switch-width;
height: $toggle-switch-height;
input {
opacity: 0; width: 0; height: 0
}
input:checked + span {
background-color: var(--toggle-switch-on-background-color);
}
input:checked + span > span {
transform: translateX($toggle-switch-toggle-end-pos);
}
&__track {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background-color: var(--toggle-switch-off-background-color);
border-radius: $toggle-switch-toggle-width;
transition: 0.3s;
}
&__toggle {
position: absolute;
height: $toggle-switch-toggle-width;
width: $toggle-switch-toggle-width;
left: $toggle-switch-toggle-padding;
bottom: $toggle-switch-toggle-padding;
background-color: var(--toggle-switch-toggle-color);
border-radius: 50%;
transition: 0.3s;
transform: translateX(0);
}
&__active {
}
}
+65
View File
@@ -0,0 +1,65 @@
use std::rc::Rc;
use std::str::FromStr;
use wasm_bindgen::JsCast;
use yew::prelude::*;
use crate::app::components::AppIcon;
#[derive(Properties, PartialEq, Clone)]
pub struct BreadcrumbsProps {
pub items: Rc<Vec<String>>,
#[prop_or_default]
pub onclick: Callback<(String, usize)>,
}
#[function_component]
pub fn Breadcrumbs(props: &BreadcrumbsProps) -> Html {
let len = props.items.len();
let handle_click = {
let click = props.onclick.clone();
Callback::from(move |e: MouseEvent| {
e.prevent_default();
if let Some(target) = e.target() {
if let Ok(element) = target.dyn_into::<web_sys::Element>() {
if let Some(data_name) = element.get_attribute("data-name") {
if let Some(data_index) = element.get_attribute("data-index") {
if let Ok(index) = usize::from_str(&data_index) {
click.emit((data_name, index));
}
}
}
}
}
})
};
html! {
<nav class="tp__breadcrumbs" aria-label="Breadcrumb">
<ol>
{ for props.items.iter().enumerate().map(|(i, item)| {
html! {
<li>
{
if i > 0 {
html! { <span class="tp__breadcrumbs__icon"><AppIcon name="ChevronRight"/></span> }
} else {
html! {}
}
}
{
if i < len -1 {
html! { <span class="tp__breadcrumbs__selectable"
data-index={i.to_string()}
data-name={ item.to_string() }
onclick={ handle_click.clone() }>{ &item }</span>
}
} else {
html! { <span class="tp__breadcrumbs__active">{ &item }</span> }
}
}
</li>
}
})}
</ol>
</nav>
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ pub fn Card(props: &CardProps) -> Html {
};
html! {
<ContextProvider<CardContext> context={context}>
<div class={classes!("card", &*custom_class)}>
<div class={classes!("tp__card", &*custom_class)}>
{ for props.children.iter() }
</div>
</ContextProvider<CardContext>>
+3 -3
View File
@@ -19,13 +19,13 @@ pub fn CollapsePanel(props: &CollapsePanelProps) -> Html {
};
html! {
<div class="collapse-panel">
<div class="collapse-panel__header" onclick={toggle}>
<div class="tp__collapse-panel">
<div class="tp__collapse-panel__header" onclick={toggle}>
<span>{ props.title.clone() }</span>
<AppIcon name={ if *expanded { "ChevronUp" } else {"ChevronDown"} }/>
</div>
if *expanded {
<div class="collapse-panel__body">
<div class="tp__collapse-panel__body">
{ for props.children.iter() }
</div>
}
@@ -22,15 +22,15 @@ pub struct ActionProps {
pub fn ActionCard(props: &ActionProps) -> Html {
html! {
<div class={if props.classname.is_empty() {"action-card".to_string()} else {format!("action-card {}", props.classname)}}>
<div class="action-card__icon">
<div class={classes!("tp__action-card", if props.classname.is_empty() {String::new()} else {props.classname.to_string()})}>
<div class="tp__action-card__icon">
<AppIcon name={props.icon.clone()} />
</div>
<div class="action-card__body">
<span class="action-card__title">
<div class="tp__action-card__body">
<span class="tp__action-card__title">
{props.title.clone()}
</span>
<span class="action-card__content">
<span class="tp__action-card__content">
{ props.subtitle.clone() }
{
if !props.subtitle_html.is_empty() {
@@ -22,7 +22,7 @@ pub fn DiscordActionCard() -> Html {
};
html! {
<ActionCard icon="Discord" classname="discord" title={translate.t("LABEL.DISCORD")}
<ActionCard icon="Discord" classname="tp__discord" title={translate.t("LABEL.DISCORD")}
subtitle={translate.t("LABEL.JOIN_ON_DISCORD")}>
<TextButton name="discord" title={translate.t("LABEL.OPEN_DISCORD")} icon="Link" onclick={handle_url} />
</ActionCard>
@@ -21,7 +21,7 @@ pub fn DocumentationActionCard() -> Html {
};
html! {
<ActionCard icon="Book" classname="documentation" title={translate.t("LABEL.DOCUMENTATION")}
<ActionCard icon="Book" classname="tp__documentation" title={translate.t("LABEL.DOCUMENTATION")}
subtitle={translate.t("LABEL.DOCUMENTATION_CONTENT")}>
<TextButton name="documentation" title={translate.t("LABEL.OPEN_DOCUMENTATION")} icon="Link" onclick={handle_url} />
</ActionCard>
@@ -24,7 +24,7 @@ pub fn IpinfoActionCard() -> Html {
if exists {
let card_ctx_2 = card_ctx.clone();
if let Some(card_context) = card_ctx_1.as_ref() {
card_context.custom_class.set("pulse".to_string());
card_context.custom_class.set("tp__pulse".to_string());
}
let services_ctx = services_ctx.clone();
let ip_address_state = ip_address_state.clone();
@@ -87,7 +87,7 @@ pub fn IpinfoActionCard() -> Html {
}
html! {
<ActionCard icon="Network" classname="ipinfo" title={translate.t("LABEL.IP_INFO")}
<ActionCard icon="Network" classname="tp__ipinfo" title={translate.t("LABEL.IP_INFO")}
subtitle_html={if *config_exists {
if let Some(ip_info) = &*ip_address {
let ip4 = ip_info.ipv4.as_ref().map_or_else(|| "n/a".to_string(), |ip| ip.to_string());
@@ -14,16 +14,15 @@ pub struct StatusCardProps {
#[function_component]
pub fn StatusCard(props: &StatusCardProps) -> Html {
html! {
<div class={if props.classname.is_empty() {"status-card".to_string()} else {format!("status-card {}", props.classname)}}>
<span class="status-card__title">
<div class={classes!("tp__status-card", if props.classname.is_empty() { String::new() } else {props.classname.to_string()})}>
<span class="tp__status-card__title">
{props.title.clone()}
</span>
<div class="status-card__body">
<div class="tp__status-card__body">
{props.data.clone()}
</div>
<div class="status-card__footer">
<div class="stp__tatus-card__footer">
{props.footer.clone()}
</div>
</div>
+5 -5
View File
@@ -40,12 +40,12 @@ pub fn DashboardView() -> Html {
}
html! {
<div class="dashboard">
<div class="dashboard__header">
<div class="tp__dashboard">
<div class="tp__dashboard__header">
<h1>{ translate.t("LABEL.DASHBOARD")}</h1>
</div>
<div class="dashboard__body">
<div class="dashboard__body-actions">
<div class="tp__dashboard__body">
<div class="tp__dashboard__body-actions">
<Card>
<VersionActionCard version={(*status).as_ref().map_or_else(String::new, |s| s.version.clone())}
build_time={(*status).as_ref().map_or_else(String::new, |s| s.build_time.as_ref().map_or_else(String::new, |v| v.clone()))}/>
@@ -55,7 +55,7 @@ pub fn DashboardView() -> Html {
<Card><DiscordActionCard /></Card>
<Card><IpinfoActionCard /></Card>
</div>
<div class="dashboard__body-stats">
<div class="tp__dashboard__body-stats">
<Card><StatusCard title={translate.t("LABEL.MEMORY")} data={status.as_ref().map_or_else(|| "n/a".to_string(), |status| status.memory.clone())} /></Card>
<Card><StatusCard title={translate.t("LABEL.CACHE")} data={status.as_ref().map_or_else(|| "n/a".to_string(), |status| status.cache.as_ref().map_or_else(|| "n/a".to_string(), |c| c.clone()))} /></Card>
<Card><StatusCard title={translate.t("LABEL.ACTIVE_USERS")} data={status.as_ref().map_or_else(|| "n/a".to_string(), |status| status.active_users.to_string())} /></Card>
+8 -7
View File
@@ -1,5 +1,5 @@
use yew::prelude::*;
use crate::app::components::{IconButton, Sidebar, DashboardView};
use crate::app::components::{IconButton, Sidebar, DashboardView, PlaylistView};
use crate::model::ViewType;
use crate::hooks::use_service_context;
@@ -8,7 +8,7 @@ pub fn Home() -> Html {
let services = use_service_context();
let app_title = services.config.ui_config.app_title.as_ref().map_or("tuliprox", |v| v.as_str());
let view_visible = use_state(|| ViewType::Dashboard);
let view_visible = use_state(|| ViewType::Playlists);
let handle_logout = {
let services_ctx = services.clone();
@@ -24,13 +24,13 @@ pub fn Home() -> Html {
// <div class={"app-header__toolbar"}><button data-tooltip={preferencesVisible ? "LABEL.PLAYLIST_BROWSER" : "LABEL.CONFIGURATION"} onClick={handlePreferences}>{getIconByName(preferencesVisible ? "Live" : "Config")}</button></div>
html! {
<div class="app">
<div class="tp__app">
<Sidebar onview={handle_view_change}/>
<div class="app-main">
<div class="app-main__header app-header">
<div class="tp__app-main">
<div class="tp__app-main__header tp__app-header">
{app_title}
<div class={"app-header-toolbar"}>
<div class={"tp__app-header-toolbar"}>
// <select onchange={handle_language} defaultValue={i18next.language}>{
// services.config().getUiConfig().languages.map(l => <option key={l} value={l}>{l}</option>)
// }</select>
@@ -47,10 +47,11 @@ pub fn Home() -> Html {
<IconButton name="Logout" icon="Logout" onclick={handle_logout} />
</div>
</div>
<div class="app-main__body">
<div class="tp__app-main__body">
{
match *view_visible {
ViewType::Dashboard => html! { <DashboardView/> },
ViewType::Playlists => html! { <PlaylistView/> },
_ => html! { <div>{"Unknown view"}</div> },
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ pub fn IconButton(props: &IconButtonProps) -> Html {
};
html! {
<button class="icon-button" onclick={handle_click}>
<button class="tp__icon-button" onclick={handle_click}>
<AppIcon name={props.icon.clone()}></AppIcon>
</button>
}
+2 -2
View File
@@ -22,14 +22,14 @@ pub struct InputProps {
#[function_component]
pub fn Input(props: &InputProps) -> Html {
html! {
<div class="input">
<div class="tp__input">
{ if props.label.is_some() {
html! {
<label>{props.label.clone().unwrap_or_default()}</label>
}
} else { html!{} }
}
<div class="input-wrapper">
<div class="tp__input-wrapper">
<input ref={props.input_ref.clone().unwrap_or_default()}
type={props.input_type.clone()}
name={props.name.clone()}
+8 -8
View File
@@ -67,18 +67,18 @@ pub fn Login() -> Html {
};
html! {
<div class="login-view">
<div class={"login-view__header"}>
<div class={"login-view__header-logo"}>{app_logo.as_ref().clone()}</div>
<div class={"login-view__header-title"}>{ format!("Login to {app_title}") }</div>
<div class="tp__login-view">
<div class={"tp__login-view__header"}>
<div class={"tp__login-view__header-logo"}>{app_logo.as_ref().clone()}</div>
<div class={"tp__login-view__header-title"}>{ format!("Login to {app_title}") }</div>
</div>
<form>
<div class="login-view__form">
<div class="tp__login-view__form">
<Input label={translation.t("LABEL.USERNAME")} input_ref={username_ref} input_type="text" name="username" autocomplete={true} autofocus={true}/>
<Input label={translation.t("LABEL.PASSWORD")} input_ref={password_ref} input_type="password" name="password" autocomplete={true} onkeydown={handle_key_down}/>
<div class="login-view__form-action">
<TextButton style="primary" name="login" title={ translation.t("LABEL.LOGIN")} onclick={handle_login}></TextButton>
<span class={if *auth_success { "hidden" } else { "error-text" }}>{ "Failed to login" }</span>
<div class="tp__login-view__form-action">
<TextButton style="tp__primary" name="login" title={ translation.t("LABEL.LOGIN")} onclick={handle_login}></TextButton>
<span class={if *auth_success { "tp__hidden" } else { "error-text" }}>{ "Failed to login" }</span>
</div>
</div>
</form>
+1 -1
View File
@@ -24,7 +24,7 @@ pub fn MenuItem(props: &MenuItemProps) -> Html {
};
html! {
<div class="menu-item" onclick={ handle_click }>
<div class="tp__menu-item" onclick={ handle_click }>
<AppIcon name={props.icon.clone()}></AppIcon>
<label>{props.label.clone()}</label>
</div>
+10 -1
View File
@@ -13,6 +13,11 @@ mod dashboard_view;
mod dashboard;
mod card;
mod text_button;
mod playlist_view;
mod breadcrumbs;
mod playlist;
mod table;
mod toggle_switch;
pub use self::svg_icon::*;
// pub use self::input::*;
@@ -29,4 +34,8 @@ pub use self::sidebar::*;
pub use self::preferences::*;
pub use self::dashboard_view::*;
pub use self::dashboard::*;
pub use self::playlist_view::*;
pub use self::breadcrumbs::*;
pub use self::playlist::*;
pub use self::table::*;
pub use self::toggle_switch::*;
+1 -1
View File
@@ -10,7 +10,7 @@ pub struct PanelProps {
#[function_component]
pub fn Panel(props: &PanelProps) -> Html {
html! {
<div class={format!("panel{}", if props.value == props.active {""} else {" hidden"} ).to_lowercase()}>
<div class={classes!("panel", if props.value == props.active {""} else {"tp__hidden"} )}>
{ for props.children.iter() }
</div>
}
@@ -0,0 +1,12 @@
mod playlist;
mod playlist_assistant_context;
mod step_name;
mod step_type;
mod step_schedule;
pub use self::playlist_assistant_context::*;
pub use self::playlist::*;
pub use self::step_name::*;
pub use self::step_type::*;
pub use self::step_schedule::*;
@@ -0,0 +1,61 @@
use std::fmt;
use std::str::FromStr;
use yew::prelude::*;
use crate::app::components::{NameStep, Panel, PlaylistAssistantContext, TypeStep};
enum PlaylistAssistantStep {
Name,
Type,
Scheduling,
Processing
}
impl fmt::Display for PlaylistAssistantStep {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
PlaylistAssistantStep::Name => "name",
PlaylistAssistantStep::Type => "type",
PlaylistAssistantStep::Scheduling => "scheduling",
PlaylistAssistantStep::Processing => "processing",
};
write!(f, "{s}")
}
}
impl FromStr for PlaylistAssistantStep {
type Err = ();
fn from_str(s: &str) -> Result<Self, ()> {
match s {
"name" => Ok(PlaylistAssistantStep::Name),
"type" => Ok(PlaylistAssistantStep::Type),
"scheduling" => Ok(PlaylistAssistantStep::Scheduling),
"processing" => Ok(PlaylistAssistantStep::Processing),
_ => Err(())
}
}
}
#[function_component]
pub fn PlaylistAssistant() -> Html {
let active_step = use_state(|| PlaylistAssistantStep::Name);
let custom_class = use_state(|| String::new());
let context = PlaylistAssistantContext {
custom_class: custom_class.clone(),
};
html! {
<ContextProvider<PlaylistAssistantContext> context={context}>
<div class="tp__playlist-assistant">
<Panel value={PlaylistAssistantStep::Name.to_string()} active={active_step.to_string()}>
<NameStep/>
</Panel>
<Panel value={PlaylistAssistantStep::Type.to_string()} active={active_step.to_string()}>
<TypeStep/>
</Panel>
</div>
</ContextProvider<PlaylistAssistantContext >>
}
}
@@ -0,0 +1,6 @@
use yew::UseStateHandle;
#[derive(Clone, PartialEq)]
pub struct PlaylistAssistantContext {
pub custom_class: UseStateHandle<String>,
}
@@ -0,0 +1,12 @@
use yew::prelude::*;
use crate::app::components::PlaylistAssistantContext;
#[function_component]
pub fn NameStep() -> Html {
let _playlist_ctx = use_context::<PlaylistAssistantContext>();
html! {
<div class={"tp__playlist-assistant__step-name"}>
</div>
}
}
@@ -0,0 +1,12 @@
use yew::prelude::*;
use crate::app::components::PlaylistAssistantContext;
#[function_component]
pub fn ScheduleStep() -> Html {
let _playlist_ctx = use_context::<PlaylistAssistantContext>();
html! {
<div class={"tp__playlist-assistant__step-schedule"}>
</div>
}
}
@@ -0,0 +1,12 @@
use yew::prelude::*;
use crate::app::components::PlaylistAssistantContext;
#[function_component]
pub fn TypeStep() -> Html {
let _playlist_ctx = use_context::<PlaylistAssistantContext>();
html! {
<div class={"tp__playlist-assistant__step-type"}>
</div>
}
}
@@ -0,0 +1,7 @@
use yew::UseStateHandle;
use crate::app::components::PlaylistPage;
#[derive(Clone, PartialEq)]
pub struct PlaylistContext {
pub active_page: UseStateHandle<PlaylistPage>,
}
@@ -0,0 +1,33 @@
use wasm_logger::Config;
use yew::prelude::*;
use yew_i18n::use_translation;
use crate::app::components::{PlaylistContext, PlaylistPage, TextButton};
#[function_component(PlaylistCreate)]
pub fn playlist_create() -> Html {
let translate = use_translation();
let playlist_ctx = use_context::<PlaylistContext>();
let handle_back = {
let playlist_ctx = playlist_ctx.clone();
Callback::from(move |_| {
if let Some(ctx) = playlist_ctx.as_ref() {
ctx.active_page.set(PlaylistPage::Lists);
}
})
};
html! {
<div class="tp__playlist-create">
<div class="tp__playlist-create__header">
<h1>{ translate.t("LABEL.CREATE")}</h1>
<TextButton style="primary" name="playlist"
icon="Playlist"
title={ translate.t("LABEL.LIST")}
onclick={handle_back}></TextButton>
</div>
<div class="tp__playlist-create__body">
</div>
</div>
}
}
+33
View File
@@ -0,0 +1,33 @@
use yew::prelude::*;
use yew_i18n::use_translation;
use crate::app::components::{PlaylistContext, PlaylistPage, TargetTable, TextButton};
#[function_component]
pub fn PlaylistList() -> Html {
let translate = use_translation();
let playlist_ctx = use_context::<PlaylistContext>();
let handle_create = {
let playlist_ctx = playlist_ctx.clone();
Callback::from(move |_| {
if let Some(ctx) = playlist_ctx.as_ref() {
ctx.active_page.set(PlaylistPage::NewPlaylist);
}
})
};
html! {
<div class="tp__playlist-list">
<div class="tp__playlist-list__header">
<h1>{ translate.t("LABEL.PLAYLISTS")}</h1>
<TextButton style="primary" name="new_playlist"
icon="PlaylistAdd"
title={ translate.t("LABEL.NEW_PLAYLIST")}
onclick={handle_create}></TextButton>
</div>
<div class="tp__playlist-create__body">
<TargetTable/>
</div>
</div>
}
}
+13
View File
@@ -0,0 +1,13 @@
mod assistant;
mod list;
mod create;
mod context;
mod page;
mod target_table;
pub use self::assistant::*;
pub use self::list::*;
pub use self::create::*;
pub use self::context::*;
pub use self::page::*;
pub use self::target_table::*;
+31
View File
@@ -0,0 +1,31 @@
use std::fmt::Display;
use std::str::FromStr;
use shared::error::{TuliproxError,TuliproxErrorKind};
use shared::info_err;
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum PlaylistPage {
Lists,
NewPlaylist,
}
impl FromStr for PlaylistPage {
type Err = TuliproxError;
fn from_str(s: &str) -> Result<Self, TuliproxError> {
match s.to_lowercase().as_str() {
"list" => Ok(PlaylistPage::Lists),
"create" => Ok(PlaylistPage::NewPlaylist),
_ => Err(info_err!(format!("Unknown page type: {s}"))),
}
}
}
impl Display for PlaylistPage {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}", match *self {
Self::Lists => "list",
Self::NewPlaylist => "create",
})
}
}
@@ -0,0 +1,79 @@
use std::rc::Rc;
use yew::prelude::*;
use yew_i18n::use_translation;
use shared::model::ConfigTargetDto;
use crate::app::components::{PlaylistContext, Table, TableDefinition, ToggleSwitch};
const HEADERS: [&str; 11] = [
"TABLE.ID",
"TABLE.ENABLED",
"TABLE.NAME",
"TABLE.OPTIONS",
"TABLE.SORT",
"TABLE.FILTER",
"TABLE.OUTPUT",
"TABLE.RENAME",
"TABLE.MAPPING",
"TABLE.PROCESSING_ORDER",
"TABLE.WATCH",
];
#[function_component]
pub fn TargetTable() -> Html {
let translate = use_translation();
let playlist_ctx = use_context::<PlaylistContext>();
let render_header_cell = {
Callback::<usize, Html>::from(move |col| {
html! {
{
if col < HEADERS.len() {
translate.t(HEADERS[col])
} else {
String::new()
}
}
}
})
};
let render_data_cell = {
Callback::<(usize, usize, &ConfigTargetDto), Html>::from(
move |(_row, col, dto): (usize, usize, &ConfigTargetDto)| {
match col {
0 => html! { &dto.name.to_string() },
1 => html! { <ToggleSwitch value={&dto.enabled} /> },
2 => html! { &dto.name.to_string() },
3 => html! { "options" },
4 => html! { "sort" },
5 => html! { "filter" },
6 => html! { "output" },
7 => html! { "rename" },
8 => html! { "mapping" },
9 => html! { "processing order" },
10 => html! { "watch" },
_ => html! {""},
}
})
};
let items: Vec<&ConfigTargetDto> = vec![];
let table_definition = {
let render_header_cell = render_header_cell.clone();
let render_data_cell = render_data_cell.clone();
use_state(|| Rc::new(TableDefinition::<&ConfigTargetDto> {
items,
num_cols: 11,
render_header_cell,
render_data_cell,
}))
};
html! {
<div class="tp__target-table">
<Table::<&ConfigTargetDto> definition={(*table_definition).clone()} />
</div>
}
}
+53
View File
@@ -0,0 +1,53 @@
use crate::app::components::{Breadcrumbs, Panel, PlaylistContext, PlaylistCreate, PlaylistList, PlaylistPage};
use std::rc::Rc;
use yew::prelude::*;
use yew_i18n::use_translation;
#[function_component]
pub fn PlaylistView() -> Html {
let translate = use_translation();
let breadcrumbs = use_state(|| Rc::new(vec![translate.t("LABEL.PLAYLISTS"), translate.t("LABEL.LIST")]));
let active_page = use_state(|| PlaylistPage::Lists);
let handle_breadcrumb_select = {
let view_visible = active_page.clone();
Callback::from(move |(_name, index)| {
if index == 0 && *view_visible != PlaylistPage::Lists {
view_visible.set(PlaylistPage::Lists);
}
})
};
{
let breadcrumbs = breadcrumbs.clone();
let view_visible_dep = active_page.clone();
let view_visible = active_page.clone();
let translate = translate.clone();
use_effect_with(view_visible_dep, move |_| {
match *view_visible {
PlaylistPage::Lists => breadcrumbs.set(Rc::new(vec![translate.t("LABEL.PLAYLISTS"), translate.t("LABEL.LIST")])),
PlaylistPage::NewPlaylist => breadcrumbs.set(Rc::new(vec![translate.t("LABEL.PLAYLISTS"), translate.t("LABEL.CREATE")])),
}
});
};
let context = PlaylistContext {
active_page: active_page.clone(),
};
html! {
<ContextProvider<PlaylistContext> context={context}>
<div class="tp__playlist-view">
<Breadcrumbs items={&*breadcrumbs} onclick={ handle_breadcrumb_select }/>
<div class="tp__playlist-view__body">
<Panel value={PlaylistPage::Lists.to_string()} active={active_page.to_string()}>
<PlaylistList />
</Panel>
<Panel value={PlaylistPage::NewPlaylist.to_string()} active={active_page.to_string()}>
<PlaylistCreate />
</Panel>
</div>
</div>
</ContextProvider<PlaylistContext>>
}
}
+10 -10
View File
@@ -79,15 +79,15 @@ pub fn Preferences() -> Html {
};
html! {
<div class="preferences">
<div class="preferences__content">
<div class="preferences__sidebar">
<div class="tp__preferences">
<div class="tp__preferences__content">
<div class="tp__preferences__sidebar">
{
SIDEBAR_ACTIONS.iter().map(|action| {
html! {
<div key={format!("pref_{:?}", action.page).to_lowercase()}
data-page={action.page.to_string()}
class={format!("preferences__sidebar-menu-action preferences__sidebar-menu-action_{}{}",
class={format!("tp__preferences__sidebar-menu-action preferences__sidebar-menu-action_{}{}",
action.page.to_string(),
if action.page == *active_page { " selected" } else { "" })}
onclick={handle_sidebar_action.clone()}
@@ -99,21 +99,21 @@ pub fn Preferences() -> Html {
}).collect::<Vec<_>>()
}
</div>
<div class="preferences__panels">
<div class="tp__preferences__panels">
<Panel value={PrefPage::Update.to_string()} active={active_page.to_string()}>
<div class="card">{"Update"}</div>
<div class="tp__card">{"Update"}</div>
</Panel>
<Panel value={PrefPage::User.to_string()} active={active_page.to_string()}>
<div class="card">{"User"}</div>
<div class="tp__card">{"User"}</div>
</Panel>
<Panel value={PrefPage::ApiServer.to_string()} active={active_page.to_string()}>
<div class="card">{"Api"}</div>
<div class="tp__card">{"Api"}</div>
</Panel>
<Panel value={PrefPage::MainConfig.to_string()} active={active_page.to_string()}>
<div class="card">{"MainConfig"}</div>
<div class="tp__card">{"MainConfig"}</div>
</Panel>
<Panel value={PrefPage::Status.to_string()} active={active_page.to_string()}>
<div class="card">{"Status"}</div>
<div class="tp__card">{"Status"}</div>
</Panel>
</div>
</div>
+10 -6
View File
@@ -34,16 +34,20 @@ pub fn Sidebar(props: &SidebarProps) -> Html {
};
html! {
<div class="app-sidebar">
<div class="app-sidebar__header app-header">
<span class="app-header__logo">{app_logo}</span>
<div class="tp__app-sidebar">
<div class="tp__app-sidebar__header tp__app-header">
<span class="tp__app-header__logo">{app_logo}</span>
<AppIcon name={"ChevronLeft"}></AppIcon>
</div>
<div class="app-sidebar__content">
<MenuItem icon="Dashboard" name="dashboard" label={translate.t("LABEL.DASHBOARD")}
<div class="tp__app-sidebar__content">
<MenuItem icon="DashboardOutline" name="dashboard" label={translate.t("LABEL.DASHBOARD")}
onclick={&handle_menu_click}></MenuItem>
<CollapsePanel title={translate.t("LABEL.SETTINGS")}>
<MenuItem icon="User" name="users" label={translate.t("LABEL.USER")}
<MenuItem icon="UserOutline" name="users" label={translate.t("LABEL.USER")}
onclick={&handle_menu_click}></MenuItem>
</CollapsePanel>
<CollapsePanel title={translate.t("LABEL.PLAYLIST")}>
<MenuItem icon="PlayArrowOutline" name="playlists" label={translate.t("LABEL.PLAYLIST")}
onclick={&handle_menu_click}></MenuItem>
</CollapsePanel>
</div>
+1 -1
View File
@@ -22,7 +22,7 @@ pub fn SvgIcon(props: &SvgIconProps) -> Html {
}
}
#[derive(Properties, Clone, PartialEq, Eq)]
#[derive(Properties, Clone, PartialEq)]
pub struct AppIconProps {
pub name: AttrValue,
#[prop_or(AttrValue::Static("100%"))]
+63
View File
@@ -0,0 +1,63 @@
use std::rc::Rc;
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct TableDefinition<T: PartialEq + Clone + 'static> {
pub items: Vec<T>,
pub num_cols: usize,
pub render_header_cell: Callback<usize, Html>,
pub render_data_cell: Callback<(usize, usize, T), Html>,
}
#[derive(Properties, Clone, PartialEq)]
pub struct TableProps<T: PartialEq + Clone + 'static> {
pub definition: Rc<TableDefinition<T>>,
}
#[function_component]
pub fn Table<T: PartialEq + Clone + 'static>(props: &TableProps<T>) -> Html {
let TableDefinition {
items,
num_cols,
render_header_cell,
render_data_cell,
} = &*props.definition;
html! {
<div class={"tp__table"}>
<div class={"tp__table__container"}>
<table class="tp__table__table">
<thead>
<tr>
{
for (0..*num_cols).map(|col_index| {
html!{
<th> {render_header_cell.emit(col_index)}</th>
}
})
}
</tr>
</thead>
<tbody>
{
for items.iter().enumerate().map(|(row_index, item)| {
let item_clone = item.clone();
html! {
<tr>
{
for (0..*num_cols).map(|col_index| {
html!{
<td>{render_data_cell.emit((row_index, col_index, item_clone.clone()))}</td>
}
})
}
</tr>
}
})
}
</tbody>
</table>
</div>
</div>
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ pub fn TextButton(props: &TextButtonProps) -> Html {
};
html! {
<button class={if props.style.is_empty() {"text-button".to_string()} else {format!("text-button button-{}", props.style)}} onclick={handle_click}>
<button class={if props.style.is_empty() {"tp__text-button".to_string()} else {format!("tp__text-button tp__button-{}", props.style)}} onclick={handle_click}>
if !props.icon.is_empty() {
<AppIcon name={props.icon.clone()}></AppIcon>
}
+29
View File
@@ -0,0 +1,29 @@
use yew::prelude::*;
#[derive(Properties, Clone, PartialEq, Debug)]
pub struct ToggleSwitchProps {
#[prop_or_default]
pub value: bool,
}
#[function_component]
pub fn ToggleSwitch(props: &ToggleSwitchProps) -> Html {
let toggled = use_state(|| props.value);
let onclick = {
let toggled = toggled.clone();
Callback::from(move |_| toggled.set(!*toggled))
};
html! {
<label class="tp__toggle-switch">
<input type="checkbox"
checked={*toggled}
onclick={onclick}/>
<span class={classes!("tp__toggle-switch__track", if *toggled { "tp__toggle-switch__active" } else { "" })}>
<span class={classes!("tp__toggle-switch__toggle", if *toggled { "tp__toggle-switch__on" } else { "" })}>
</span>
</span>
</label>
}
}
-1
View File
@@ -1,5 +1,4 @@
mod components;
use std::collections::HashMap;
use std::rc::Rc;
use futures::future::join_all;
+3
View File
@@ -1,9 +1,11 @@
use std::str::FromStr;
use shared::error::{info_err, TuliproxError, TuliproxErrorKind};
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ViewType {
Dashboard,
Users,
Playlists,
}
impl FromStr for ViewType {
@@ -13,6 +15,7 @@ impl FromStr for ViewType {
match s.to_lowercase().as_str() {
"dashboard" => Ok(ViewType::Dashboard),
"users" => Ok(ViewType::Users),
"playlists" => Ok(ViewType::Playlists),
_ => Err(info_err!(format!("Unknown view type: {s}"))),
}
}
@@ -8,7 +8,6 @@ pub struct IconContextProps {
pub icons: Vec<Rc<IconDefinition>>,
}
/// User context provider.
#[function_component]
pub fn IconContextProvider(props: &IconContextProps) -> Html {
let icon_ctx = use_state(||IconContext::new(&props.icons));
@@ -9,7 +9,6 @@ pub struct ServiceContextProps {
pub config: WebConfig
}
/// User context provider.
#[function_component]
pub fn ServiceContextProvider(props: &ServiceContextProps) -> Html {
let service_ctx = use_state(||ServiceContext::new(&props.config));