feat(chat): add HTML templates

This commit is contained in:
2026-04-28 12:36:39 +02:00
parent 57950e73da
commit 9b16654660
11 changed files with 889 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
{% extends 'chat/base.html' %}
{% block title %}404 — DiscordClone{% endblock %}
{% block body %}
<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;text-align:center;background:var(--dc-bg);">
<div>
<div style="font-size:5rem;">😵</div>
<h1 style="font-size:2rem;">404</h1>
<p style="color:var(--dc-muted);">That page doesn't exist.</p>
<a href="/" class="btn btn-primary mt-2" id="btn_404_home">Take me home</a>
</div>
</div>
{% endblock %}
+12
View File
@@ -0,0 +1,12 @@
{% extends 'chat/base.html' %}
{% block title %}500 — DiscordClone{% endblock %}
{% block body %}
<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;text-align:center;background:var(--dc-bg);">
<div>
<div style="font-size:5rem;">💥</div>
<h1 style="font-size:2rem;">500</h1>
<p style="color:var(--dc-muted);">Server error. Something broke.</p>
<a href="/" class="btn btn-primary mt-2" id="btn_500_home">Take me home</a>
</div>
</div>
{% endblock %}
+62
View File
@@ -0,0 +1,62 @@
{% extends 'chat/base.html' %}
{% block title %}Admin Panel — DiscordClone{% endblock %}
{% block body %}
<div class="auth-wrapper" style="overflow:auto;align-items:flex-start;padding:32px 0;">
<div style="width:100%;max-width:700px;margin:0 auto;">
<div class="auth-card" style="max-width:100%;">
<div class="d-flex align-items-center justify-content-between mb-3">
<h1 style="font-size:1.3rem;margin:0;"><i class="bi bi-shield-check text-warning me-2"></i>Admin Panel</h1>
<a href="{% url 'home' %}" class="btn btn-sm btn-secondary" id="btn_admin_back">← Back</a>
</div>
{% if messages %}
{% for msg in messages %}
<div class="alert alert-success py-2 mb-3">{{ msg }}</div>
{% endfor %}
{% endif %}
<table class="table table-dark table-hover align-middle" style="font-size:.9rem;">
<thead>
<tr>
<th>User</th>
<th>Email</th>
<th>Role</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td>
{% if u.profile.avatar %}<img src="{{ u.profile.avatar.url }}" class="rounded-circle me-2" style="width:28px;height:28px;object-fit:cover;">{% endif %}
{{ u.username }}
{% if u.id in blocked_ids %}<span class="badge bg-danger ms-1">blocked</span>{% endif %}
</td>
<td style="color:var(--dc-muted);">{{ u.email }}</td>
<td><span class="role-badge role-{{ u.profile.role }}">{{ u.profile.role }}</span></td>
<td>
<form method="post" action="{% url 'admin_panel' %}" class="d-flex gap-1 flex-wrap" id="form_user_{{ u.id }}">
{% csrf_token %}
<input type="hidden" name="user_id" value="{{ u.id }}">
<!-- Role assign -->
<select name="role" class="form-select form-select-sm" style="width:auto;" id="sel_role_{{ u.id }}">
<option value="user" {% if u.profile.role == 'user' %}selected{% endif %}>User</option>
<option value="moderator" {% if u.profile.role == 'moderator' %}selected{% endif %}>Moderator</option>
<option value="admin" {% if u.profile.role == 'admin' %}selected{% endif %}>Admin</option>
</select>
<button type="submit" name="action" value="set_role" class="btn btn-sm btn-outline-primary" id="btn_setrole_{{ u.id }}">Set Role</button>
{% if u.id in blocked_ids %}
<button type="submit" name="action" value="unblock" class="btn btn-sm btn-outline-success" id="btn_unblock_{{ u.id }}">Unblock</button>
{% else %}
<button type="submit" name="action" value="block" class="btn btn-sm btn-outline-danger" id="btn_block_{{ u.id }}">Block</button>
{% endif %}
</form>
</td>
</tr>
{% empty %}
<tr><td colspan="4" class="text-center" style="color:var(--dc-muted);">No other users.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+251
View File
@@ -0,0 +1,251 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}DiscordClone{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<style>
:root {
--dc-bg: #1e1f22;
--dc-sidebar: #2b2d31;
--dc-panel: #232428;
--dc-accent: #5865f2;
--dc-accent-hover: #4752c4;
--dc-text: #dcddde;
--dc-muted: #8a8c92;
--dc-online: #23a55a;
--dc-mod: #f0b232;
--dc-admin: #ed4245;
--dc-border: #3f4147;
--dc-msg-bg: #2e3035;
--dc-input: #383a40;
}
* { box-sizing: border-box; }
body {
background: var(--dc-bg);
color: var(--dc-text);
font-family: 'Segoe UI', system-ui, sans-serif;
height: 100vh;
overflow: hidden;
}
/* ── Layout ── */
.app-layout { display: flex; height: 100vh; }
.sidebar-channels {
width: 240px;
min-width: 240px;
background: var(--dc-sidebar);
display: flex;
flex-direction: column;
border-right: 1px solid var(--dc-border);
}
.sidebar-users {
width: 200px;
min-width: 200px;
background: var(--dc-sidebar);
display: flex;
flex-direction: column;
border-left: 1px solid var(--dc-border);
}
.main-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
/* ── Sidebar items ── */
.sidebar-header {
padding: 16px;
font-weight: 700;
font-size: 0.95rem;
border-bottom: 1px solid var(--dc-border);
letter-spacing: .5px;
}
.sidebar-section {
padding: 16px 8px 4px 8px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 1px;
color: var(--dc-muted);
text-transform: uppercase;
display: flex;
align-items: center;
justify-content: space-between;
}
.channel-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
margin: 1px 4px;
border-radius: 4px;
cursor: pointer;
color: var(--dc-muted);
text-decoration: none;
font-size: 0.9rem;
transition: background .15s, color .15s;
}
.channel-item:hover, .channel-item.active {
background: rgba(255,255,255,0.08);
color: #fff;
}
.channel-item .bi { font-size: 1rem; }
/* ── User bar ── */
.user-bar {
padding: 10px 12px;
background: #232428;
border-top: 1px solid var(--dc-border);
display: flex;
align-items: center;
gap: 10px;
margin-top: auto;
}
.user-avatar {
width: 32px; height: 32px;
border-radius: 50%;
object-fit: cover;
background: var(--dc-accent);
display: flex; align-items: center; justify-content: center;
font-size: .8rem; font-weight: 700; color: #fff;
}
.user-avatar img { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; }
.role-badge {
font-size: 0.65rem;
padding: 1px 5px;
border-radius: 3px;
font-weight: 600;
}
.role-admin { background: var(--dc-admin); color: #fff; }
.role-moderator { background: var(--dc-mod); color: #000; }
.role-user { background: #4e5058; color: #fff; }
/* ── Chat area ── */
.chat-header {
padding: 12px 20px;
background: var(--dc-panel);
border-bottom: 1px solid var(--dc-border);
font-weight: 700;
font-size: 1rem;
display: flex;
align-items: center;
gap: 10px;
}
.messages-container {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 4px;
}
.messages-container::-webkit-scrollbar { width: 6px; }
.messages-container::-webkit-scrollbar-thumb { background: var(--dc-border); border-radius: 3px; }
.message-group {
display: flex;
gap: 14px;
padding: 4px 8px;
border-radius: 4px;
transition: background .1s;
}
.message-group:hover { background: rgba(0,0,0,0.1); }
.msg-avatar {
width: 40px; height: 40px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
background: var(--dc-accent);
display: flex; align-items: center; justify-content: center;
font-weight: 700; color: #fff; font-size: .85rem;
}
.msg-avatar img { width: 40px; height: 40px; border-radius: 50%; object-fit: cover; }
.msg-body { flex: 1; min-width: 0; }
.msg-meta { display: flex; align-items: baseline; gap: 8px; margin-bottom: 2px; }
.msg-author { font-weight: 600; font-size: .9rem; }
.msg-time { font-size: 0.72rem; color: var(--dc-muted); }
.msg-text { font-size: 0.9rem; word-break: break-word; line-height: 1.4; }
.msg-file img { max-width: 300px; max-height: 200px; border-radius: 6px; margin-top: 6px; }
.msg-file audio { margin-top: 6px; }
.msg-file a { color: var(--dc-accent); }
.msg-actions { margin-left: auto; display: none; gap: 4px; }
.message-group:hover .msg-actions { display: flex; }
/* ── Input area ── */
.input-area {
padding: 12px 20px 16px;
background: var(--dc-panel);
}
.input-box {
background: var(--dc-input);
border-radius: 8px;
padding: 0 12px;
display: flex;
align-items: center;
gap: 8px;
}
.input-box input[type="text"] {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--dc-text);
padding: 12px 0;
font-size: .95rem;
}
.input-box input[type="text"]::placeholder { color: var(--dc-muted); }
.input-box button {
background: none;
border: none;
color: var(--dc-muted);
font-size: 1.2rem;
cursor: pointer;
transition: color .15s;
}
.input-box button:hover { color: var(--dc-accent); }
/* ── Forms / pages ── */
.auth-wrapper {
min-height: 100vh;
background: var(--dc-bg);
display: flex;
align-items: center;
justify-content: center;
overflow: auto;
}
.auth-card {
background: var(--dc-panel);
border-radius: 8px;
padding: 32px 36px;
width: 100%;
max-width: 420px;
box-shadow: 0 8px 40px rgba(0,0,0,.6);
}
.auth-card h1 { font-size: 1.5rem; font-weight: 700; text-align: center; margin-bottom: 4px; }
.auth-card p.sub { text-align: center; color: var(--dc-muted); font-size: .85rem; margin-bottom: 24px; }
.form-control {
background: var(--dc-input) !important;
border-color: var(--dc-border) !important;
color: var(--dc-text) !important;
}
.form-control:focus { border-color: var(--dc-accent) !important; box-shadow: 0 0 0 2px rgba(88,101,242,.3) !important; }
.btn-primary {
background: var(--dc-accent) !important;
border-color: var(--dc-accent) !important;
}
.btn-primary:hover { background: var(--dc-accent-hover) !important; border-color: var(--dc-accent-hover) !important; }
.online-dot {
width: 10px; height: 10px;
background: var(--dc-online);
border-radius: 50%;
display: inline-block;
}
.offline-dot {
width: 10px; height: 10px;
background: var(--dc-muted);
border-radius: 50%;
display: inline-block;
}
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--dc-border); border-radius: 3px; }
</style>
</head>
<body>
{% block body %}{% endblock %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+200
View File
@@ -0,0 +1,200 @@
{% extends 'chat/base.html' %}
{% block title %}#{{ channel.name }} — DiscordClone{% endblock %}
{% block body %}
{% with profile=request.user.profile %}
<div class="app-layout">
<!-- Channel sidebar -->
<div class="sidebar-channels">
<div class="sidebar-header"><a href="{% url 'home' %}" class="text-white text-decoration-none"><i class="bi bi-arrow-left me-1"></i>DiscordClone</a></div>
<div style="flex:1;overflow-y:auto;">
<div class="sidebar-section">Channels
{% if profile.is_moderator %}<a href="{% url 'create_channel' %}" class="text-white" id="btn_create_ch"><i class="bi bi-plus-lg"></i></a>{% endif %}
</div>
{% for ch in all_channels %}
<a href="{% url 'channel' ch.id %}" class="channel-item {% if ch.id == channel.id %}active{% endif %}" id="ch_{{ ch.id }}">
<i class="bi bi-hash"></i>{{ ch.name }}
</a>
{% endfor %}
</div>
<div class="user-bar">
<div class="user-avatar">
{% if profile.avatar %}<img src="{{ profile.avatar.url }}" alt="av">{% else %}{{ request.user.username|first|upper }}{% endif %}
</div>
<div style="flex:1;min-width:0;">
<div style="font-size:.85rem;font-weight:600;">{{ request.user.username }}</div>
<span class="role-badge role-{{ profile.role }}">{{ profile.role }}</span>
</div>
<a href="{% url 'profile' %}" class="text-secondary" id="btn_profile_ch"><i class="bi bi-gear"></i></a>
{% if profile.is_admin %}<a href="{% url 'admin_panel' %}" class="text-warning ms-1" id="btn_admin_ch"><i class="bi bi-shield-check"></i></a>{% endif %}
<a href="{% url 'logout' %}" class="text-secondary ms-1" id="btn_logout_ch"><i class="bi bi-box-arrow-right"></i></a>
</div>
</div>
<!-- Main chat -->
<div class="main-content">
<div class="chat-header">
<i class="bi bi-hash text-secondary"></i>
<span>{{ channel.name }}</span>
{% if channel.description %}<span style="color:var(--dc-muted);font-size:.8rem;font-weight:400;">— {{ channel.description }}</span>{% endif %}
<span class="ms-auto" style="font-size:.75rem;color:var(--dc-muted);">{{ channel.members.count }} members</span>
</div>
<div class="messages-container" id="messages-container">
{% for msg in messages %}
{% if not msg.deleted %}
<div class="message-group" id="msg_{{ msg.id }}">
<div class="msg-avatar">
{% if msg.sender.profile.avatar %}<img src="{{ msg.sender.profile.avatar.url }}" alt="av">{% else %}{{ msg.sender.username|first|upper }}{% endif %}
</div>
<div class="msg-body">
<div class="msg-meta">
<span class="msg-author" style="color:{% if msg.sender.profile.role == 'admin' %}var(--dc-admin){% elif msg.sender.profile.role == 'moderator' %}var(--dc-mod){% else %}#fff{% endif %};">
{{ msg.sender.username }}
</span>
<span class="role-badge role-{{ msg.sender.profile.role }}">{{ msg.sender.profile.role|slice:":3" }}</span>
<span class="msg-time">{{ msg.timestamp|date:"H:i" }}</span>
<div class="msg-actions">
{% if profile.is_moderator or msg.sender == request.user %}
<a href="{% url 'delete_message' msg.id %}" class="btn btn-sm btn-danger py-0 px-1" style="font-size:.7rem;" id="del_msg_{{ msg.id }}" onclick="return confirm('Delete?')"><i class="bi bi-trash"></i></a>
{% endif %}
</div>
</div>
{% if msg.content %}<div class="msg-text">{{ msg.content }}</div>{% endif %}
{% if msg.file %}
<div class="msg-file">
{% if msg.file.url|slice:"-4:" in ".jpg .png .gif .web" or msg.file.name|lower|slice:"-4:" in ".jpg,.png,.gif,.web" %}
<img src="{{ msg.file.url }}" alt="image">
{% elif ".mp3" in msg.file.name or ".wav" in msg.file.name or ".ogg" in msg.file.name %}
<audio controls src="{{ msg.file.url }}"></audio>
{% else %}
<a href="{{ msg.file.url }}" target="_blank"><i class="bi bi-file-earmark"></i> {{ msg.file.name|slice:"8:" }}</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endif %}
{% endfor %}
</div>
<!-- File upload -->
<div style="padding: 0 20px 4px;">
<form method="post" enctype="multipart/form-data" id="file-upload-form">
{% csrf_token %}
<div class="d-flex gap-2 align-items-center">
<input type="file" name="file" class="form-control form-control-sm" accept="image/*,audio/*" style="max-width:260px;" id="id_file_upload">
<input type="text" name="content" class="form-control form-control-sm" placeholder="Caption (optional)" id="id_file_caption">
<button type="submit" class="btn btn-sm btn-primary" id="btn_upload_file"><i class="bi bi-upload"></i> Upload</button>
</div>
</form>
</div>
<!-- Text input -->
<div class="input-area">
<div class="input-box">
<button type="button" title="Attach file" onclick="document.getElementById('id_file_upload').click()"><i class="bi bi-plus-circle"></i></button>
<input type="text" id="chat-input" placeholder="Message #{{ channel.name }}" autocomplete="off">
<button type="button" id="send-btn" title="Send"><i class="bi bi-send"></i></button>
</div>
</div>
</div>
<!-- Members sidebar -->
<div class="sidebar-users">
<div class="sidebar-section">Members — {{ channel.members.count }}</div>
<div style="flex:1;overflow-y:auto;padding:8px;">
{% for member in channel.members.all %}
<div class="channel-item" style="cursor:default;">
<span {% if member.profile.online %}class="online-dot"{% else %}class="offline-dot"{% endif %}></span>
<a href="{% url 'dm' member.username %}" class="text-decoration-none" style="color:inherit;">{{ member.username }}</a>
<span class="role-badge role-{{ member.profile.role }} ms-auto">{{ member.profile.role|slice:":3" }}</span>
</div>
{% endfor %}
</div>
</div>
</div>
{% endwith %}
{% endblock %}
{% block scripts %}
<script>
const channelId = {{ channel.id }};
const username = "{{ request.user.username }}";
const userRole = "{{ request.user.profile.role }}";
const userAvatar = "{% if request.user.profile.avatar %}{{ request.user.profile.avatar.url }}{% endif %}";
const container = document.getElementById('messages-container');
const input = document.getElementById('chat-input');
const sendBtn = document.getElementById('send-btn');
// Scroll to bottom
container.scrollTop = container.scrollHeight;
const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${wsProto}//${location.host}/ws/channel/${channelId}/`);
ws.onopen = () => console.log('WS connected');
ws.onclose = () => console.log('WS closed');
ws.onmessage = function(e) {
const data = JSON.parse(e.data);
appendMessage(data);
};
function sendMessage() {
const msg = input.value.trim();
if (!msg || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ message: msg }));
input.value = '';
}
sendBtn.addEventListener('click', sendMessage);
input.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } });
function appendMessage(data) {
const avatarHtml = data.avatar
? `<img src="${data.avatar}" alt="av">`
: data.sender[0].toUpperCase();
const roleColor = data.role === 'admin' ? 'var(--dc-admin)' : data.role === 'moderator' ? 'var(--dc-mod)' : '#fff';
const roleShort = data.role.slice(0, 3);
let fileHtml = '';
if (data.file_url) {
const fileLower = data.file_name.toLowerCase();
if (fileLower.endsWith('.jpg') || fileLower.endsWith('.png') || fileLower.endsWith('.gif') || fileLower.endsWith('.webp') || fileLower.endsWith('.jpeg')) {
fileHtml = `<div class="msg-file"><img src="${data.file_url}" alt="image"></div>`;
} else if (fileLower.endsWith('.mp3') || fileLower.endsWith('.wav') || fileLower.endsWith('.ogg')) {
fileHtml = `<div class="msg-file"><audio controls src="${data.file_url}"></audio></div>`;
} else {
fileHtml = `<div class="msg-file"><a href="${data.file_url}" target="_blank"><i class="bi bi-file-earmark"></i> ${data.file_name.split('/').pop()}</a></div>`;
}
}
const div = document.createElement('div');
div.className = 'message-group';
div.id = `msg_${data.message_id}`;
div.innerHTML = `
<div class="msg-avatar">${avatarHtml}</div>
<div class="msg-body">
<div class="msg-meta">
<span class="msg-author" style="color:${roleColor};">${data.sender}</span>
<span class="role-badge role-${data.role}">${roleShort}</span>
<span class="msg-time">${data.timestamp}</span>
<div class="msg-actions">
${(userRole === 'admin' || userRole === 'moderator' || data.sender === username) ?
`<a href="/message/${data.message_id}/delete/" class="btn btn-sm btn-danger py-0 px-1" style="font-size:.7rem;" onclick="return confirm('Delete?')"><i class="bi bi-trash"></i></a>` : ''}
</div>
</div>
${data.message ? `<div class="msg-text">${escHtml(data.message)}</div>` : ''}
${fileHtml}
</div>`;
container.appendChild(div);
container.scrollTop = container.scrollHeight;
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
</script>
{% endblock %}
+25
View File
@@ -0,0 +1,25 @@
{% extends 'chat/base.html' %}
{% block title %}Create Channel — DiscordClone{% endblock %}
{% block body %}
<div class="auth-wrapper">
<div class="auth-card">
<h1 style="font-size:1.3rem;">Create Channel</h1>
<p class="sub">Start a new conversation space.</p>
<form method="post" action="{% url 'create_channel' %}">
{% csrf_token %}
<div class="mb-3">
<label class="form-label" style="font-size:.8rem;font-weight:600;color:#b5bac1;text-transform:uppercase;">Channel Name</label>
<input type="text" name="name" class="form-control" id="id_channel_name" required placeholder="general">
</div>
<div class="mb-4">
<label class="form-label" style="font-size:.8rem;font-weight:600;color:#b5bac1;text-transform:uppercase;">Description</label>
<input type="text" name="description" class="form-control" id="id_channel_desc" placeholder="Optional">
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary flex-fill" id="btn_create_ch_submit">Create</button>
<a href="{% url 'home' %}" class="btn btn-secondary flex-fill" id="btn_create_ch_back">Cancel</a>
</div>
</form>
</div>
</div>
{% endblock %}
+151
View File
@@ -0,0 +1,151 @@
{% extends 'chat/base.html' %}
{% block title %}DM — {{ other.username }} — DiscordClone{% endblock %}
{% block body %}
{% with profile=request.user.profile %}
<div class="app-layout">
<!-- Sidebar -->
<div class="sidebar-channels">
<div class="sidebar-header"><a href="{% url 'home' %}" class="text-white text-decoration-none"><i class="bi bi-arrow-left me-1"></i>DiscordClone</a></div>
<div style="flex:1;overflow-y:auto;padding:8px;">
<div class="sidebar-section">Direct Messages</div>
{% for u in all_users %}
<a href="{% url 'dm' u.username %}" class="channel-item {% if u.username == other.username %}active{% endif %}" id="dm_link_{{ u.id }}">
<span {% if u.profile.online %}class="online-dot"{% else %}class="offline-dot"{% endif %}></span>
{{ u.username }}
</a>
{% endfor %}
</div>
<div class="user-bar">
<div class="user-avatar">
{% if profile.avatar %}<img src="{{ profile.avatar.url }}" alt="av">{% else %}{{ request.user.username|first|upper }}{% endif %}
</div>
<div style="flex:1;min-width:0;font-size:.85rem;font-weight:600;">{{ request.user.username }}</div>
<a href="{% url 'logout' %}" class="text-secondary" id="btn_logout_dm"><i class="bi bi-box-arrow-right"></i></a>
</div>
</div>
<!-- DM chat -->
<div class="main-content">
<div class="chat-header">
<i class="bi bi-person-fill text-secondary"></i>
<span>{{ other.username }}</span>
{% if other.profile.online %}<span class="online-dot ms-1"></span>{% endif %}
</div>
<div class="messages-container" id="messages-container">
{% for msg in messages %}
<div class="message-group" id="msg_dm_{{ msg.id }}">
<div class="msg-avatar">
{% if msg.sender.profile.avatar %}<img src="{{ msg.sender.profile.avatar.url }}" alt="av">{% else %}{{ msg.sender.username|first|upper }}{% endif %}
</div>
<div class="msg-body">
<div class="msg-meta">
<span class="msg-author">{{ msg.sender.username }}</span>
<span class="msg-time">{{ msg.timestamp|date:"H:i" }}</span>
</div>
{% if msg.content %}<div class="msg-text">{{ msg.content }}</div>{% endif %}
{% if msg.file %}
<div class="msg-file">
{% if ".jpg" in msg.file.name or ".png" in msg.file.name or ".gif" in msg.file.name or ".webp" in msg.file.name %}
<img src="{{ msg.file.url }}" alt="image">
{% elif ".mp3" in msg.file.name or ".wav" in msg.file.name or ".ogg" in msg.file.name %}
<audio controls src="{{ msg.file.url }}"></audio>
{% else %}
<a href="{{ msg.file.url }}" target="_blank"><i class="bi bi-file-earmark"></i> {{ msg.file.name|slice:"11:" }}</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<!-- File upload -->
<div style="padding: 0 20px 4px;">
<form method="post" enctype="multipart/form-data" id="dm-file-form">
{% csrf_token %}
<div class="d-flex gap-2 align-items-center">
<input type="file" name="file" class="form-control form-control-sm" accept="image/*,audio/*" style="max-width:260px;" id="id_dm_file">
<input type="text" name="content" class="form-control form-control-sm" placeholder="Caption" id="id_dm_caption">
<button type="submit" class="btn btn-sm btn-primary" id="btn_dm_upload"><i class="bi bi-upload"></i> Upload</button>
</div>
</form>
</div>
<div class="input-area">
<div class="input-box">
<input type="text" id="dm-input" placeholder="Message {{ other.username }}" autocomplete="off">
<button type="button" id="dm-send-btn" title="Send"><i class="bi bi-send"></i></button>
</div>
</div>
</div>
</div>
{% endwith %}
{% endblock %}
{% block scripts %}
<script>
const otherUsername = "{{ other.username }}";
const myUsername = "{{ request.user.username }}";
const container = document.getElementById('messages-container');
const input = document.getElementById('dm-input');
const sendBtn = document.getElementById('dm-send-btn');
const myAvatar = "{% if request.user.profile.avatar %}{{ request.user.profile.avatar.url }}{% endif %}";
container.scrollTop = container.scrollHeight;
const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${wsProto}//${location.host}/ws/dm/${otherUsername}/`);
ws.onmessage = function(e) {
const data = JSON.parse(e.data);
appendDM(data);
};
function sendDM() {
const msg = input.value.trim();
if (!msg || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ message: msg }));
input.value = '';
}
sendBtn.addEventListener('click', sendDM);
input.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendDM(); } });
function appendDM(data) {
const avatarHtml = data.avatar ? `<img src="${data.avatar}" alt="av">` : data.sender[0].toUpperCase();
let fileHtml = '';
if (data.file_url) {
const fileLower = data.file_name.toLowerCase();
if (fileLower.endsWith('.jpg') || fileLower.endsWith('.png') || fileLower.endsWith('.gif') || fileLower.endsWith('.webp') || fileLower.endsWith('.jpeg')) {
fileHtml = `<div class="msg-file"><img src="${data.file_url}" alt="image"></div>`;
} else if (fileLower.endsWith('.mp3') || fileLower.endsWith('.wav') || fileLower.endsWith('.ogg')) {
fileHtml = `<div class="msg-file"><audio controls src="${data.file_url}"></audio></div>`;
} else {
fileHtml = `<div class="msg-file"><a href="${data.file_url}" target="_blank"><i class="bi bi-file-earmark"></i> ${data.file_name.split('/').pop()}</a></div>`;
}
}
const div = document.createElement('div');
div.className = 'message-group';
div.id = `msg_dm_${data.message_id}`;
div.innerHTML = `
<div class="msg-avatar">${avatarHtml}</div>
<div class="msg-body">
<div class="msg-meta">
<span class="msg-author">${data.sender}</span>
<span class="msg-time">${data.timestamp}</span>
</div>
${data.message ? `<div class="msg-text">${escHtml(data.message)}</div>` : ''}
${fileHtml}
</div>`;
container.appendChild(div);
container.scrollTop = container.scrollHeight;
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
</script>
{% endblock %}
+76
View File
@@ -0,0 +1,76 @@
{% extends 'chat/base.html' %}
{% block title %}Home — DiscordClone{% endblock %}
{% block body %}
{% with profile=request.user.profile %}
<div class="app-layout">
<!-- Channel Sidebar -->
<div class="sidebar-channels">
<div class="sidebar-header" style="color:#fff;">
<i class="bi bi-discord text-primary"></i> DiscordClone
</div>
<div style="flex:1;overflow-y:auto;">
<div class="sidebar-section">
Channels
{% if profile.is_moderator %}
<a href="{% url 'create_channel' %}" class="text-white" style="font-size:1.1rem;" title="New Channel" id="btn_create_channel"><i class="bi bi-plus-lg"></i></a>
{% endif %}
</div>
{% for ch in channels %}
<a href="{% url 'channel' ch.id %}" class="channel-item" id="ch_{{ ch.id }}">
<i class="bi bi-hash"></i>
<span>{{ ch.name }}</span>
{% if profile.is_admin %}
<a href="{% url 'delete_channel' ch.id %}" class="ms-auto text-danger" style="font-size:.75rem;" onclick="return confirm('Delete #{{ ch.name }}?')" id="del_ch_{{ ch.id }}"><i class="bi bi-trash"></i></a>
{% endif %}
</a>
{% empty %}
<div class="px-3 py-2" style="color:#8a8c92;font-size:.8rem;">No channels yet.</div>
{% endfor %}
<div class="sidebar-section">Direct Messages</div>
{% for u in users %}
<a href="{% url 'dm' u.username %}" class="channel-item" id="dm_{{ u.id }}">
<span {% if u.profile.online %}class="online-dot"{% else %}class="offline-dot"{% endif %}></span>
{{ u.username }}
<span class="role-badge role-{{ u.profile.role }} ms-auto">{{ u.profile.role|slice:":3" }}</span>
</a>
{% endfor %}
</div>
<!-- User bar -->
<div class="user-bar">
<div class="user-avatar">
{% if profile.avatar %}
<img src="{{ profile.avatar.url }}" alt="av">
{% else %}
{{ request.user.username|first|upper }}
{% endif %}
</div>
<div style="flex:1;min-width:0;">
<div style="font-size:.85rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ request.user.username }}</div>
<div><span class="role-badge role-{{ profile.role }}">{{ profile.role }}</span></div>
</div>
<a href="{% url 'profile' %}" class="text-secondary" title="Edit Profile" id="btn_profile"><i class="bi bi-gear"></i></a>
{% if profile.is_admin %}
<a href="{% url 'admin_panel' %}" class="text-warning ms-1" title="Admin Panel" id="btn_admin"><i class="bi bi-shield-check"></i></a>
{% endif %}
<a href="{% url 'logout' %}" class="text-secondary ms-1" title="Logout" id="btn_logout"><i class="bi bi-box-arrow-right"></i></a>
</div>
</div>
<!-- Main: welcome -->
<div class="main-content" style="align-items:center;justify-content:center;background:var(--dc-bg);">
<div style="text-align:center;color:var(--dc-muted);">
<div style="font-size:4rem;">👾</div>
<h2 style="color:#fff;">Welcome, {{ request.user.username }}!</h2>
<p>Pick a channel or DM from the sidebar.</p>
{% if messages %}
{% for msg in messages %}
<div class="alert alert-{{ msg.tags }} mt-2">{{ msg }}</div>
{% endfor %}
{% endif %}
</div>
</div>
</div>
{% endwith %}
{% endblock %}
+28
View File
@@ -0,0 +1,28 @@
{% extends 'chat/base.html' %}
{% block title %}Login — DiscordClone{% endblock %}
{% block body %}
<div class="auth-wrapper">
<div class="auth-card">
<h1>👾 DiscordClone</h1>
<p class="sub">Welcome back! Sign in to continue.</p>
{% if error %}
<div class="alert alert-danger py-2" style="font-size:.85rem;">{{ error }}</div>
{% endif %}
<form method="post" action="{% url 'login' %}">
{% csrf_token %}
<div class="mb-3">
<label class="form-label" style="font-size:.8rem;font-weight:600;color:#b5bac1;text-transform:uppercase;letter-spacing:.5px;">Username</label>
<input type="text" name="username" class="form-control" id="id_username_login" required autofocus>
</div>
<div class="mb-4">
<label class="form-label" style="font-size:.8rem;font-weight:600;color:#b5bac1;text-transform:uppercase;letter-spacing:.5px;">Password</label>
<input type="password" name="password" class="form-control" id="id_password_login" required>
</div>
<button type="submit" class="btn btn-primary w-100 fw-600" id="btn_login">Log In</button>
</form>
<p class="text-center mt-3" style="font-size:.85rem;color:#b5bac1;">
No account? <a href="{% url 'register' %}" style="color:#00b0f4;">Register</a>
</p>
</div>
</div>
{% endblock %}
+37
View File
@@ -0,0 +1,37 @@
{% extends 'chat/base.html' %}
{% block title %}Profile — DiscordClone{% endblock %}
{% block body %}
<div class="auth-wrapper">
<div class="auth-card" style="max-width:480px;">
<h1 style="font-size:1.3rem;">Edit Profile</h1>
<p class="sub">{{ request.user.username }} · <span class="role-badge role-{{ request.user.profile.role }}">{{ request.user.profile.role }}</span></p>
{% if messages %}
{% for msg in messages %}
<div class="alert alert-success py-2">{{ msg }}</div>
{% endfor %}
{% endif %}
<form method="post" enctype="multipart/form-data" action="{% url 'profile' %}">
{% csrf_token %}
<div class="mb-3 text-center">
{% if request.user.profile.avatar %}
<img src="{{ request.user.profile.avatar.url }}" class="rounded-circle" style="width:80px;height:80px;object-fit:cover;" alt="av">
{% else %}
<div class="user-avatar mx-auto" style="width:80px;height:80px;font-size:2rem;">{{ request.user.username|first|upper }}</div>
{% endif %}
</div>
<div class="mb-3">
<label class="form-label" style="font-size:.8rem;font-weight:600;color:#b5bac1;text-transform:uppercase;">Avatar</label>
<input type="file" name="avatar" class="form-control" id="id_avatar" accept="image/*">
</div>
<div class="mb-4">
<label class="form-label" style="font-size:.8rem;font-weight:600;color:#b5bac1;text-transform:uppercase;">Bio</label>
<textarea name="bio" class="form-control" rows="3" id="id_bio">{{ request.user.profile.bio }}</textarea>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary flex-fill" id="btn_save_profile">Save Changes</button>
<a href="{% url 'home' %}" class="btn btn-secondary flex-fill" id="btn_back_profile">Back</a>
</div>
</form>
</div>
</div>
{% endblock %}
+35
View File
@@ -0,0 +1,35 @@
{% extends 'chat/base.html' %}
{% block title %}Register — DiscordClone{% endblock %}
{% block body %}
<div class="auth-wrapper">
<div class="auth-card">
<h1>Create Account</h1>
<p class="sub">Join the server.</p>
{% if form.errors %}
<div class="alert alert-danger py-2" style="font-size:.85rem;">
{% for field in form %}{% for e in field.errors %}<div>{{ e }}</div>{% endfor %}{% endfor %}
{% for e in form.non_field_errors %}<div>{{ e }}</div>{% endfor %}
</div>
{% endif %}
<form method="post" action="{% url 'register' %}">
{% csrf_token %}
<div class="mb-3">
<label class="form-label" style="font-size:.8rem;font-weight:600;color:#b5bac1;text-transform:uppercase;letter-spacing:.5px;">Username</label>
<input type="text" name="username" class="form-control" id="id_reg_username" required autofocus value="{{ form.username.value|default:'' }}">
</div>
<div class="mb-3">
<label class="form-label" style="font-size:.8rem;font-weight:600;color:#b5bac1;text-transform:uppercase;letter-spacing:.5px;">Email</label>
<input type="email" name="email" class="form-control" id="id_reg_email" required value="{{ form.email.value|default:'' }}">
</div>
<div class="mb-4">
<label class="form-label" style="font-size:.8rem;font-weight:600;color:#b5bac1;text-transform:uppercase;letter-spacing:.5px;">Password</label>
<input type="password" name="password" class="form-control" id="id_reg_password" required>
</div>
<button type="submit" class="btn btn-primary w-100" id="btn_register">Register</button>
</form>
<p class="text-center mt-3" style="font-size:.85rem;color:#b5bac1;">
Have account? <a href="{% url 'login' %}" style="color:#00b0f4;">Login</a>
</p>
</div>
</div>
{% endblock %}