feat(chat): add HTML templates
This commit is contained in:
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user