Permission editor reworking

canary
WolverinDEV 2020-06-15 16:56:05 +02:00
parent c936a56aee
commit c38b2c8d83
39 changed files with 5946 additions and 144 deletions

View File

@ -1,4 +1,15 @@
# Changelog:
* **15.06.20**
- Recoded the permission editor with react
- Fixed sever permission editor display bugs
- Recoded the group add editor with react
- Added the ability to duplicate groups and copy their permissions
- The permission editor now uses by default the highest permission value instead of 1
- Added options to enable/disable a whole permission group
* **14.06.20**
- Fixed local icon display element not updating when the icon has been loaded
* **13.06.20**
- Started to extract all color values and put them into css variables
- Fixed a minor issue related to server/channel groups

View File

@ -70,8 +70,7 @@
"webpack": "^4.42.1",
"webpack-bundle-analyzer": "^3.6.1",
"webpack-cli": "^3.3.11",
"worker-plugin": "^4.0.2",
"tsd": "latest"
"worker-plugin": "^4.0.2"
},
"repository": {
"type": "git",

View File

@ -1,47 +1,6 @@
@import "mixin";
@import "properties";
html:root {
--modal-permissions-header-text: #e1e1e1;
--modal-permissions-header-background: #19191b;
--modal-permissions-header-hover: #4e4e4e;
--modal-permissions-header-selected: #0073d4;
--modal-permission-right: #303036;
--modal-permission-left: #222226;
--modal-permissions-entry-hover: #28282c;
--modal-permissions-entry-selected: #111111;
--modal-permissions-current-group: #101012;
--modal-permissions-buttons-background: #1b1b1b;
--modal-permissions-buttons-hover: #262626;
--modal-permissions-buttons-disabled: hsla(0, 0%, 9%, 1);
--modal-permissions-seperator: #1e1e1e; /* the seperator for the "enter a unique id" and "client info" part */
--modal-permissions-container-seperator: #222224; /* the seperator between left and right */
--modal-permissions-icon-select: #121213;
--modal-permissions-icon-select-border: #0d0d0d;
--modal-permissions-icon-select-hover: #17171a;
--modal-permissions-icon-select-hover-border: #333333;
--modal-permissions-table-border: #1e2025;
--modal-permissions-table-header: #303036;
--modal-permissions-table-entry-odd: #303036;
--modal-permissions-table-entry-even: #25252a;
--modal-permissions-table-entry-hover: #343a47;
--modal-permissions-table-header-text: #e1e1e1;
--modal-permissions-table-entry-text: #535455;
--modal-permissions-table-entry-active-text: #e1e1e1;
--modal-permissions-table-entry-group-text: #e1e1e1;
--modal-permissions-table-input: #e1e1e1;
--modal-permissions-table-input-focus: #3f7dbf;
}
.modal-body.modal-permission-editor {
padding: 0!important;
@ -69,8 +28,8 @@ html:root {
.header {
height: 4em;
background-color: var(--modal-permissions-header-text);
color: var(--modal-permissions-header-background);
background-color: var(--modal-permissions-header-background);
color: var(--modal-permissions-header-text);
display: flex;
flex-direction: row;
@ -116,6 +75,7 @@ html:root {
background-color: var(--modal-permission-right);
/* DONE! */
.header {
> .entry {
position: relative;
@ -736,9 +696,9 @@ html:root {
height: 2em;
border: none;
border-bottom: 1px solid var(--modal-permissions-table-border);
background-color: var(--modal-permissions-table-entry-odd);
background-color: var(--modal-permissions-table-row-odd);
color: var(--modal-permissions-table-entry-text);
color: var(--modal-permissions-table-row-text);
@mixin fixed-column($name, $width) {
.column-#{$name} {
@ -845,11 +805,11 @@ html:root {
.entry {
&.even {
background-color: var(--modal-permissions-table-entry-even);
background-color: var(--modal-permissions-table-row-even);
}
&:hover {
background-color: var(--modal-permissions-table-entry-hover);
background-color: var(--modal-permissions-table-row-hover);
}
/* We cant use this effect here because the odd/even effect would be a bit crazy then */
//@include transition(background-color $button_hover_animation_time ease-in-out);

View File

Before

Width:  |  Height:  |  Size: 678 B

After

Width:  |  Height:  |  Size: 678 B

View File

@ -375,12 +375,15 @@ export class CommandHelper extends AbstractCommandHandler {
try {
const result: ServerGroupClient[] = [];
for(const entry of command.arguments)
for(const entry of command.arguments) {
if(!('cldbid' in entry))
continue;
result.push({
client_database_id: parseInt(entry["cldbid"]),
client_nickname: entry["client_nickname"],
client_unique_identifier: entry["client_unique_identifier"]
});
}
resolve(result);
} catch (error) {
log.error(LogCategory.NETWORKING, tr("Failed to parse server group client list: %o"), error);

View File

@ -44,9 +44,9 @@ export class Registry<Events> {
enable_warn_unhandled_events() { this.warn_unhandled_events = true; }
disable_warn_unhandled_events() { this.warn_unhandled_events = false; }
on<T extends keyof Events>(event: T, handler: (event?: Events[T] & Event<Events, T>) => void);
on(events: (keyof Events)[], handler: (event?: Event<Events, keyof Events>) => void);
on(events, handler) {
on<T extends keyof Events>(event: T, handler: (event?: Events[T] & Event<Events, T>) => void) : () => void;
on(events: (keyof Events)[], handler: (event?: Event<Events, keyof Events>) => void) : () => void;
on(events, handler) : () => void {
if(!Array.isArray(events))
events = [events];
@ -57,12 +57,13 @@ export class Registry<Events> {
const handlers = this.handler[event] || (this.handler[event] = []);
handlers.push(handler);
}
return () => this.off(events, handler);
}
/* one */
one<T extends keyof Events>(event: T, handler: (event?: Events[T] & Event<Events, T>) => void);
one(events: (keyof Events)[], handler: (event?: Event<Events, keyof Events>) => void);
one(events, handler) {
one<T extends keyof Events>(event: T, handler: (event?: Events[T] & Event<Events, T>) => void) : () => void;
one(events: (keyof Events)[], handler: (event?: Event<Events, keyof Events>) => void) : () => void;
one(events, handler) : () => void {
if(!Array.isArray(events))
events = [events];
@ -72,6 +73,7 @@ export class Registry<Events> {
handler[this.registry_uuid] = { singleshot: true };
handlers.push(handler);
}
return () => this.off(events, handler);
}
off<T extends keyof Events>(handler: (event?) => void);

View File

@ -1,21 +1,18 @@
import {Registry} from "tc-shared/events";
import {ClientGlobalControlEvents} from "tc-shared/events/GlobalEvents";
import {control_bar_instance, ControlBarEvents} from "tc-shared/ui/frames/control-bar";
import {manager, Sound} from "tc-shared/sound/Sounds";
import {Sound} from "tc-shared/sound/Sounds";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {server_connections} from "tc-shared/ui/frames/connection_handlers";
import {createErrorModal, createInfoModal, createInputModal} from "tc-shared/ui/elements/Modal";
import {default_recorder} from "tc-shared/voice/RecorderProfile";
import {Settings, settings} from "tc-shared/settings";
import {add_server_to_bookmarks} from "tc-shared/bookmarks";
import {settings} from "tc-shared/settings";
import {spawnConnectModal} from "tc-shared/ui/modal/ModalConnect";
import PermissionType from "tc-shared/permission/PermissionType";
import {spawnQueryCreate} from "tc-shared/ui/modal/ModalQuery";
import {openBanList} from "tc-shared/ui/modal/ModalBanList";
import {spawnPermissionEdit} from "tc-shared/ui/modal/permission/ModalPermissionEdit";
import {formatMessage} from "tc-shared/ui/frames/chat";
import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration";
import {spawnSettingsModal} from "tc-shared/ui/modal/ModalSettings";
import {spawnPermissionEditorModal} from "tc-shared/ui/modal/permissionv2/ModalPermissionEditor";
/*
function initialize_sounds(event_registry: Registry<ClientGlobalControlEvents>) {
@ -114,7 +111,7 @@ export function initialize(event_registry: Registry<ClientGlobalControlEvents>)
}
if(connection_handler)
spawnPermissionEdit(connection_handler).open();
spawnPermissionEditorModal(connection_handler);
else
createErrorModal(tr("You have to be connected"), tr("You have to be connected!")).open();
break;

View File

@ -34,6 +34,8 @@ import {spawnFileTransferModal} from "tc-shared/ui/modal/transfer/ModalFileTrans
import {MenuEntryType, spawn_context_menu} from "tc-shared/ui/elements/ContextMenu";
import {copy_to_clipboard} from "tc-shared/utils/helpers";
import ContextMenuEvent = JQuery.ContextMenuEvent;
import {spawnPermissionEditorModal} from "tc-shared/ui/modal/permissionv2/ModalPermissionEditor";
import {spawnGroupCreate} from "tc-shared/ui/modal/ModalGroups";
/* required import for init */
require("./proto").initialize();
@ -496,7 +498,8 @@ function main() {
modal.open();
}
*/
//setTimeout(() => spawnPermissionEditorModal(server_connections.active_connection()), 3000);
//setTimeout(() => spawnGroupCreate(server_connections.active_connection(), "server"), 3000);
if(settings.static_global(Settings.KEY_USER_IS_NEW)) {
const modal = openModalNewcomer();
@ -547,7 +550,7 @@ const task_connect_handler: loader.Task = {
}
};
if(chandler) {
if(chandler && !settings.static(Settings.KEY_CONNECT_NO_SINGLE_INSTANCE)) {
try {
await chandler.post_connect_request(connect_data, () => new Promise<boolean>((resolve, reject) => {
spawnYesNo(tr("Another TeaWeb instance is already running"), tra("Another TeaWeb instance is already running.{:br:}Would you like to connect there?"), response => {

View File

@ -44,12 +44,12 @@ export interface GroupManagerEvents {
}
}
export type GroupProperty = "name" | "icon" | "sort-id" | "save-db" | "name-mode";
export interface GroupEvents {
notify_group_deleted: { },
notify_properties_updated: {
updated_properties: {[Key in keyof GroupProperties]: GroupProperties[Key]};
group_properties: GroupProperties
updated_properties: GroupProperty[];
},
notify_needed_powers_updated: { }
@ -81,25 +81,41 @@ export class Group {
this.name = name;
}
updateProperties(properties: {key: string, value: string}[]) {
let updates = {};
for(const { key, value } of properties) {
if(!JSON.map_field_to(this.properties, value, key))
continue; /* no updates */
updatePropertiesFromGroupList(data: any) {
const updates: GroupProperty[] = [];
if(key === "iconid")
this.properties.iconid = this.properties.iconid >>> 0;
updates[key] = this.properties[key];
if(this.name !== data["name"]) {
this.name = data["name"];
updates.push("name");
}
if(Object.keys(updates).length === 0)
return;
/* icon */
let value = parseInt(data["iconid"]) >>> 0;
if(value !== this.properties.iconid) {
this.properties.iconid = value;
updates.push("icon");
}
this.events.fire("notify_properties_updated", {
group_properties: this.properties,
updated_properties: updates as any
});
value = parseInt(data["sortid"]);
if(value !== this.properties.sortid) {
this.properties.sortid = value;
updates.push("sort-id");
}
let flag = parseInt(data["savedb"]) >= 1;
if(flag !== this.properties.savedb) {
this.properties.savedb = flag;
updates.push("save-db");
}
value = parseInt(data["namemode"]);
if(value !== this.properties.namemode) {
this.properties.namemode = value;
updates.push("name-mode");
}
if(updates.length > 0)
this.events.fire("notify_properties_updated", { updated_properties: updates });
}
}
@ -240,16 +256,10 @@ export class GroupManager extends AbstractCommandHandler {
group = deleteGroups.splice(groupIndex, 1)[0];
}
const property_blacklist = [
"sgid", "cgid", "type", "name",
"n_member_removep", "n_member_addp", "n_modifyp"
];
group.requiredMemberRemovePower = parseInt(groupData["n_member_removep"]);
group.requiredMemberAddPower = parseInt(groupData["n_member_addp"]);
group.requiredModifyPower = parseInt(groupData["n_modifyp"]);
group.updateProperties(Object.keys(groupData).filter(e => property_blacklist.findIndex(a => a === e) === -1).map(e => { return { key: e, value: groupData[e] } }));
group.updatePropertiesFromGroupList(groupData);
group.events.fire("notify_needed_powers_updated");
}

View File

@ -6,6 +6,7 @@ import {ServerCommand} from "tc-shared/connection/ConnectionBase";
import {CommandResult, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {AbstractCommandHandler} from "tc-shared/connection/AbstractCommandHandler";
import {Registry} from "tc-shared/events";
export class PermissionInfo {
name: string;
@ -34,7 +35,7 @@ export class GroupedPermissions {
export class PermissionValue {
readonly type: PermissionInfo;
value: number;
value: number | undefined; /* undefined if no permission is given */
flag_skip: boolean;
flag_negate: boolean;
granted_value: number;
@ -61,9 +62,14 @@ export class PermissionValue {
hasValue() : boolean {
return typeof(this.value) !== "undefined" && this.value != -2;
}
hasGrant() : boolean {
return typeof(this.granted_value) !== "undefined" && this.granted_value != -2;
}
valueOr(fallback: number) {
return this.hasValue() ? this.value : fallback;
}
}
export class NeededPermissionValue extends PermissionValue {
@ -126,6 +132,10 @@ export namespace find {
}
}
export interface PermissionManagerEvents {
client_permissions_changed: {}
}
export type RequestLists =
"requests_channel_permissions" |
"requests_client_permissions" |
@ -134,6 +144,8 @@ export type RequestLists =
"requests_playlist_client_permissions";
export class PermissionManager extends AbstractCommandHandler {
readonly events = new Registry<PermissionManagerEvents>();
readonly handle: ConnectionHandler;
permissionList: PermissionInfo[] = [];
@ -397,6 +409,8 @@ export class PermissionManager extends AbstractCommandHandler {
for(const listener of this.needed_permission_change_listener[e.type.name] || [])
listener();
}
this.events.fire("client_permissions_changed");
}
register_needed_permission(key: PermissionType, listener: () => any) {

View File

@ -289,6 +289,9 @@ if (!String.prototype.format) {
};
}
if(!Object.values)
Object.values = object => Object.keys(object).map(e => object[e]);
function concatenate(resultConstructor, ...arrays) {
let totalLength = 0;
for (const arr of arrays) {

View File

@ -238,6 +238,10 @@ export class Settings extends StaticSettings {
static readonly KEY_CONNECT_HISTORY: SettingsKey<string> = {
key: 'connect_history'
};
static readonly KEY_CONNECT_NO_SINGLE_INSTANCE: SettingsKey<boolean> = {
key: 'connect_no_single_instance',
default_value: false
};
static readonly KEY_CONNECT_NO_DNSPROXY: SettingsKey<boolean> = {
key: 'connect_no_dnsproxy',

View File

@ -201,6 +201,14 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
if(typeof event.updated_properties.client_nickname !== "undefined" || typeof event.updated_properties.client_talk_power !== "undefined")
this.reorderClientList(true);
};
this.events.on("notify_properties_updated", event => {
this.channelTree?.events.fire("notify_channel_updated", {
channel: this,
channelProperties: event.channel_properties,
updatedProperties: event.updated_properties
});
});
}
destroy() {
@ -223,6 +231,16 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
return this.properties.channel_name;
}
channelDepth() {
let depth = 0;
let parent = this.parent;
while(parent) {
depth++;
parent = parent.parent;
}
return depth;
}
formattedChannelName() {
return this.parsed_channel_name.text;
}

View File

@ -15,7 +15,6 @@ import {ChannelEntry} from "tc-shared/ui/channel";
import {ConnectionHandler, ViewReasonId} from "tc-shared/ConnectionHandler";
import {voice} from "tc-shared/connection/ConnectionBase";
import VoiceClient = voice.VoiceClient;
import {spawnPermissionEdit} from "tc-shared/ui/modal/permission/ModalPermissionEdit";
import {createServerGroupAssignmentModal} from "tc-shared/ui/modal/ModalGroupAssignment";
import {openClientInfo} from "tc-shared/ui/modal/ModalClientInfo";
import {spawnBanClient} from "tc-shared/ui/modal/ModalBanClient";
@ -28,6 +27,7 @@ import { ClientEntry as ClientEntryView } from "./tree/Client";
import * as React from "react";
import {ChannelTreeEntry, ChannelTreeEntryEvents} from "tc-shared/ui/TreeEntry";
import {spawnClientVolumeChange, spawnMusicBotVolumeChange} from "tc-shared/ui/modal/ModalChangeVolumeNew";
import {spawnPermissionEditorModal} from "tc-shared/ui/modal/permissionv2/ModalPermissionEditor";
export enum ClientType {
CLIENT_VOICE,
@ -455,13 +455,13 @@ export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
type: contextmenu.MenuEntryType.ENTRY,
icon_class: "client-permission_client",
name: tr("Client permissions"),
callback: () => spawnPermissionEdit(this.channelTree.client, "clp", {unique_id: this.clientUid()}).open()
callback: () => spawnPermissionEditorModal(this.channelTree.client, "client", { clientDatabaseId: this.properties.client_database_id })
},
{
type: contextmenu.MenuEntryType.ENTRY,
icon_class: "client-permission_client",
name: tr("Client channel permissions"),
callback: () => spawnPermissionEdit(this.channelTree.client, "clchp", {unique_id: this.clientUid(), channel_id: this._channel ? this._channel.channelId : undefined }).open()
callback: () => spawnPermissionEditorModal(this.channelTree.client, "client-channel", { clientDatabaseId: this.properties.client_database_id })
}
]
}];

View File

@ -7,10 +7,9 @@ import {
boorkmak_connect,
DirectoryBookmark
} from "tc-shared/bookmarks";
import {ConnectionHandler, DisconnectReason} from "tc-shared/ConnectionHandler";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {Sound} from "tc-shared/sound/Sounds";
import {spawnConnectModal} from "tc-shared/ui/modal/ModalConnect";
import {spawnPermissionEdit} from "tc-shared/ui/modal/permission/ModalPermissionEdit";
import {createErrorModal, createInfoModal, createInputModal} from "tc-shared/ui/elements/Modal";
import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration";
import {PermissionType} from "tc-shared/permission/PermissionType";
@ -24,6 +23,7 @@ import * as loader from "tc-loader";
import {formatMessage} from "tc-shared/ui/frames/chat";
import {control_bar_instance} from "tc-shared/ui/frames/control-bar";
import {icon_cache_loader, IconManager, LocalIcon} from "tc-shared/file/Icons";
import {spawnPermissionEditorModal} from "tc-shared/ui/modal/permissionv2/ModalPermissionEditor";
export interface HRItem { }
@ -390,35 +390,35 @@ export function initialize() {
item = menu.append_item(tr("Server Groups"));
item.icon("client-permission_server_groups");
item.click(() => {
spawnPermissionEdit(server_connections.active_connection(), "sg").open();
spawnPermissionEditorModal(server_connections.active_connection(), "groups-server");
});
_state_updater["permission.sg"] = { item: item, conditions: [condition_connected]};
item = menu.append_item(tr("Client Permissions"));
item.icon("client-permission_client");
item.click(() => {
spawnPermissionEdit(server_connections.active_connection(), "clp").open();
spawnPermissionEditorModal(server_connections.active_connection(), "client");
});
_state_updater["permission.clp"] = { item: item, conditions: [condition_connected]};
item = menu.append_item(tr("Channel Client Permissions"));
item.icon("client-permission_client");
item.click(() => {
spawnPermissionEdit(server_connections.active_connection(), "clchp").open();
spawnPermissionEditorModal(server_connections.active_connection(), "client-channel");
});
_state_updater["permission.chclp"] = { item: item, conditions: [condition_connected]};
item = menu.append_item(tr("Channel Groups"));
item.icon("client-permission_channel");
item.click(() => {
spawnPermissionEdit(server_connections.active_connection(), "cg").open();
spawnPermissionEditorModal(server_connections.active_connection(), "groups-channel");
});
_state_updater["permission.cg"] = { item: item, conditions: [condition_connected]};
item = menu.append_item(tr("Channel Permissions"));
item.icon("client-permission_channel");
item.click(() => {
spawnPermissionEdit(server_connections.active_connection(), "chp").open();
spawnPermissionEditorModal(server_connections.active_connection(), "channel");
});
_state_updater["permission.cp"] = { item: item, conditions: [condition_connected]};

View File

@ -0,0 +1,57 @@
.container {
display: flex;
flex-direction: column;
justify-content: flex-start;
padding: 0 1em 1em;
width: 35em;
min-width: 10em;
max-width: 100%;
.row {
display: flex;
flex-direction: row;
justify-content: stretch;
}
select {
.hiddenOption {
display: none;
}
}
.groupType, .groupSource {
flex-grow: 1;
flex-shrink: 1;
min-width: 6em;
}
.groupType {
margin-right: 1em;
flex-grow: 0;
width: 12em;
}
.groupSource {}
.buttons {
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
@media all and (max-width: 30em){
.container {
.row {
flex-direction: column;
justify-content: flex-start;
}
.groupType, .groupSource {
width: 100%;
}
}
}

View File

@ -0,0 +1,349 @@
import {Modal, spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {Registry} from "tc-shared/events";
import {FlatInputField, FlatSelect} from "tc-shared/ui/react-elements/InputField";
import * as React from "react";
import {useEffect, useRef, useState} from "react";
import {GroupType} from "tc-shared/permission/GroupManager";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import {Button} from "tc-shared/ui/react-elements/Button";
import PermissionType from "tc-shared/permission/PermissionType";
import {CommandResult, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration";
import {createErrorModal, createInfoModal} from "tc-shared/ui/elements/Modal";
import {tra} from "tc-shared/i18n/localize";
const cssStyle = require("./ModalGroupCreate.scss");
export type GroupInfo = {
id: number,
name: string,
type: "query" | "template" | "normal"
};
export interface GroupCreateModalEvents {
action_set_name: { name: string | undefined },
action_set_type: { target: "query" | "template" | "normal" },
action_set_source: { group: number },
action_cancel: {},
action_create: {
name: string,
target: "query" | "template" | "normal",
source: number /* if zero than no template */
}
query_available_groups: { },
query_available_groups_result: {
groups: GroupInfo[]
},
query_client_permissions: {},
notify_client_permissions: {
createTemplateGroup: boolean,
createQueryGroup: boolean
},
notify_destroy: {}
}
const GroupNameInput = (props: { events: Registry<GroupCreateModalEvents>, defaultSource: number }) => {
const [ initialLoad, setInitialLoad ] = useState(true);
const [ existingGroups, setExistingGroups ] = useState<"loading" | GroupInfo[]>("loading");
const [ selectedType, setSelectedType ] = useState<"query" | "template" | "normal" | "loading">("loading");
const refInput = useRef<FlatInputField>();
useEffect(() => {
if(!initialLoad || !refInput.current)
return;
if(selectedType === "loading" || existingGroups === "loading")
return;
setInitialLoad(false);
refInput.current.focus();
const defaultGroup = existingGroups.find(e => e.id === props.defaultSource);
if(defaultGroup) {
let name = defaultGroup.name + " (" + tr("Copy") + ")";
let index = 1;
while(existingGroups.findIndex(e => e.name === name) !== -1)
name = defaultGroup.name + " (" + tr("Copy")+ " " + index++ + ")";
refInput.current.setValue(name);
props.events.fire("action_set_name", { name: updateGroupNameState(name) ? name : undefined });
}
});
const updateGroupNameState = (input: string) => {
if(!refInput.current)
return false;
if(input.length === 0 || input.length > 30) {
refInput.current.setState({ isInvalid: true, invalidMessage: tr("Invalid group name length") });
return false;
}
if(existingGroups === "loading")
return false;
if(existingGroups.findIndex(e => e.name === input && e.type === selectedType) !== -1) {
refInput.current.setState({ isInvalid: true, invalidMessage: tr("A group with this name already exists") });
return false;
}
refInput.current.setState({ isInvalid: false });
return true;
};
props.events.reactUse("query_available_groups_result", event => setExistingGroups(event.groups));
props.events.reactUse("action_set_type", event => setSelectedType(event.target));
return (
<FlatInputField
ref={refInput}
label={<Translatable>Group name</Translatable>}
finishOnEnter={true}
disabled={existingGroups === "loading" || selectedType === "loading"}
placeholder={existingGroups === "loading" || selectedType === "loading" ? tr("loading data...") : undefined}
onInput={() => props.events.fire("action_set_name", { name: updateGroupNameState(refInput.current.value()) ? refInput.current.value() : undefined })}
onBlur={() => props.events.fire("action_set_name", { name: updateGroupNameState(refInput.current.value()) ? refInput.current.value() : undefined })}
/>
)
};
const GroupTypeSelector = (props: { events: Registry<GroupCreateModalEvents> }) => {
const [ selectedType, setSelectedType ] = useState<"query" | "template" | "normal" | "loading">("loading");
const [ permissions, setPermissions ] = useState<"loading" | { createTemplate, createQuery }>("loading");
const refSelect = useRef<FlatSelect>();
props.events.reactUse("notify_client_permissions", event => {
setPermissions({
createQuery: event.createQueryGroup,
createTemplate: event.createTemplateGroup
});
/* the default type */
props.events.fire("action_set_type", { target: "normal" });
});
props.events.reactUse("action_set_type", event => setSelectedType(event.target));
return (
<FlatSelect
ref={refSelect}
label={<Translatable>Target group type</Translatable>}
className={cssStyle.groupType}
disabled={permissions === "loading"}
value={selectedType}
onChange={event => event.target.value !== "loading" && props.events.fire("action_set_type", { target: event.target.value as any })}
>
<option className={cssStyle.hiddenOption} value={"loading"}>{tr("loading...")}</option>
<option
value={"query"}
disabled={permissions === "loading" || !permissions.createQuery}
>{tr("Query group")}</option>
<option
value={"template"}
disabled={permissions === "loading" || !permissions.createTemplate}
>{tr("Template group")}</option>
<option
value={"normal"}
>{tr("Regular group")}</option>
</FlatSelect>
)
};
const SourceGroupSelector = (props: { events: Registry<GroupCreateModalEvents>, defaultSource: number }) => {
const [ selectedGroup, setSelectedGroup ] = useState(undefined);
const [ permissions, setPermissions ] = useState<"loading" | { createTemplate, createQuery }>("loading");
const [ exitingGroups, setExitingGroups ] = useState<"loading" | GroupInfo[]>("loading");
const refSelect = useRef<FlatSelect>();
props.events.reactUse("notify_client_permissions", event => setPermissions({
createQuery: event.createQueryGroup,
createTemplate: event.createTemplateGroup
}));
props.events.reactUse("query_available_groups_result", event => setExitingGroups(event.groups));
props.events.reactUse("action_set_source", event => setSelectedGroup(event.group));
const groupName = (group: GroupInfo) => {
let prefix = group.type === "template" ? "[T] " : group.type === "query" ? "[Q] " : "";
return prefix + group.name + " (" + group.id + ")";
};
const isLoading = exitingGroups === "loading" || permissions === "loading";
if(!isLoading && selectedGroup === undefined)
props.events.fire_async("action_set_source", {
group: (exitingGroups as GroupInfo[]).findIndex(e => e.id === props.defaultSource) === -1 ? 0 : props.defaultSource
});
return (
<FlatSelect
ref={refSelect}
label={<Translatable>Create group using this template</Translatable>}
className={cssStyle.groupSource}
disabled={isLoading}
value={isLoading || selectedGroup === undefined ? "-1" : selectedGroup.toString()}
onChange={event => props.events.fire("action_set_source", { group: parseInt(event.target.value) })}
>
<option className={cssStyle.hiddenOption} value={"-1"}>{tr("loading...")}</option>
<option value={"0"} onSelect={() => props.events.fire("action_set_source", { group: 0 })}>{tr("No template")}</option>
<optgroup label={tr("Query groups")} className={permissions === "loading" || !permissions.createQuery ? cssStyle.hiddenOption : ""} >
{exitingGroups === "loading" ? undefined :
exitingGroups.filter(e => e.type === "query").map(e => (
<option key={"group-" + e.id} value={e.id.toString()}>{groupName(e)}</option>
))
}
</optgroup>
<optgroup label={tr("Template groups")} className={permissions === "loading" || !permissions.createTemplate ? cssStyle.hiddenOption : ""} >
{exitingGroups === "loading" ? undefined :
exitingGroups.filter(e => e.type === "template").map(e => (
<option key={"group-" + e.id} value={e.id.toString()}>{groupName(e)}</option>
))
}
</optgroup>
<optgroup label={tr("Regular Groups")} >
{exitingGroups === "loading" ? undefined :
exitingGroups.filter(e => e.type === "normal").map(e => (
<option key={"group-" + e.id} value={e.id.toString()}>{groupName(e)}</option>
))
}
</optgroup>
</FlatSelect>
)
};
const CreateButton = (props: { events: Registry<GroupCreateModalEvents> }) => {
const [ sourceGroup, setSourceGroup ] = useState<number | undefined>(undefined);
const [ groupType, setGroupType ] = useState<"query" | "template" | "normal" | undefined>(undefined);
const [ groupName, setGroupName ] = useState<string | undefined>(undefined);
props.events.reactUse("action_set_name", event => setGroupName(event.name));
props.events.reactUse("action_set_type", event => setGroupType(event.target));
props.events.reactUse("action_set_source", event => setSourceGroup(event.group));
return <Button color={"green"} disabled={sourceGroup === undefined || groupType === undefined || groupName === undefined} onClick={() => {
props.events.fire("action_create", { name: groupName, source: sourceGroup, target: groupType });
}}>
<Translatable>Create Group</Translatable>
</Button>
};
class ModalGroupCreate extends Modal {
readonly target: "server" | "channel";
readonly events = new Registry<GroupCreateModalEvents>();
readonly defaultSourceGroup: number;
constructor(connection: ConnectionHandler, target: "server" | "channel", defaultSourceGroup: number) {
super();
this.events.enable_debug("group-create");
this.defaultSourceGroup = defaultSourceGroup;
this.target = target;
initializeGroupCreateController(connection, this.events, this.target);
}
protected onInitialize() {
this.modalController().events.on("destroy", () => this.events.fire("notify_destroy"));
this.events.fire_async("query_available_groups");
this.events.fire_async("query_client_permissions");
this.events.on(["action_cancel", "action_create"], () => this.modalController().destroy());
}
renderBody() {
return <div className={cssStyle.container}>
<GroupNameInput events={this.events} defaultSource={this.defaultSourceGroup} />
<div className={cssStyle.row}>
<GroupTypeSelector events={this.events} />
<SourceGroupSelector events={this.events} defaultSource={this.defaultSourceGroup} />
</div>
<div className={cssStyle.buttons}>
<Button color={"red"} onClick={() => this.events.fire("action_cancel")}><Translatable>Cancel</Translatable></Button>
<CreateButton events={this.events} />
</div>
</div>;
}
title(): string {
return this.target === "server" ? tr("Create a new server group") : tr("Create a new channel group");
}
}
export function spawnGroupCreate(connection: ConnectionHandler, target: "server" | "channel", sourceGroup: number = 0) {
const modal = spawnReactModal(ModalGroupCreate, connection, target, sourceGroup);
modal.show();
}
const stringifyError = error => {
if(error instanceof CommandResult) {
if(error.id === ErrorID.PERMISSION_ERROR)
return tr("insufficient permissions");
else
return error.message + (error.extra_message ? " (" + error.extra_message + ")" : "");
} else if(error instanceof Error) {
return error.message;
} else if(typeof error !== "string") {
return tr("Lookup the console");
}
return error;
};
function initializeGroupCreateController(connection: ConnectionHandler, events: Registry<GroupCreateModalEvents>, target: "server" | "channel") {
events.on("query_available_groups", event => {
const groups = target === "server" ? connection.groups.serverGroups : connection.groups.channelGroups;
events.fire_async("query_available_groups_result", {
groups: groups.map(e => {
return {
name: e.name,
id: e.id,
type: e.type === GroupType.TEMPLATE ? "template" : e.type === GroupType.QUERY ? "query" : "normal"
}
})
});
});
const notifyClientPermissions = () => events.fire_async("notify_client_permissions", {
createQueryGroup: connection.permissions.neededPermission(PermissionType.B_SERVERINSTANCE_MODIFY_QUERYGROUP).granted(1),
createTemplateGroup: connection.permissions.neededPermission(PermissionType.B_SERVERINSTANCE_MODIFY_TEMPLATES).granted(1)
});
events.on("query_client_permissions", notifyClientPermissions);
events.on("notify_destroy", connection.permissions.events.on("client_permissions_changed", notifyClientPermissions));
events.on("action_create", event => {
let promise: Promise<CommandResult>;
if(event.source <= 0) {
/* real group create */
promise = connection.serverConnection.send_command("servergroupadd", {
name: event.name,
type: event.target === "query" ? 2 : event.target === "template" ? 0 : 1
});
} else {
/* group copy */
promise = connection.serverConnection.send_command("servergroupcopy", {
ssgid: event.source,
name: event.name,
type: event.target === "query" ? 2 : event.target === "template" ? 0 : 1
});
}
promise.then(() => {
createInfoModal(tr("Group has been created"), tr("The group has been successfully created.")).open();
}).catch(error => {
if(error instanceof CommandResult && error.id === ErrorID.PERMISSION_ERROR) {
createErrorModal(tr("Failed to create group"),
tra("Failed to create group.\nMissing permission {}", connection.permissions.resolveInfo(parseInt(error.json["failed_permid"]))?.name || tr("unknwon"))).open();
return;
}
console.warn(tr("Failed to create group: %o"), error);
createErrorModal(tr("Failed to create group"),
tra("Failed to create group.\n{}", stringifyError(error))).open();
});
});
}

View File

@ -0,0 +1,41 @@
.container {
display: flex;
flex-direction: column;
justify-content: flex-start;
padding: 0 1em 1em;
width: 40em;
min-width: 10em;
max-width: 100%;
.row {
display: flex;
flex-direction: row;
justify-content: stretch;
}
select {
.hiddenOption {
display: none;
}
}
.sourceGroup, .targetGroup {
flex-grow: 1;
flex-shrink: 1;
min-width: 5em;
width: 10em;
}
.sourceGroup {
margin-right: 1em;
}
.buttons {
display: flex;
flex-direction: row;
justify-content: space-between;
}
}

View File

@ -0,0 +1,223 @@
import {Modal, spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {Registry} from "tc-shared/events";
import {useRef, useState} from "react";
import {FlatSelect} from "tc-shared/ui/react-elements/InputField";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import * as React from "react";
import {Button} from "tc-shared/ui/react-elements/Button";
import {GroupType} from "tc-shared/permission/GroupManager";
import PermissionType from "tc-shared/permission/PermissionType";
import {CommandResult, ErrorID} from "tc-shared/connection/ServerConnectionDeclaration";
import {createErrorModal, createInfoModal} from "tc-shared/ui/elements/Modal";
import {tra} from "tc-shared/i18n/localize";
const cssStyle = require("./ModalGroupPermissionCopy.scss");
export type GroupInfo = {
id: number,
name: string,
type: "query" | "template" | "normal"
};
export interface GroupPermissionCopyModalEvents {
action_set_source: { group: number },
action_set_target: { group: number }
action_cancel: {},
action_copy: {
source: number;
target: number;
}
query_available_groups: { },
query_available_groups_result: {
groups: GroupInfo[]
},
query_client_permissions: {},
notify_client_permissions: {
createTemplateGroup: boolean,
createQueryGroup: boolean
},
notify_destroy: {}
}
const GroupSelector = (props: { events: Registry<GroupPermissionCopyModalEvents>, defaultGroup: number, updateEvent: "action_set_source" | "action_set_target", label: string, className: string}) => {
const [ selectedGroup, setSelectedGroup ] = useState(undefined);
const [ permissions, setPermissions ] = useState<"loading" | { createTemplate, createQuery }>("loading");
const [ exitingGroups, setExitingGroups ] = useState<"loading" | GroupInfo[]>("loading");
const refSelect = useRef<FlatSelect>();
props.events.reactUse("notify_client_permissions", event => setPermissions({
createQuery: event.createQueryGroup,
createTemplate: event.createTemplateGroup
}));
props.events.reactUse("query_available_groups_result", event => setExitingGroups(event.groups));
props.events.reactUse(props.updateEvent, event => setSelectedGroup(event.group));
const groupName = (group: GroupInfo) => {
let prefix = group.type === "template" ? "[T] " : group.type === "query" ? "[Q] " : "";
return prefix + group.name + " (" + group.id + ")";
};
const isLoading = exitingGroups === "loading" || permissions === "loading";
if(!isLoading && selectedGroup === undefined)
props.events.fire_async(props.updateEvent, {
group: (exitingGroups as GroupInfo[]).findIndex(e => e.id === props.defaultGroup) === -1 ? 0 : props.defaultGroup
});
return (
<FlatSelect
ref={refSelect}
label={<Translatable>{props.label}</Translatable>}
className={props.className}
disabled={isLoading}
value={isLoading || selectedGroup === undefined ? "-1" : selectedGroup.toString()}
onChange={event => props.events.fire(props.updateEvent, { group: parseInt(event.target.value) })}
>
<option className={cssStyle.hiddenOption} value={"-1"}>{tr("loading...")}</option>
<option className={cssStyle.hiddenOption} value={"0"}>{tr("Select a group")}</option>
<optgroup label={tr("Query groups")} className={permissions === "loading" || !permissions.createQuery ? cssStyle.hiddenOption : ""} >
{exitingGroups === "loading" ? undefined :
exitingGroups.filter(e => e.type === "query").map(e => (
<option key={"group-" + e.id} value={e.id.toString()}>{groupName(e)}</option>
))
}
</optgroup>
<optgroup label={tr("Template groups")} className={permissions === "loading" || !permissions.createTemplate ? cssStyle.hiddenOption : ""} >
{exitingGroups === "loading" ? undefined :
exitingGroups.filter(e => e.type === "template").map(e => (
<option key={"group-" + e.id} value={e.id.toString()}>{groupName(e)}</option>
))
}
</optgroup>
<optgroup label={tr("Regular Groups")} >
{exitingGroups === "loading" ? undefined :
exitingGroups.filter(e => e.type === "normal").map(e => (
<option key={"group-" + e.id} value={e.id.toString()}>{groupName(e)}</option>
))
}
</optgroup>
</FlatSelect>
)
};
const CopyButton = (props: { events: Registry<GroupPermissionCopyModalEvents> }) => {
const [ sourceGroup, setSourceGroup ] = useState<number>(0);
const [ targetGroup, setTargetGroup ] = useState<number>(0);
props.events.reactUse("action_set_source", event => setSourceGroup(event.group));
props.events.reactUse("action_set_target", event => setTargetGroup(event.group));
return <Button color={"green"} disabled={sourceGroup === 0 || targetGroup === 0 || targetGroup === sourceGroup} onClick={() => {
props.events.fire("action_copy", { source: sourceGroup, target: targetGroup });
}}>
<Translatable>Copy group permissions</Translatable>
</Button>
};
class ModalGroupPermissionCopy extends Modal {
readonly events = new Registry<GroupPermissionCopyModalEvents>();
readonly defaultSource: number;
readonly defaultTarget: number;
constructor(connection: ConnectionHandler, target: "server" | "channel", sourceGroup?: number, targetGroup?: number) {
super();
this.defaultSource = sourceGroup;
this.defaultTarget = targetGroup;
initializeGroupPermissionCopyController(connection, this.events, target);
}
protected onInitialize() {
this.modalController().events.on("destroy", () => this.events.fire("notify_destroy"));
this.events.fire_async("query_available_groups");
this.events.fire_async("query_client_permissions");
this.events.on(["action_cancel", "action_copy"], () => this.modalController().destroy());
}
renderBody() {
return <div className={cssStyle.container}>
<div className={cssStyle.row}>
<GroupSelector events={this.events} defaultGroup={this.defaultSource} updateEvent={"action_set_source"} label={"Source group"} className={cssStyle.sourceGroup} />
<GroupSelector events={this.events} defaultGroup={this.defaultTarget} updateEvent={"action_set_target"} label={"Target group"} className={cssStyle.targetGroup} />
</div>
<div className={cssStyle.buttons}>
<Button color={"red"} onClick={() => this.events.fire("action_cancel")}><Translatable>Cancel</Translatable></Button>
<CopyButton events={this.events} />
</div>
</div>;
}
title(): string {
return tr("Copy group permissions");
}
}
export function spawnModalGroupPermissionCopy(connection: ConnectionHandler, target: "channel" | "server", sourceGroup?: number, targetGroup?: number) {
const modal = spawnReactModal(ModalGroupPermissionCopy, connection, target, sourceGroup, targetGroup);
modal.show();
}
const stringifyError = error => {
if(error instanceof CommandResult) {
if(error.id === ErrorID.PERMISSION_ERROR)
return tr("insufficient permissions");
else
return error.message + (error.extra_message ? " (" + error.extra_message + ")" : "");
} else if(error instanceof Error) {
return error.message;
} else if(typeof error !== "string") {
return tr("Lookup the console");
}
return error;
};
function initializeGroupPermissionCopyController(connection: ConnectionHandler, events: Registry<GroupPermissionCopyModalEvents>, target: "server" | "channel") {
events.on("query_available_groups", event => {
const groups = target === "server" ? connection.groups.serverGroups : connection.groups.channelGroups;
events.fire_async("query_available_groups_result", {
groups: groups.map(e => {
return {
name: e.name,
id: e.id,
type: e.type === GroupType.TEMPLATE ? "template" : e.type === GroupType.QUERY ? "query" : "normal"
}
})
});
});
const notifyClientPermissions = () => events.fire_async("notify_client_permissions", {
createQueryGroup: connection.permissions.neededPermission(PermissionType.B_SERVERINSTANCE_MODIFY_QUERYGROUP).granted(1),
createTemplateGroup: connection.permissions.neededPermission(PermissionType.B_SERVERINSTANCE_MODIFY_TEMPLATES).granted(1)
});
events.on("query_client_permissions", notifyClientPermissions);
events.on("notify_destroy", connection.permissions.events.on("client_permissions_changed", notifyClientPermissions));
events.on("action_copy", event => {
connection.serverConnection.send_command("servergroupcopy", {
ssgid: event.source,
tsgid: event.target
}).then(() => {
createInfoModal(tr("Group permissions have been copied"), tr("The group permissions have been successfully copied.")).open();
}).catch(error => {
if(error instanceof CommandResult && error.id === ErrorID.PERMISSION_ERROR) {
createErrorModal(tr("Failed to copy group permissions"),
tra("Failed to copy group permissions.\nMissing permission {}", connection.permissions.resolveInfo(parseInt(error.json["failed_permid"]))?.name || tr("unknwon"))).open();
return;
}
console.warn(tr("Failed to copy group permissions: %o"), error);
createErrorModal(tr("Failed to copy group permissions"),
tra("Failed to copy group permissions.\n{}", stringifyError(error))).open();
});
});
}

View File

@ -1319,7 +1319,7 @@ function apply_server_groups(connection: ConnectionHandler, editor: AbstractPerm
if(!!text.match(/^[0-9]+$/))
return true;
try {
return atob(text).length >= 20;
return atob(text).length == 20;
} catch(error) {
return false;
}
@ -1349,7 +1349,6 @@ function apply_server_groups(connection: ConnectionHandler, editor: AbstractPerm
return;
}
connection.serverConnection.send_command("servergroupaddclient", {
sgid: current_group.id,
cldbid: dbid
@ -1411,7 +1410,8 @@ function apply_server_groups(connection: ConnectionHandler, editor: AbstractPerm
}
}
function spawnGroupAdd(server_group: boolean, permissions: PermissionManager, valid_name: (name: string, group_type: number) => boolean, callback: (group_name: string, group_type: number) => any) {
/* Attention: This is used by the new permission editor! */
export function spawnGroupAdd(server_group: boolean, permissions: PermissionManager, valid_name: (name: string, group_type: number) => boolean, callback: (group_name: string, group_type: number) => any) {
let modal: Modal;
modal = createModal({
header: tr("Create a new group"),

View File

@ -4,6 +4,8 @@ export const senseless_server_group_permissions: PermissionType[] = [
PermissionType.B_CHANNEL_GROUP_INHERITANCE_END
];
/* TODO: All the needed permissions! */
const filter = (text, ignore_type) => Object.keys(PermissionType)
.filter(e => e.toLowerCase().substr(ignore_type ? 1 : 0).startsWith(text)).map(e => PermissionType[e]);

View File

@ -0,0 +1,193 @@
@import "../../../../css/static/mixin";
@import "../../../../css/static/properties";
html:root {
--modal-permissions-header-text: #e1e1e1;
--modal-permissions-header-background: #19191b;
--modal-permissions-header-hover: #4e4e4e;
--modal-permissions-header-selected: #0073d4;
--modal-permission-right: #303036;
--modal-permission-left: #222226;
--modal-permissions-entry-hover: #28282c;
--modal-permissions-entry-selected: #111111;
--modal-permissions-current-group: #101012;
--modal-permissions-buttons-background: #0f0f0f;
--modal-permissions-buttons-hover: #262626;
--modal-permissions-buttons-disabled: #1b1b1b;
--modal-permissions-seperator: #1e1e1e; /* the seperator for the "enter a unique id" and "client info" part */
--modal-permissions-container-seperator: #222224; /* the seperator between left and right */
--modal-permissions-icon-select: #121213;
--modal-permissions-icon-select-border: #0d0d0d;
--modal-permissions-icon-select-hover: #17171a;
--modal-permissions-icon-select-hover-border: #333333;
--modal-permission-no-permnissions:#18171c;
--modal-permissions-table-border: #1e2025;
--modal-permissions-table-header: #303036;
--modal-permissions-table-row-odd: #303036;
--modal-permissions-table-row-even: #25252a;
--modal-permissions-table-row-hover: #343a47;
--modal-permissions-table-header-text: #e1e1e1;
--modal-permissions-table-row-text: #535455;
--modal-permissions-table-entry-active-text: #e1e1e1;
--modal-permissions-table-entry-group-text: #e1e1e1;
--modal-permissions-table-input: #e1e1e1;
--modal-permissions-table-input-focus: #3f7dbf;
}
.container {
@include user-select(none);
display: flex;
flex-direction: row;
justify-content: stretch;
width: 1000em;
min-width: 20em;
max-width: 100%;
flex-shrink: 1;
flex-grow: 1;
.contextContainer {
display: flex;
flex-direction: column;
justify-content: stretch;
&.left {
min-width: 10em;
min-height: 10em;
overflow: hidden;
background-color: var(--modal-permission-left);
}
&.right {
min-width: 30em;
background-color: var(--modal-permission-right);
}
}
.header {
flex-shrink: 0;
flex-grow: 0;
height: 4em;
background-color: var(--modal-permissions-header-background);
color: var(--modal-permissions-header-text);
display: flex;
flex-direction: row;
justify-content: stretch;
.entry {
flex-grow: 1;
flex-shrink: 1;
text-align: center;
height: 100%;
padding-left: .5em;
padding-right: .5em;
display: flex;
flex-direction: column;
justify-content: space-around;
}
&.tabSelector {
min-width: 8em;
.entry {
position: relative;
overflow: hidden;
cursor: pointer;
padding-bottom: 2px;
a {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
&:hover {
border: none;
border-bottom: 2px solid var(--modal-permissions-header-hover);
padding-bottom: 0;
&:before {
position: absolute;
content: '';
margin-right: -10em;
margin-left: -10em;
margin-bottom: -.2em;
bottom: 0;
height: 100%;
width: calc(100% + 20em);
box-shadow: inset 0px -1.2em 3em -20px var(--modal-permissions-header-hover);
}
}
&.selected {
border: none;
border-bottom: 2px solid var(--modal-permissions-header-selected);
padding-bottom: 0;
&:before {
position: absolute;
content: '';
margin-right: -10em;
margin-left: -10em;
margin-bottom: -.2em;
bottom: 0;
height: 100%;
width: calc(100% + 20em);
box-shadow: inset 0px -1.2em 3em -20px var(--modal-permissions-header-selected);
}
}
}
}
&.activeTabInfo {
min-width: 6em;
font-weight: bold;
.entry {
overflow: hidden;
a {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
> * {
font-size: 1.5em;
}
}
}
.body {
flex-grow: 1;
flex-shrink: 1;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,458 @@
@import "../../../../css/static/mixin";
@import "../../../../css/static/properties";
html:root {
--modal-permission-loading: #666;
--modal-permission-error: #666161;
}
.containerMenuBar {
padding-top: .5em;
padding-left: .5em;
padding-right: .5em;
flex-shrink: 0;
flex-grow: 0;
height: 3em;
box-sizing: content-box;
display: flex;
flex-direction: row;
justify-content: stretch;
.clients {
width: 12em;
min-width: 3em;
flex-shrink: 1;
flex-grow: 0;
margin-right: 1em;
align-self: center;
&.hidden {
display: none;
}
}
.filter {
flex-grow: 1;
flex-shrink: 1;
min-width: 4em;
align-self: center;
padding-top: 0 !important;
margin-bottom: 0 !important;
.label {
top: 0.5em;
}
&:focus-within .label, .labelFloating {
top: -0.4em!important;
}
}
.options {
display: flex;
flex-direction: column;
justify-content: center;
margin-left: .5em;
font-size: .9em;
padding-top: .8em; /* since we've only one switch currently */
}
.containerIconSelect {
position: relative;
height: 2.5em;
border-radius: .2em;
margin-left: 1em;
display: flex;
flex-direction: row;
justify-content: flex-end;
cursor: pointer;
background-color: var(--modal-permissions-icon-select);
border: 1px solid var(--modal-permissions-icon-select-border);
.preview {
height: 100%;
width: 3em;
border: none;
border-right: 1px solid var(--modal-permissions-icon-select-border);
display: flex;
flex-direction: column;
justify-content: space-around;
> div {
align-self: center;
}
> img {
align-self: center;
width: 1em;
height: 1em;
}
@include transition(border-color $button_hover_animation_time ease-in-out);
}
.containerDropdown {
position: relative;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: space-around;
height: 100%;
width: 1.5em;
.button {
text-align: center;
:global(.arrow) {
border-color: var(--text);
}
}
.dropdown {
display: none;
position: absolute;
width: max-content;
top: calc(2.5em - 2px);
flex-direction: column;
justify-content: flex-start;
background-color: var(--modal-permissions-icon-select);
border: 1px solid var(--modal-permissions-icon-select-border);
border-radius: .2em 0 .2em .2em;
right: -1px;
z-index: 10;
.entry {
padding: .5em;
&:not(:last-of-type) {
border: none;
border-bottom: 1px solid var(--modal-permissions-icon-select-border);
}
&:hover {
background-color: var(--modal-permissions-icon-select-hover);
}
}
}
&:hover {
border-bottom-right-radius: 0;
.dropdown {
display: flex;
}
}
}
&:hover {
background-color: var(--modal-permissions-icon-select-hover);
border-color: var(--modal-permissions-icon-select-hover-border);
.preview {
border-color: var(--modal-permissions-icon-select-hover-border);
}
}
@include transition(border-color $button_hover_animation_time ease-in-out);
}
}
.permissionTable {
@include user-select(none);
position: relative;
display: flex;
flex-direction: column;
justify-content: stretch;
min-height: 6em;
height: 50em;
flex-shrink: 1;
flex-grow: 1;
padding-left: .5em;
padding-right: .5em;
.row {
display: flex;
flex-direction: row;
justify-content: stretch;
flex-grow: 0;
flex-shrink: 0;
width: 100%;
height: 2em;
border: none;
border-bottom: 1px solid var(--modal-permissions-table-border);
@mixin fixed-column($name, $width) {
.column#{$name} {
display: flex;
flex-direction: row;
justify-content: stretch;
flex-grow: 0;
flex-shrink: 0;
width: $width;
align-items: center;
padding-left: 1em;
border: none;
border-right: 1px solid var(--modal-permissions-table-border);
overflow: hidden;
a {
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
@include fixed-column(Name, 6em);
@include fixed-column(Value, 6em);
@include fixed-column(Skip, 5em);
@include fixed-column(Negate, 5em);
@include fixed-column(Granted, 6em);
.columnName {
flex-grow: 1;
flex-shrink: 1;
.groupName {
margin-left: .5em;
}
}
.columnGranted {
border-right: none;
}
&.active {
color: var(--modal-permissions-table-entry-active-text)!important;
}
&.group {
color: var(--modal-permissions-table-entry-group-text)!important;
font-weight: bold;
:global(.arrow) {
cursor: pointer;
border-color: var(--modal-permissions-table-entry-active-text);
}
}
&.permission {}
}
.header {
.row {
background-color: var(--modal-permissions-table-header);
color: var(--modal-permissions-table-header-text);
font-weight: bold;
.columnGranted {
margin-right: .5em;
}
}
}
.body {
flex-grow: 1;
flex-shrink: 1;
position: relative;
min-height: 6em; /* TODO: Width */
display: flex;
flex-direction: column;
justify-content: stretch;
overflow-y: scroll;
overflow-x: auto;
@include chat-scrollbar-vertical();
@include chat-scrollbar-horizontal();
.row {
position: absolute;
left: 0;
right: 0;
color: var(--modal-permissions-table-row-text);
background-color: var(--modal-permissions-table-row-odd);
&.even {
background-color: var(--modal-permissions-table-row-even);
}
&:hover {
background-color: var(--modal-permissions-table-row-hover);
}
input[type="number"] {
color: var(--modal-permissions-table-input);
outline: none;
background: transparent;
border: none;
height: 1.5em;
width: 5em; /* the column width minus one */
/* fix the column padding */
padding-left: 1em;
margin-left: -.5em; /* have a bit of space on both sides */
border-bottom: 2px solid transparent;
@include transition(border-bottom-color $button_hover_animation_time ease-in-out);
&:not(.applying):focus {
border-bottom-color: var(--modal-permissions-table-input-focus);
}
&.applying {
padding-left: 0;
}
}
/* We cant use this effect here because the odd/even effect would be a bit crazy then */
//@include transition(background-color $button_hover_animation_time ease-in-out);
}
.spaceAllocator {
width: 100%;
flex-shrink: 0;
flex-grow: 0;
}
.overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
justify-content: flex-start;
z-index: 1;
background-color: var(--modal-permission-right);
padding-top: 2em;
a {
text-align: center;
font-size: 1.6em;
color: var(--modal-permission-loading);
}
&.hidden {
opacity: 0;
pointer-events: none;
}
&.error {
a {
color: var(--modal-permission-error);
}
}
}
}
.overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
justify-content: center;
z-index: 1;
background: var(--modal-permission-right);
&.hidden {
display: none;
}
&.unset {}
&.noPermissions {
justify-content: flex-start;
padding-top: 2em;
font-size: 1em;
a {
text-align: center;
font-size: 1.5em;
color: var(--modal-permission-no-permnissions);
}
}
}
}
.containerFooter {
display: flex;
flex-direction: row;
justify-content: flex-end;
padding: .5em;
button {
display: flex;
flex-direction: row;
justify-content: center;
* {
align-self: center;
}
div {
margin-right: .5em;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,312 @@
@import "../../../../css/static/mixin";
@import "../../../../css/static/properties";
.containerList {
color: var(--text);
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-start;
overflow: auto;
@include chat-scrollbar-vertical();
@include chat-scrollbar-horizontal();
width: 100%;
flex-grow: 1;
flex-shrink: 1;
.entries {
display: flex;
flex-direction: column;
justify-content: flex-start;
height: max-content;
min-width: 100%;
width: max-content;
.entry {
padding-left: .25em;
flex-shrink: 0;
flex-grow: 0;
display: flex;
flex-direction: row;
justify-content: flex-start;
cursor: pointer;
width: 100%;
&:hover {
background-color: var(--modal-permissions-entry-hover);
}
&.selected {
background-color: var(--modal-permissions-entry-selected);
}
> * {
align-self: center;
}
@include transition(background-color .25s ease-in-out);
}
}
}
.sideContainer {
height: 0; /* will expend due to flex grow */
width: 100%;
flex-grow: 1;
&.hidden {
display: none;
}
}
.containerServerGroups, .containerChannelGroups {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: stretch;
.list {
flex-grow: 1;
flex-shrink: 1;
min-height: 2em;
.name {
margin-left: 0.25em;
}
:global(.icon_em) {
font-size: 16px;
}
}
.buttons {
position: relative;
display: flex;
flex-direction: row;
justify-content: stretch;
flex-grow: 0;
flex-shrink: 0;
height: 2.5em;
width: 100%;
.button {
display: flex;
flex-direction: row;
justify-content: space-around;
flex-grow: 1;
flex-shrink: 1;
cursor: pointer;
background-color: var(--modal-permissions-buttons-background);
&:hover {
background-color: var(--modal-permissions-buttons-hover);
}
&.disabled {
background-color: var(--modal-permissions-buttons-disabled);
}
@include transition(background-color .25s ease-in-out);
img {
width: 2.2em;
height: 2.2em;
align-self: center;
}
}
}
$animation_length: .3s;
.containerGroupList {
flex-grow: 1;
flex-shrink: 1;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
justify-content: stretch;
&.hidden {
@include transform(translateX(-100%));
}
@include transition($animation_length ease-in-out);
}
.containerClientList {
flex-grow: 1;
flex-shrink: 1;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
justify-content: stretch;
.selectedGroup {
flex-shrink: 0;
flex-grow: 0;
z-index: 2;
display: flex;
flex-direction: row;
justify-content: stretch;
background-color: var(--modal-permissions-current-group);
color: var(--text);
padding-left: .25em;
height: 1.5em;
font-size: 1.125em;
.icon {
display: flex;
flex-direction: column;
justify-content: space-around;
height: 100%;
margin-right: .25em;
}
.name {
flex-grow: 1;
flex-shrink: 1;
height: 1.5em;
}
}
.overlay {
z-index: 1;
position: absolute;
display: flex;
flex-direction: column;
justify-content: flex-start;
padding-top: 2em;
top: 0;
left: 0;
right: 0;
bottom: 0;
color: #666666;
background-color: var(--modal-permission-left);
a {
text-align: center;
font-size: 1.2em;
align-self: center;
}
&.error {
color: #6b6161;
}
&.hidden {
display: none;
}
}
&.hidden {
@include transform(translateX(100%));
}
@include transition($animation_length ease-in-out);
}
}
.containerChannels {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: stretch;
}
.containerClient {
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.containerChannelClient {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: stretch;
}
.containerChannels, .containerClient, .containerChannelClient {
.listChannels {
flex-grow: 1;
flex-shrink: 1;
min-height: 2em;
.name {
margin-left: 0.25em;
}
}
.clientSelect {
flex-grow: 0;
flex-shrink: 0;
padding: .25em;
hr {
border: none;
border-top: 2px solid var(--modal-permissions-seperator);
margin-left: -.25em;
margin-right: -.25em;
}
.inputField {
margin-top: -.75em;
}
.infoField {
margin-bottom: .25em;
margin-top: -.5em;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -27,6 +27,7 @@ export class Button extends ReactComponentBase<ButtonProperties, ButtonState> {
render() {
if(this.props.hidden)
return null;
return (
<button
className={this.classList(
@ -35,7 +36,7 @@ export class Button extends ReactComponentBase<ButtonProperties, ButtonState> {
cssStyle["type-" + this.props.type] || cssStyle["type-normal"],
this.props.className
)}
disabled={this.state.disabled || this.props.disabled}
disabled={typeof this.state.disabled === "boolean" ? this.state.disabled : this.props.disabled}
onClick={this.props.onClick}
>
{this.props.children}

View File

@ -0,0 +1,36 @@
@import "../../../css/static/mixin";
@import "../../../css/static/properties";
$separator_thickness: 5px;
$animation_separator_length: .1s;
.separator {
@include transition(all $animation_separator_length ease-in-out);
background: #1e1e1e;
display: block;
flex-grow: 0!important;
flex-shrink: 0!important;
&.vertical {
height: $separator_thickness;
min-height: $separator_thickness!important;
max-height: $separator_thickness!important;
//width: 100%;
cursor: row-resize;
}
&.horizontal {
width: $separator_thickness;
min-width: $separator_thickness!important;
max-width: $separator_thickness!important;
//height: 100%;
cursor: col-resize;
}
}
.documentActiveClass {
@include user-select(none);
}

View File

@ -0,0 +1,158 @@
import * as React from "react";
import {settings} from "tc-shared/settings";
const cssStyle = require("./ContextDivider.scss");
export interface ContextDividerProperties {
id: string;
direction: "vertical" | "horizontal";
defaultValue: number; /* [0;100] */
separatorClassName?: string;
separatorActiveClassName?: string;
children: [React.ReactElement, React.ReactElement];
}
export interface ContextDividerState {
active: boolean;
}
export class ContextDivider extends React.Component<ContextDividerProperties, ContextDividerState> {
private readonly refSeparator = React.createRef<HTMLDivElement>();
private readonly listenerMove;
private readonly listenerUp;
private value;
constructor(props) {
super(props);
this.state = {
active: false
};
this.value = this.props.defaultValue;
try {
const config = JSON.parse(settings.global("separator-settings-" + this.props.id));
if(typeof config.value !== "number")
throw "Invalid value";
this.value = config.value;
} catch (e) { }
this.listenerMove = (event: MouseEvent | TouchEvent) => {
const separator = this.refSeparator.current;
if(!separator) {
this.setState({ active: false });
return;
}
const parentBounds = separator.parentElement.getBoundingClientRect();
const min = this.props.direction === "horizontal" ? parentBounds.left : parentBounds.top;
const max = this.props.direction === "horizontal" ? parentBounds.left + parentBounds.width : parentBounds.top + parentBounds.height;
const current = event instanceof MouseEvent ?
(this.props.direction === "horizontal" ? event.pageX : event.pageY) :
(this.props.direction === "horizontal" ? event.touches[event.touches.length - 1].clientX : event.touches[event.touches.length - 1].clientY);
/*
const previous_offset = previous_element.offset();
const next_offset = next_element.offset();
const min = vertical ? Math.min(previous_offset.left, next_offset.left) : Math.min(previous_offset.top, next_offset.top);
const max = vertical ?
Math.max(previous_offset.left + previous_element.width(), next_offset.left + next_element.width()) :
Math.max(previous_offset.top + previous_element.height(), next_offset.top + next_element.height());
*/
if(current < min) {
this.value = 0;
} else if(current < max) {
const x_offset = current - min;
const x_offset_max = max - min;
this.value = x_offset * 100 / x_offset_max;
} else {
this.value = 100;
}
settings.changeGlobal("separator-settings-" + this.props.id, JSON.stringify({
value: this.value
}));
this.applySeparator(separator.previousSibling as HTMLElement, separator.nextSibling as HTMLElement);
};
this.listenerUp = () => this.stopMovement();
}
render() {
let separatorClassNames = cssStyle.separator + " " + (this.props.separatorClassName || "");
if(this.props.direction === "vertical")
separatorClassNames += " " + cssStyle.vertical;
else
separatorClassNames += " " + cssStyle.horizontal;
if(this.state.active && this.props.separatorClassName)
separatorClassNames += " " + this.props.separatorClassName;
return [
this.props.children[0],
<div key={"context-separator"} ref={this.refSeparator} className={separatorClassNames} onMouseDown={e => this.startMovement(e)} onTouchStart={e => this.startMovement(e)} />,
this.props.children[1]
];
}
componentDidMount(): void {
const separator = this.refSeparator.current;
if(!separator) return;
this.applySeparator(separator.previousSibling as HTMLElement, separator.nextSibling as HTMLElement);
}
componentWillUnmount(): void {
this.stopMovement();
}
private startMovement(event: React.MouseEvent | React.TouchEvent) {
this.setState({ active: true });
document.addEventListener('mousemove', this.listenerMove);
document.addEventListener('touchmove', this.listenerMove);
document.addEventListener('mouseup', this.listenerUp);
document.addEventListener('touchend', this.listenerUp);
document.addEventListener('touchcancel', this.listenerUp);
document.documentElement.classList.add(cssStyle.documentActiveClass);
this.listenerMove(event.nativeEvent);
}
private stopMovement() {
this.setState({ active: false });
document.removeEventListener('mousemove', this.listenerMove);
document.removeEventListener('touchmove', this.listenerMove);
document.removeEventListener('mouseup', this.listenerUp);
document.removeEventListener('touchend', this.listenerUp);
document.removeEventListener('touchcancel', this.listenerUp);
document.documentElement.classList.remove(cssStyle.documentActiveClass);
}
private applySeparator(previousElement: HTMLElement, nextElement: HTMLElement) {
if(!this.refSeparator.current || !previousElement || !nextElement)
return;
if(this.props.direction === "horizontal") {
const center = this.refSeparator.current.clientWidth;
previousElement.style.width = `calc(${this.value}% - ${center / 2}px)`;
nextElement.style.width = `calc(${100 - this.value}% - ${center / 2}px)`;
} else {
const center = this.refSeparator.current.clientHeight;
previousElement.style.height = `calc(${this.value}% - ${center / 2}px)`;
nextElement.style.height = `calc(${100 - this.value}% - ${center / 2}px)`;
}
}
}

View File

@ -39,12 +39,12 @@ export class LocalIconRenderer extends React.Component<LoadedIconRenderer, {}> {
render() {
const icon = this.props.icon;
if(!icon || icon.status === "empty" || icon.status === "destroyed")
return <div className={"icon-container icon-empty"} title={this.props.title} />;
return <div key={"empty"} className={"icon-container icon-empty"} title={this.props.title} />;
else if(icon.status === "loaded") {
if(icon.icon_id >= 0 && icon.icon_id <= 1000) {
if(icon.icon_id === 0)
return <div className={"icon-container icon-empty"} title={this.props.title} />;
return <div className={"icon_em client-group_" + icon.icon_id} />;
return <div key={"loaded-empty"} className={"icon-container icon-empty"} title={this.props.title} />;
return <div key={"loaded"} className={"icon_em client-group_" + icon.icon_id} />;
}
return <div key={"icon"} className={"icon-container"}><img style={{ maxWidth: "100%", maxHeight: "100%" }} src={icon.loaded_url} alt={this.props.title || ("icon " + icon.icon_id)} /></div>;
} else if(icon.status === "loading")
@ -60,4 +60,9 @@ export class LocalIconRenderer extends React.Component<LoadedIconRenderer, {}> {
componentWillUnmount(): void {
this.props.icon?.status_change_callbacks.remove(this.callback_state_update);
}
componentDidUpdate(prevProps: Readonly<LoadedIconRenderer>, prevState: Readonly<{}>, snapshot?: any): void {
prevProps.icon?.status_change_callbacks.remove(this.callback_state_update);
this.props.icon?.status_change_callbacks.push(this.callback_state_update);
}
}

View File

@ -2,33 +2,33 @@
@import "../../../css/static/properties";
html:root {
--input-field-border: #111112;
--input-field-background: #121213;
--input-field-text: #b3b3b3;
--input-field-placeholder: #606060;
--boxed-input-field-border: #111112;
--boxed-input-field-background: #121213;
--boxed-input-field-text: #b3b3b3;
--boxed-input-field-placeholder: #606060;
--input-field-disabled-background: #1a1819;
--boxed-input-field-disabled-background: #1a1819;
--input-field-focus-border: #111112;
--input-field-focus-background: #121213;
--input-field-focus-text: #b3b3b3;
--boxed-input-field-focus-border: #111112;
--boxed-input-field-focus-background: #121213;
--boxed-input-field-focus-text: #b3b3b3;
--input-field-invalid-border: #721c1c;
--input-field-invalid-background: #180d0d;
--input-field-invalid-text: #b3b3b3;
--boxed-input-field-invalid-border: #721c1c;
--boxed-input-field-invalid-background: #180d0d;
--boxed-input-field-invalid-text: #b3b3b3;
}
.container {
.containerBoxed {
border-radius: .2em;
border: 1px solid var(--input-field-border);
border: 1px solid var(--boxed-input-field-border);
background-color: var(--input-field-background);
background-color: var(--boxed-input-field-background);
display: flex;
flex-direction: row;
justify-content: stretch;
color: var(--input-field-text);
color: var(--boxed-input-field-text);
&.size-normal {
height: 2em;
@ -43,7 +43,7 @@ html:root {
}
@include placeholder(&) {
color: var(--input-field-placeholder);
color: var(--boxed-input-field-placeholder);
};
.prefix {
@ -66,18 +66,18 @@ html:root {
}
&.is-invalid {
background-color: var(--input-field-invalid-background);
border-color: var(--input-field-invalid-border);
color: var(--input-field-invalid-text);
background-color: var(--boxed-input-field-invalid-background);
border-color: var(--boxed-input-field-invalid-border);
color: var(--boxed-input-field-invalid-text);
background-image: unset!important;
}
&:focus, &:focus-within {
background-color: var(--input-field-focus-background);
border-color: var(--input-field-focus-border);
background-color: var(--boxed-input-field-focus-background);
border-color: var(--boxed-input-field-focus-border);
color: var(--input-field-focus-text);
color: var(--boxed-input-field-focus-text);
.prefix {
width: 0;
@ -123,7 +123,7 @@ html:root {
}
&.disabled, &:disabled {
background-color: var(--input-field-disabled-background);
background-color: var(--boxed-input-field-disabled-background);
}
&.noRightIcon {
@ -139,4 +139,136 @@ html:root {
}
@include transition($button_hover_animation_time ease-in-out);
}
.containerFlat {
position: relative;
padding-top: 1.75rem; /* the label above (might be floating) */
margin-bottom: 1rem; /* for invalid label/help label */
label {
color: #999999;
top: 1rem;
left: 0;
font-size: .75rem;
position: absolute;
pointer-events: none;
transition: all .3s ease;
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
&.type-floating {
will-change: left, top, contents;
color: #999999;
top: 2.42rem;
font-size: 1rem;
}
&.type-static {
top: 1rem;
font-size: .75rem;
}
@include transition(color $button_hover_animation_time ease-in-out, top $button_hover_animation_time ease-in-out, font-size $button_hover_animation_time ease-in-out);
}
&:focus-within, &.isFilled {
label {
color: #3c74a2;
&.type-floating {
font-size: .75rem;
top: 1rem;
}
}
}
input, select {
display: block;
height: 2.25em;
width: 100%;
font-size: 1rem;
line-height: 1.5;
color: #cdd1d0;
background: no-repeat bottom, 50% calc(100% - 1px);
background-image: linear-gradient(0deg, #008aff 2px, rgba(0, 150, 136, 0) 0), linear-gradient(0deg, #393939 1px, transparent 0);
background-clip: padding-box;
background-size: 0 100%, 100% 100%;
border: none;
border-radius: 0;
box-shadow: none;
transition: background 0s ease-out;
padding: .4375rem 0;
@include transition(all .15s ease-in-out);
&:focus {
background-size: 100% 100%, 100% 100%;
transition-duration: .3s;
color: #ced3d3;
background-color: transparent;
outline: 0;
}
option, optgroup {
background: #121213;
}
}
.invalidFeedback {
position: absolute;
opacity: 0;
width: 100%;
margin-top: .25rem;
font-size: 80%;
color: #f44336;
@include transition(opacity .25s ease-in-out);
}
&.isInvalid {
input, select {
background-image: linear-gradient(0deg, #d50000 2px,rgba(213,0,0,0) 0),linear-gradient(0deg,rgba(241,1,1,.61) 1px,transparent 0);
}
.invalidFeedback {
opacity: 1;
}
label {
color: #f44336!important;
}
}
.help {
position: absolute;
opacity: 0;
width: 100%;
margin-top: .25rem;
font-size: .75em;
@include transition(opacity .25s ease-in-out);
}
input:focus-within ~ .help, select:focus-within ~ .help {
opacity: 1;
}
}

View File

@ -51,7 +51,7 @@ export class BoxedInputField extends React.Component<BoxedInputFieldProperties,
<div
draggable={false}
className={
cssStyle.container + " " +
cssStyle.containerBoxed + " " +
cssStyle["size-" + (this.props.size || "normal")] +
(this.state.disabled || this.props.disabled ? cssStyle.disabled : "") + " " +
(this.state.isInvalid || this.props.isInvalid ? cssStyle.isInvalid : "") + " " +
@ -102,4 +102,217 @@ export class BoxedInputField extends React.Component<BoxedInputFieldProperties,
if(this.props.onBlur)
this.props.onBlur();
}
}
}
export interface FlatInputFieldProperties {
defaultValue?: string;
placeholder?: string;
className?: string;
label?: string | React.ReactElement;
labelType?: "static" | "floating";
labelClassName?: string;
labelFloatingClassName?: string;
help?: string | React.ReactElement;
helpClassName?: string;
invalidClassName?: string;
disabled?: boolean;
editable?: boolean;
onFocus?: () => void;
onBlur?: () => void;
onChange?: (newValue?: string) => void;
onInput?: (newValue?: string) => void;
finishOnEnter?: boolean;
}
export interface FlatInputFieldState {
filled: boolean;
placeholder?: string;
disabled?: boolean;
editable?: boolean;
isInvalid: boolean;
invalidMessage: string | React.ReactElement;
}
export class FlatInputField extends React.Component<FlatInputFieldProperties, FlatInputFieldState> {
private refInput = React.createRef<HTMLInputElement>();
constructor(props) {
super(props);
this.state = {
isInvalid: false,
filled: !!this.props.defaultValue,
invalidMessage: ""
}
}
render() {
const disabled = typeof this.state.disabled === "boolean" ? this.state.disabled : typeof this.props.disabled === "boolean" ? this.props.disabled : false;
const readOnly = typeof this.state.editable === "boolean" ? !this.state.editable : typeof this.props.editable === "boolean" ? !this.props.editable : false;
const placeholder = typeof this.state.placeholder === "string" ? this.state.placeholder : typeof this.props.placeholder === "string" ? this.props.placeholder : undefined;
return (
<div className={cssStyle.containerFlat + " " + (this.state.isInvalid ? cssStyle.isInvalid : "") + " " + (this.state.filled ? cssStyle.isFilled : "") + " " + (this.props.className || "")}>
{this.props.label ?
<label className={
cssStyle["type-" + (this.props.labelType || "static")] + " " +
(this.props.labelClassName || "") + " " +
(this.props.labelFloatingClassName && this.state.filled ? this.props.labelFloatingClassName : "")}>{this.props.label}</label> : undefined}
<input
defaultValue={this.props.defaultValue}
type={"text"}
ref={this.refInput}
readOnly={readOnly}
disabled={disabled}
placeholder={placeholder}
onFocus={this.props.onFocus}
onBlur={this.props.onBlur}
onChange={() => this.onChange()}
onInput={e => this.props.onInput && this.props.onInput(e.currentTarget.value)}
onKeyPress={e => this.props.finishOnEnter && e.key === "Enter" && this.refInput.current?.blur()}
/>
{this.state.invalidMessage ? <small className={cssStyle.invalidFeedback + " " + (this.props.invalidClassName || "")}>{this.state.invalidMessage}</small> : undefined}
{this.props.help ? <small className={cssStyle.help + " " + (this.props.helpClassName || "")}>{this.props.help}</small> : undefined}
</div>
);
}
private onChange() {
const value = this.refInput.current?.value;
this.setState({ filled: !!value});
this.props.onChange && this.props.onChange(value);
}
value() {
return this.refInput.current?.value;
}
setValue(value: string | undefined) {
this.refInput.current.value = typeof value === "undefined" ? "" : value;
}
inputElement() : HTMLInputElement | undefined {
return this.refInput.current;
}
focus() {
this.refInput.current?.focus();
}
}
export interface FlatSelectProperties {
defaultValue?: string;
value?: string;
className?: string;
label?: string | React.ReactElement;
labelClassName?: string;
help?: string | React.ReactElement;
helpClassName?: string;
invalidClassName?: string;
disabled?: boolean;
editable?: boolean;
onFocus?: () => void;
onBlur?: () => void;
onChange?: (event?: React.ChangeEvent<HTMLSelectElement>) => void;
}
export interface FlatSelectFieldState {
disabled?: boolean;
isInvalid: boolean;
invalidMessage: string | React.ReactElement;
}
export class FlatSelect extends React.Component<FlatSelectProperties, FlatSelectFieldState> {
private refSelect = React.createRef<HTMLSelectElement>();
constructor(props) {
super(props);
this.state = {
isInvalid: false,
invalidMessage: ""
}
}
render() {
const disabled = typeof this.state.disabled === "boolean" ? this.state.disabled : typeof this.props.disabled === "boolean" ? this.props.disabled : false;
return (
<div className={cssStyle.containerFlat + " " + (this.state.isInvalid ? cssStyle.isInvalid : "") + " " + (this.props.className || "")}>
{this.props.label ?
<label className={cssStyle["type-static"] + " " + (this.props.labelClassName || "")}>{this.props.label}</label> : undefined}
<select
ref={this.refSelect}
value={this.props.value}
defaultValue={this.props.defaultValue}
disabled={disabled}
onFocus={this.props.onFocus}
onBlur={this.props.onBlur}
onChange={e => this.props.onChange && this.props.onChange(e)}
>
{this.props.children}
</select>
{this.state.invalidMessage ? <small className={cssStyle.invalidFeedback + " " + (this.props.invalidClassName || "")}>{this.state.invalidMessage}</small> : undefined}
{this.props.help ? <small className={cssStyle.help + " " + (this.props.helpClassName || "")}>{this.props.help}</small> : undefined}
</div>
);
}
selectElement() : HTMLSelectElement | undefined {
return this.refSelect.current;
}
focus() {
this.refSelect.current?.focus();
}
}

View File

@ -170,6 +170,10 @@ class ModalImpl extends React.PureComponent<{ controller: ModalController }, {
}
}
export function spawnReactModal<ModalClass extends Modal, T>(modalClass: new (T) => ModalClass, properties?: T) : ModalController<ModalClass> {
return new ModalController(new modalClass(properties));
export function spawnReactModal<ModalClass extends Modal, A1>(modalClass: new (..._: [A1]) => ModalClass, arg1: A1) : ModalController<ModalClass>;
export function spawnReactModal<ModalClass extends Modal, A1, A2>(modalClass: new (..._: [A1, A2]) => ModalClass, arg1: A1, arg2: A2) : ModalController<ModalClass>;
export function spawnReactModal<ModalClass extends Modal, A1, A2, A3>(modalClass: new (..._: [A1, A2, A3]) => ModalClass, arg1: A1, arg2: A2, arg3: A3) : ModalController<ModalClass>;
export function spawnReactModal<ModalClass extends Modal, A1, A2, A3, A4>(modalClass: new (..._: [A1, A2, A3, A4]) => ModalClass, arg1: A1, arg2: A2, arg3: A3, arg4: A4) : ModalController<ModalClass>;
export function spawnReactModal<ModalClass extends Modal>(modalClass: new (..._: any[]) => ModalClass, ...args: any[]) : ModalController<ModalClass> {
return new ModalController(new modalClass(...args));
}

View File

@ -0,0 +1,139 @@
@import "../../../css/static/properties";
@import "../../../css/static/mixin";
/* general switch look */
.switch {
$ball_outer_width: 1.5em; /* 1.5? */
$ball_inner_width: .4em;
$slider_height: .8em;
$slider_width: 2em;
$slider_border_size: .1em;
position: relative;
display: inline-block;
outline: none;
width: $slider_width;
height: $slider_height;
/* "allocate" space for the slider */
margin-top: ($ball_outer_width - $slider_height) / 2;
margin-bottom: ($ball_outer_width - $slider_height) / 2;
margin-left: $ball_outer_width / 2;
margin-right: $ball_outer_width / 2;
/* fix size */
flex-shrink: 0;
flex-grow: 0;
input {
/* "hide" the actual input node */
opacity: 0;
width: 0;
height: 0;
outline: none;
}
.slider {
pointer-events: all!important;
position: absolute;
cursor: pointer;
outline: none;
top: -$slider_border_size;
left: -$slider_border_size;
right: -$slider_border_size;
bottom: -$slider_border_size;
background-color: #1c1c1c;
border: $slider_border_size solid #262628;
border-radius: 5px;
&:before {
position: absolute;
content: "";
height: $ball_outer_width;
width: $ball_outer_width;
left: - $ball_outer_width / 2;
bottom: -($ball_outer_width - $slider_height) / 2;
background-color: #3d3a3a;
@include transition(.4s);
border-radius: 50%;
box-shadow: 0 0 .2em 1px rgba(0, 0, 0, 0.27);
}
.dot {
position: absolute;
height: $ball_inner_width;
width: $ball_inner_width;
left: -($ball_inner_width / 2);
bottom: $slider_height / 2 - $ball_inner_width / 2;
background-color: #a5a5a5;
box-shadow: 0 0 1em 1px rgba(165, 165, 165, 0.4);
border-radius: 50%;
@include transition(.4s);
}
}
input:focus + .slider {
}
input:checked + .slider {
&:before {
@include transform(translateX($slider_width));
}
.dot {
@include transform(translateX($slider_width));
background-color: #46c0ec;
box-shadow: 0 0 1em 1px #46c0ec;
}
}
}
.container {
display: flex;
flex-direction: row;
justify-content: flex-start;
.label {
margin-left: .25em;
}
&.disabled {
.dot {
background-color: #808080;
box-shadow: 0 0 1em 1px rgba(102, 102, 102, 0.4);
}
input:checked + .slider {
.dot {
background-color: #138db9;
box-shadow: 0 0 1em 1px #138db9;
}
}
.slider {
background-color: #252424;
}
.slider:before {
background-color: #2f2d2d;
}
}
}

View File

@ -0,0 +1,55 @@
import * as React from "react";
const cssStyle = require("./Switch.scss");
export interface SwitchProperties {
initialState: boolean;
className?: string;
label?: string | React.ReactElement;
labelSide?: "right" | "left";
disabled?: boolean;
onChange?: (value: boolean) => void;
onBlur?: () => void;
}
export interface SwitchState {
checked: boolean;
disabled?: boolean;
}
export class Switch extends React.Component<SwitchProperties, SwitchState> {
private readonly ref = React.createRef<HTMLLabelElement>();
constructor(props) {
super(props);
this.state = {
checked: this.props.initialState,
}
}
render() {
const disabled = typeof this.state.disabled === "boolean" ? this.state.disabled : !!this.props.disabled;
return (
<label ref={this.ref} className={cssStyle.container + " " + (this.props.className || "") + " " + (disabled ? cssStyle.disabled : "")} onBlur={this.props.onBlur}>
<div className={cssStyle.switch}>
<input type="checkbox" onChange={e => {
this.setState({ checked: e.currentTarget.checked });
this.props.onChange && this.props.onChange(e.currentTarget.checked);
}} disabled={disabled} checked={this.state.checked} />
<span className={cssStyle.slider}>
<span className={cssStyle.dot} />
</span>
</div>
{this.props.label ? <a className={cssStyle.label}>{this.props.label}</a> : undefined}
</label>
)
}
focus() {
this.ref.current?.focus();
}
}

View File

@ -1,15 +1,11 @@
import * as React from "react";
export class Translatable extends React.Component<{ message: string, children?: never } | { children: string }, { translated: string }> {
export class Translatable extends React.Component<{ message: string, children?: never } | { children: string }, any> {
constructor(props) {
super(props);
this.state = {
translated: /* @tr-ignore */ tr(props.message || props.children)
}
}
render() {
return this.state.translated || "";
return /* @tr-ignore */ tr(typeof this.props.children === "string" ? this.props.children : (this.props as any).message);
}
}

View File

@ -115,7 +115,7 @@ class ClientServerGroupIcons extends ReactComponentBase<ClientServerGroupIconsPr
protected initialize() {
this.group_updated_callback = (event: GroupEvents["notify_properties_updated"]) => {
if(typeof event.updated_properties.iconid !== "undefined" || typeof event.updated_properties.sortid !== "undefined")
if(event.updated_properties.indexOf("sort-id") !== -1 || event.updated_properties.indexOf("icon") !== -1)
this.forceUpdate();
};
}
@ -170,7 +170,7 @@ class ClientChannelGroupIcon extends ReactComponentBase<ClientChannelGroupIconPr
protected initialize() {
this.group_updated_callback = (event: GroupEvents["notify_properties_updated"]) => {
if(typeof event.updated_properties.iconid !== "undefined" || typeof event.updated_properties.sortid !== "undefined")
if(event.updated_properties.indexOf("sort-id") !== -1 || event.updated_properties.indexOf("icon") !== -1)
this.forceUpdate();
};
}
@ -254,6 +254,7 @@ interface ClientNameState {
@BatchUpdateAssignment(BatchUpdateType.CHANNEL_TREE)
@ReactEventHandler<ClientName>(e => e.props.client.events)
class ClientName extends ReactComponentBase<ClientNameProperties, ClientNameState> {
/* FIXME: Update prefix/suffix if a server/channel group updates! */
protected initialize() {
this.state = {} as any;
this.updateGroups(this.state);

View File

@ -9,7 +9,7 @@ import {Sound} from "tc-shared/sound/Sounds";
import {Group} from "tc-shared/permission/GroupManager";
import * as server_log from "tc-shared/ui/frames/server_log";
import {ServerAddress, ServerEntry} from "tc-shared/ui/server";
import {ChannelEntry, ChannelSubscribeMode} from "tc-shared/ui/channel";
import {ChannelEntry, ChannelProperties, ChannelSubscribeMode} from "tc-shared/ui/channel";
import {ClientEntry, LocalClientEntry, MusicClientEntry} from "tc-shared/ui/client";
import {ConnectionHandler, ViewReasonId} from "tc-shared/ConnectionHandler";
import {createChannelModal} from "tc-shared/ui/modal/ModalCreateChannel";
@ -46,7 +46,13 @@ export interface ChannelTreeEvents {
notify_query_view_state_changed: { queries_shown: boolean },
notify_entry_move_begin: {},
notify_entry_move_end: {}
notify_entry_move_end: {},
notify_channel_updated: {
channel: ChannelEntry,
channelProperties: ChannelProperties,
updatedProperties: ChannelProperties
}
}
export class ChannelTreeEntrySelect {
@ -260,6 +266,19 @@ export class ChannelTree {
return this._tag_container;
}
channelsOrdered() : ChannelEntry[] {
const result = [];
const visit = (channel: ChannelEntry) => {
result.push(channel);
channel.child_channel_head && visit(channel.child_channel_head);
channel.channel_next && visit(channel.channel_next);
};
this.channel_first && visit(this.channel_first);
return result;
}
destroy() {
ReactDOM.unmountComponentAtNode(this._tag_container[0]);