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:
parent
401e558011
commit
163828e213
@ -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.
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
|
||||
205
src/snek/templates/dialog_channel_settings.html
Normal file
205
src/snek/templates/dialog_channel_settings.html
Normal 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>
|
||||
91
src/snek/templates/dialog_create_channel.html
Normal file
91
src/snek/templates/dialog_create_channel.html
Normal 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>
|
||||
@ -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() {
|
||||
|
||||
116
src/snek/templates/dialog_invite.html
Normal file
116
src/snek/templates/dialog_invite.html
Normal 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>
|
||||
65
src/snek/templates/dialog_leave_channel.html
Normal file
65
src/snek/templates/dialog_leave_channel.html
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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 ---
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user