feat: add channel management dialogs and styling

feat: implement channel creation, settings, and deletion UI
feat: add RPC methods for channel operations
This commit is contained in:
retoor 2025-12-26 02:05:06 +01:00
parent 401e558011
commit 163828e213
11 changed files with 803 additions and 8 deletions

View File

@ -14,6 +14,14 @@
## Version 1.14.0 - 2025-12-26
Users can now create, configure settings for, and delete channels through dedicated dialog interfaces. Developers access new RPC methods to support these channel management operations.
**Changes:** 9 files, 801 lines
**Languages:** CSS (127 lines), HTML (517 lines), Python (157 lines)
## Version 1.13.0 - 2025-12-24
Improves performance in the balancer, socket service, and cache by removing async locks. Adds database connection injection for testing in the app and updates pytest configuration.

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "Snek"
version = "1.13.0"
version = "1.14.0"
readme = "README.md"
#license = { file = "LICENSE", content-type="text/markdown" }
description = "Snek Chat Application by Molodetz"

View File

@ -429,6 +429,20 @@ a {
margin-top: 0;
}
.sidebar-add-btn {
color: #f05a28;
text-decoration: none;
font-size: 1.2em;
font-weight: bold;
margin-left: 8px;
opacity: 0.7;
transition: opacity 0.2s;
}
.sidebar-add-btn:hover {
opacity: 1;
}
.sidebar ul {
list-style: none;
margin-bottom: 15px;
@ -657,6 +671,119 @@ dialog .dialog-button:disabled:focus {
outline: none;
}
dialog .dialog-form {
display: flex;
flex-direction: column;
gap: 12px;
}
dialog .dialog-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #333;
border-radius: 4px;
background-color: #0f0f0f;
color: #e6e6e6;
font-family: 'Courier New', monospace;
font-size: 14px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
box-sizing: border-box;
}
dialog .dialog-input:focus {
outline: none;
border-color: #f05a28;
box-shadow: 0 0 0 2px rgba(240, 90, 40, 0.2);
}
dialog .dialog-input::placeholder {
color: #555;
}
dialog .dialog-input.error {
border-color: #8b0000;
box-shadow: 0 0 0 2px rgba(139, 0, 0, 0.2);
}
dialog .dialog-checkbox-label {
display: flex;
align-items: center;
gap: 8px;
color: #e6e6e6;
font-size: 14px;
cursor: pointer;
}
dialog .dialog-checkbox-label input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: #f05a28;
}
dialog .dialog-suggestions {
max-height: 150px;
overflow-y: auto;
}
dialog .dialog-suggestion-item {
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s;
}
dialog .dialog-suggestion-item:hover {
background-color: #1a1a1a;
}
dialog .dialog-box-wide {
max-width: 500px;
}
dialog .dialog-label {
display: block;
font-size: 12px;
color: #888;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
dialog .dialog-label-danger {
color: #8b0000;
}
dialog .dialog-divider {
height: 1px;
background-color: #333;
margin: 16px 0;
}
dialog .dialog-danger-zone {
padding: 12px;
border: 1px solid #8b0000;
border-radius: 4px;
background-color: rgba(139, 0, 0, 0.1);
}
dialog .dialog-button.danger {
background-color: #8b0000;
border-color: #8b0000;
color: #fff;
}
dialog .dialog-button.danger:hover {
background-color: #a00000;
border-color: #a00000;
}
dialog .dialog-button.danger:disabled {
background-color: #0a0a0a;
border-color: #222;
color: #555;
cursor: not-allowed;
}
.embed-url-link {
display: flex;
flex-direction: column;

View File

@ -0,0 +1,205 @@
<dialog id="channel-settings-dialog">
<div class="dialog-backdrop">
<div class="dialog-box dialog-box-wide">
<div class="dialog-title"><h2>Channel Settings</h2></div>
<div class="dialog-content">
<div class="dialog-form">
<label class="dialog-label">Name</label>
<input type="text" id="settings-channel-name" class="dialog-input" placeholder="Channel name" autocomplete="off">
<label class="dialog-label">Description</label>
<input type="text" id="settings-channel-description" class="dialog-input" placeholder="Description (optional)" autocomplete="off">
<label class="dialog-checkbox-label">
<input type="checkbox" id="settings-channel-private">
<span>Private channel</span>
</label>
<div class="dialog-divider"></div>
<div class="dialog-danger-zone">
<label class="dialog-label dialog-label-danger">Danger Zone</label>
<button class="dialog-button danger btn-delete-channel">Delete Channel</button>
</div>
</div>
</div>
<div class="dialog-actions">
<button class="dialog-button secondary btn-cancel">Cancel</button>
<button class="dialog-button primary btn-save">Save</button>
</div>
</div>
</div>
</dialog>
<dialog id="delete-channel-dialog">
<div class="dialog-backdrop">
<div class="dialog-box">
<div class="dialog-title"><h2>Delete Channel</h2></div>
<div class="dialog-content">
<p>This action cannot be undone. Type the channel name to confirm:</p>
<div class="dialog-form">
<input type="text" id="delete-channel-confirm" class="dialog-input" placeholder="Channel name" autocomplete="off">
</div>
</div>
<div class="dialog-actions">
<button class="dialog-button secondary btn-cancel">Cancel</button>
<button class="dialog-button danger btn-confirm-delete" disabled>Delete</button>
</div>
</div>
</div>
</dialog>
<script>
class ChannelSettingsDialog {
constructor() {
this.dialog = document.getElementById('channel-settings-dialog');
this.nameInput = document.getElementById('settings-channel-name');
this.descriptionInput = document.getElementById('settings-channel-description');
this.privateInput = document.getElementById('settings-channel-private');
this.saveButton = this.dialog.querySelector('.btn-save');
this.cancelButton = this.dialog.querySelector('.btn-cancel');
this.deleteButton = this.dialog.querySelector('.btn-delete-channel');
this.channelUid = null;
this.channelName = null;
this.isModerator = false;
this._initEventListeners();
}
_initEventListeners() {
this.cancelButton.addEventListener('click', () => this.close());
this.saveButton.addEventListener('click', () => this.save());
this.deleteButton.addEventListener('click', () => this.showDeleteConfirm());
}
async show(channelUid) {
this.channelUid = channelUid;
const channels = await window.app.rpc.getChannels();
const channel = channels.find(c => c.uid === channelUid);
if (!channel) {
alert('Channel not found');
return;
}
this.channelName = channel.name;
this.isModerator = channel.is_moderator;
this.nameInput.value = channel.name.replace(/^#/, '');
this.descriptionInput.value = '';
this.privateInput.checked = false;
if (!this.isModerator) {
this.nameInput.disabled = true;
this.descriptionInput.disabled = true;
this.privateInput.disabled = true;
this.saveButton.disabled = true;
this.deleteButton.disabled = true;
} else {
this.nameInput.disabled = false;
this.descriptionInput.disabled = false;
this.privateInput.disabled = false;
this.saveButton.disabled = false;
this.deleteButton.disabled = false;
}
this.dialog.showModal();
this.nameInput.focus();
}
close() {
this.dialog.close();
}
async save() {
const name = this.nameInput.value.trim();
if (!name) {
this.nameInput.classList.add('error');
return;
}
this.nameInput.classList.remove('error');
this.saveButton.disabled = true;
try {
const result = await window.app.rpc.updateChannel(
this.channelUid,
name,
this.descriptionInput.value.trim() || null,
this.privateInput.checked
);
if (result && result.success) {
this.close();
window.location.reload();
} else if (result && result.error) {
alert(result.error);
}
} catch (err) {
alert('Failed to update channel');
} finally {
this.saveButton.disabled = false;
}
}
showDeleteConfirm() {
this.close();
deleteChannelDialog.show(this.channelUid, this.channelName);
}
}
class DeleteChannelDialog {
constructor() {
this.dialog = document.getElementById('delete-channel-dialog');
this.confirmInput = document.getElementById('delete-channel-confirm');
this.deleteButton = this.dialog.querySelector('.btn-confirm-delete');
this.cancelButton = this.dialog.querySelector('.btn-cancel');
this.channelUid = null;
this.channelName = null;
this._initEventListeners();
}
_initEventListeners() {
this.cancelButton.addEventListener('click', () => this.close());
this.deleteButton.addEventListener('click', () => this.delete());
this.confirmInput.addEventListener('input', () => this.validateInput());
}
validateInput() {
const input = this.confirmInput.value.trim();
const name = this.channelName.replace(/^#/, '');
this.deleteButton.disabled = input !== name;
}
show(channelUid, channelName) {
this.channelUid = channelUid;
this.channelName = channelName;
this.confirmInput.value = '';
this.deleteButton.disabled = true;
this.dialog.showModal();
this.confirmInput.focus();
}
close() {
this.dialog.close();
}
async delete() {
this.deleteButton.disabled = true;
try {
const result = await window.app.rpc.deleteChannel(this.channelUid);
if (result && result.success) {
this.close();
window.location.href = '/';
} else if (result && result.error) {
alert(result.error);
}
} catch (err) {
alert('Failed to delete channel');
} finally {
this.deleteButton.disabled = false;
}
}
}
const channelSettingsDialog = new ChannelSettingsDialog();
const deleteChannelDialog = new DeleteChannelDialog();
function showChannelSettings() {
channelSettingsDialog.show('{{ channel.uid.value }}');
}
</script>

View File

@ -0,0 +1,91 @@
<dialog id="create-channel-dialog">
<div class="dialog-backdrop">
<div class="dialog-box">
<div class="dialog-title"><h2>Create Channel</h2></div>
<div class="dialog-content">
<div class="dialog-form">
<input type="text" id="channel-name-input" class="dialog-input" placeholder="Channel name" autocomplete="off">
<input type="text" id="channel-description-input" class="dialog-input" placeholder="Description (optional)" autocomplete="off">
<label class="dialog-checkbox-label">
<input type="checkbox" id="channel-private-input">
<span>Private channel</span>
</label>
</div>
</div>
<div class="dialog-actions">
<button class="dialog-button secondary btn-cancel">Cancel</button>
<button class="dialog-button primary btn-create">Create</button>
</div>
</div>
</div>
</dialog>
<script>
class CreateChannelDialog {
constructor() {
this.dialog = document.getElementById('create-channel-dialog');
this.nameInput = document.getElementById('channel-name-input');
this.descriptionInput = document.getElementById('channel-description-input');
this.privateInput = document.getElementById('channel-private-input');
this.createButton = this.dialog.querySelector('.btn-create');
this.cancelButton = this.dialog.querySelector('.btn-cancel');
this._initEventListeners();
}
_initEventListeners() {
this.cancelButton.addEventListener('click', () => this.close());
this.createButton.addEventListener('click', () => this.create());
this.nameInput.addEventListener('keyup', (e) => {
if (e.key === 'Enter') this.create();
});
}
show() {
this.nameInput.value = '';
this.descriptionInput.value = '';
this.privateInput.checked = false;
this.dialog.showModal();
this.nameInput.focus();
}
close() {
this.dialog.close();
}
async create() {
const name = this.nameInput.value.trim();
if (!name) {
this.nameInput.classList.add('error');
return;
}
this.nameInput.classList.remove('error');
this.createButton.disabled = true;
try {
const result = await window.app.rpc.createChannel(
name,
this.descriptionInput.value.trim() || null,
this.privateInput.checked
);
if (result && result.uid) {
this.close();
window.location.href = `/channel/${result.uid}.html`;
} else if (result && result.error) {
this.nameInput.classList.add('error');
this.nameInput.title = result.error;
}
} catch (err) {
this.nameInput.classList.add('error');
this.nameInput.title = 'Failed to create channel';
} finally {
this.createButton.disabled = false;
}
}
}
const createChannelDialog = new CreateChannelDialog();
function showCreateChannel() {
createChannelDialog.show();
}
</script>

View File

@ -32,10 +32,30 @@
command: "/img-gen",
description: "Generate an image"
},
{
{
command: "/live",
description: "Toggle live typing mode"
}
description: "Toggle live typing mode"
},
{
command: "/create",
description: "Create a new channel"
},
{
command: "/invite",
description: "Invite a user to this channel"
},
{
command: "/leave",
description: "Leave this channel"
},
{
command: "/settings",
description: "Channel settings (moderators)"
},
{
command: "/container",
description: "Manage channel container"
}
];
constructor() {

View File

@ -0,0 +1,116 @@
<dialog id="invite-dialog">
<div class="dialog-backdrop">
<div class="dialog-box">
<div class="dialog-title"><h2>Invite User</h2></div>
<div class="dialog-content">
<div class="dialog-form">
<input type="text" id="invite-username-input" class="dialog-input" placeholder="Username" autocomplete="off">
<div id="invite-user-suggestions" class="dialog-suggestions"></div>
</div>
</div>
<div class="dialog-actions">
<button class="dialog-button secondary btn-cancel">Cancel</button>
<button class="dialog-button primary btn-invite">Invite</button>
</div>
</div>
</div>
</dialog>
<script>
class InviteDialog {
constructor() {
this.dialog = document.getElementById('invite-dialog');
this.usernameInput = document.getElementById('invite-username-input');
this.suggestions = document.getElementById('invite-user-suggestions');
this.inviteButton = this.dialog.querySelector('.btn-invite');
this.cancelButton = this.dialog.querySelector('.btn-cancel');
this.channelUid = null;
this._searchTimeout = null;
this._initEventListeners();
}
_initEventListeners() {
this.cancelButton.addEventListener('click', () => this.close());
this.inviteButton.addEventListener('click', () => this.invite());
this.usernameInput.addEventListener('keyup', (e) => {
if (e.key === 'Enter') {
this.invite();
} else {
this._debounceSearch();
}
});
}
_debounceSearch() {
clearTimeout(this._searchTimeout);
this._searchTimeout = setTimeout(() => this._search(), 300);
}
async _search() {
const query = this.usernameInput.value.trim();
if (query.length < 2) {
this.suggestions.innerHTML = '';
return;
}
try {
const users = await window.app.rpc.searchUser(query);
this.suggestions.innerHTML = '';
users.forEach(username => {
const div = document.createElement('div');
div.className = 'dialog-suggestion-item';
div.textContent = username;
div.addEventListener('click', () => {
this.usernameInput.value = username;
this.suggestions.innerHTML = '';
});
this.suggestions.appendChild(div);
});
} catch (err) {
this.suggestions.innerHTML = '';
}
}
show(channelUid) {
this.channelUid = channelUid;
this.usernameInput.value = '';
this.suggestions.innerHTML = '';
this.dialog.showModal();
this.usernameInput.focus();
}
close() {
this.dialog.close();
}
async invite() {
const username = this.usernameInput.value.trim();
if (!username) {
this.usernameInput.classList.add('error');
return;
}
this.usernameInput.classList.remove('error');
this.inviteButton.disabled = true;
try {
const result = await window.app.rpc.inviteUser(this.channelUid, username);
if (result && result.success) {
this.close();
} else if (result && result.error) {
this.usernameInput.classList.add('error');
this.usernameInput.title = result.error;
}
} catch (err) {
this.usernameInput.classList.add('error');
this.usernameInput.title = 'Failed to invite user';
} finally {
this.inviteButton.disabled = false;
}
}
}
const inviteDialog = new InviteDialog();
function showInvite() {
inviteDialog.show('{{ channel.uid.value }}');
}
</script>

View File

@ -0,0 +1,65 @@
<dialog id="leave-channel-dialog">
<div class="dialog-backdrop">
<div class="dialog-box">
<div class="dialog-title"><h2>Leave Channel</h2></div>
<div class="dialog-content">
<p>Are you sure you want to leave this channel?</p>
</div>
<div class="dialog-actions">
<button class="dialog-button secondary btn-cancel">Cancel</button>
<button class="dialog-button primary btn-leave">Leave</button>
</div>
</div>
</div>
</dialog>
<script>
class LeaveChannelDialog {
constructor() {
this.dialog = document.getElementById('leave-channel-dialog');
this.leaveButton = this.dialog.querySelector('.btn-leave');
this.cancelButton = this.dialog.querySelector('.btn-cancel');
this.channelUid = null;
this._initEventListeners();
}
_initEventListeners() {
this.cancelButton.addEventListener('click', () => this.close());
this.leaveButton.addEventListener('click', () => this.leave());
}
show(channelUid) {
this.channelUid = channelUid;
this.dialog.showModal();
this.cancelButton.focus();
}
close() {
this.dialog.close();
}
async leave() {
this.leaveButton.disabled = true;
try {
const result = await window.app.rpc.leaveChannel(this.channelUid);
if (result && result.success) {
this.close();
window.location.href = '/';
} else if (result && result.error) {
alert(result.error);
}
} catch (err) {
alert('Failed to leave channel');
} finally {
this.leaveButton.disabled = false;
}
}
}
const leaveChannelDialog = new LeaveChannelDialog();
function showLeaveChannel() {
leaveChannelDialog.show('{{ channel.uid.value }}');
}
</script>

View File

@ -11,7 +11,7 @@
</ul>
{% if channels %}
<h2 class="no-select">Channels</h2>
<h2 class="no-select">Channels <a href="#" class="sidebar-add-btn" onclick="event.preventDefault(); showCreateChannel();" title="Create channel">+</a></h2>
<ul>
{% for channel in channels if not channel['is_private'] %}
<li id="channel-list-item-{{channel['uid']}}"><a class="no-select" {% if channel['color'] %}style="color: {{channel['color']}}"{% endif %} href="/channel/{{channel['uid']}}.html">{{channel['name']}} <span class="message-count">{% if channel['new_count'] %}({{ channel['new_count'] }}){% endif %}</span></a></li>

View File

@ -39,6 +39,10 @@
{% include "dialog_container.html" %}
{% include "dialog_help.html" %}
{% include "dialog_online.html" %}
{% include "dialog_create_channel.html" %}
{% include "dialog_invite.html" %}
{% include "dialog_leave_channel.html" %}
{% include "dialog_channel_settings.html" %}
<script type="module">
import {app} from "/app.js";
@ -54,9 +58,11 @@ chatInputField.autoCompletions = {
"/clear": () => { messagesContainer.innerHTML = ''; },
"/live": () => { chatInputField.liveType = !chatInputField.liveType; },
"/help": showHelp,
"/container": async() =>{
containerDialog.openWithStatus()
}
"/container": async() => { containerDialog.openWithStatus(); },
"/create": showCreateChannel,
"/invite": showInvite,
"/leave": showLeaveChannel,
"/settings": showChannelSettings
};
// --- Throttle utility ---

View File

@ -954,6 +954,163 @@ class RPCView(BaseView):
logger.warning(f"stars_render failed: {safe_str(ex)}")
return False
async def createChannel(self, name, description=None, is_private=False):
self._require_login()
self._require_services()
if not name or not isinstance(name, str):
return {"error": "Invalid channel name", "success": False}
name = name.strip()
if not name:
return {"error": "Channel name cannot be empty", "success": False}
try:
channel = await self.services.channel.create(
label=name,
created_by_uid=self.user_uid,
description=description,
is_private=is_private,
is_listed=True
)
if not channel:
return {"error": "Failed to create channel", "success": False}
await self.services.channel_member.create(
channel["uid"],
self.user_uid,
is_moderator=True
)
await self.services.socket.subscribe(self.ws, channel["uid"], self.user_uid)
return {
"success": True,
"uid": channel["uid"],
"name": channel["label"]
}
except Exception as ex:
logger.warning(f"Failed to create channel: {safe_str(ex)}")
return {"error": safe_str(ex), "success": False}
async def inviteUser(self, channel_uid, username):
self._require_login()
self._require_services()
if not channel_uid or not isinstance(channel_uid, str):
return {"error": "Invalid channel", "success": False}
if not username or not isinstance(username, str):
return {"error": "Invalid username", "success": False}
try:
channel_member = await self.services.channel_member.get(
channel_uid=channel_uid, user_uid=self.user_uid
)
if not channel_member:
raise PermissionError("Not a member of this channel")
user = await self.services.user.get(username=username.strip())
if not user:
return {"error": "User not found", "success": False}
existing = await self.services.channel_member.get(
channel_uid=channel_uid, user_uid=user["uid"]
)
if existing and not existing["deleted_at"]:
return {"error": "User is already a member", "success": False}
result = await self.services.channel_member.create(
channel_uid,
user["uid"],
is_moderator=False
)
if not result:
return {"error": "Failed to invite user", "success": False}
return {"success": True, "username": user["username"]}
except PermissionError as ex:
return {"error": safe_str(ex), "success": False}
except Exception as ex:
logger.warning(f"Failed to invite user: {safe_str(ex)}")
return {"error": "Failed to invite user", "success": False}
async def leaveChannel(self, channel_uid):
self._require_login()
self._require_services()
if not channel_uid or not isinstance(channel_uid, str):
return {"error": "Invalid channel", "success": False}
try:
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
return {"error": "Channel not found", "success": False}
if channel["tag"] == "public":
return {"error": "Cannot leave the public channel", "success": False}
channel_member = await self.services.channel_member.get(
channel_uid=channel_uid, user_uid=self.user_uid
)
if not channel_member:
return {"error": "Not a member of this channel", "success": False}
channel_member["deleted_at"] = now()
await self.services.channel_member.save(channel_member)
await self.services.socket.unsubscribe(self.ws, channel_uid, self.user_uid)
return {"success": True}
except Exception as ex:
logger.warning(f"Failed to leave channel: {safe_str(ex)}")
return {"error": "Failed to leave channel", "success": False}
async def updateChannel(self, channel_uid, name, description=None, is_private=False):
self._require_login()
self._require_services()
if not channel_uid or not isinstance(channel_uid, str):
return {"error": "Invalid channel", "success": False}
if not name or not isinstance(name, str):
return {"error": "Invalid channel name", "success": False}
name = name.strip()
if not name:
return {"error": "Channel name cannot be empty", "success": False}
try:
channel_member = await self.services.channel_member.get(
channel_uid=channel_uid, user_uid=self.user_uid
)
if not channel_member or not channel_member["is_moderator"]:
return {"error": "Only moderators can update channel settings", "success": False}
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
return {"error": "Channel not found", "success": False}
if channel["tag"] == "public":
return {"error": "Cannot modify the public channel", "success": False}
if not name.startswith("#") and channel["is_listed"]:
name = f"#{name}"
channel["label"] = name
channel["description"] = description
channel["is_private"] = is_private
await self.services.channel.save(channel)
async for member in self.services.channel_member.find(
channel_uid=channel_uid, deleted_at=None
):
member["label"] = name
await self.services.channel_member.save(member)
return {"success": True, "name": channel["label"]}
except Exception as ex:
logger.warning(f"Failed to update channel: {safe_str(ex)}")
return {"error": "Failed to update channel", "success": False}
async def deleteChannel(self, channel_uid):
self._require_login()
self._require_services()
if not channel_uid or not isinstance(channel_uid, str):
return {"error": "Invalid channel", "success": False}
try:
channel_member = await self.services.channel_member.get(
channel_uid=channel_uid, user_uid=self.user_uid
)
if not channel_member or not channel_member["is_moderator"]:
return {"error": "Only moderators can delete channels", "success": False}
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
return {"error": "Channel not found", "success": False}
if channel["tag"] == "public":
return {"error": "Cannot delete the public channel", "success": False}
channel["deleted_at"] = now()
await self.services.channel.save(channel)
async for member in self.services.channel_member.find(
channel_uid=channel_uid, deleted_at=None
):
member["deleted_at"] = now()
await self.services.channel_member.save(member)
return {"success": True}
except Exception as ex:
logger.warning(f"Failed to delete channel: {safe_str(ex)}")
return {"error": "Failed to delete channel", "success": False}
async def get(self):
ws = None
rpc = None