diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3ba657f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +npm-debug.log +tickets.db +data/ +.env +.git +.gitignore \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7a36630 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM node:20-alpine + +WORKDIR /app + +# Copy package files first for better layer caching +COPY package.json package-lock.json* ./ + +RUN npm ci --omit=dev + +# Copy the rest of the application +COPY server.js ./ +COPY public/ ./public/ +COPY assets/ ./assets/ + +# Create data directory for the database +RUN mkdir -p /app/data + +EXPOSE 3000 + +CMD ["node", "server.js"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..81d6228 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + ticketsystem: + build: . + container_name: ticketsystem + restart: unless-stopped + ports: + - "3000:3000" + volumes: + - ./data:/app/data # Bind mount for the SQLite database + - ./assets:/app/assets # Bind mount so you can update avatars etc. + env_file: + - .env \ No newline at end of file diff --git a/package.json b/package.json index b879917..2af8724 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ticketsystem", - "version": "1.0.0", + "version": "1.2", "description": "", "main": "server.js", "scripts": { diff --git a/public/index.html b/public/index.html index c591f9e..94f3a7c 100644 --- a/public/index.html +++ b/public/index.html @@ -24,7 +24,6 @@ - Admin Dashboard @@ -139,4 +138,4 @@ - \ No newline at end of file + diff --git a/public/ticketview/ticket-shared.js b/public/ticketview/ticket-shared.js new file mode 100644 index 0000000..f6930ac --- /dev/null +++ b/public/ticketview/ticket-shared.js @@ -0,0 +1,86 @@ +export async function fetchTicket(id) { + const res = await fetch(`/api/tickets/${id}`); + if (!res.ok) return null; + return res.json(); +} + +export async function fetchComments(id) { + const res = await fetch(`/api/tickets/${id}/comments`); + if (!res.ok) return []; + return res.json(); +} + +export async function fetchHistory(id) { + const res = await fetch(`/api/tickets/${id}/history`); + if (!res.ok) return []; + return res.json(); +} + +export async function logTime(id, timeLog) { + // timeLog = { minutes: Number, comment: String, author: String } + const res = await fetch(`/api/tickets/${id}/time`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(timeLog), + }); + return res.json(); +} + +// Flexible Update-Funktion fΓΌr beliebige Felder, z.B. {status, priority, category, actor, ...} +export async function updateTicket(id, data) { + const res = await fetch(`/api/tickets/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + return res.json(); +} + +export async function createComment(id, comment) { + const res = await fetch(`/api/tickets/${id}/comments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(comment), + }); + return res.json(); +} + +export function formatDateTime(date) { + if (!date) return 'n/a'; + + // Convert SQLite DATETIME if needed + const safeDate = date.includes('T') ? date : date.replace(' ', 'T'); + const parsed = new Date(safeDate + 'Z'); // Assume UTC + + if (isNaN(parsed.getTime())) return 'n/a'; + + return parsed.toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +export function formatMinutes(mins) { + if (typeof mins !== 'number' || isNaN(mins)) return '0 Min.'; + const hours = Math.floor(mins / 60); + const minutes = mins % 60; + if (hours > 0 && minutes > 0) return `${hours} Std. ${minutes} Min.`; + if (hours > 0) return `${hours} Std.`; + return `${minutes} Min.`; +} + +export function getHistoryIcon(action) { + const icons = { + 'Ticket Created': 'plus-circle', + 'Status Changed': 'sync-alt', + 'Priority Changed': 'exclamation-circle', + 'Comment Added': 'comment', + 'Ticket Deleted': 'trash-alt', + 'Category Changed': 'tags', + 'Time Logged': 'clock', + }; + return icons[action] || 'history'; +} \ No newline at end of file diff --git a/public/ticketview/ticket-user.js b/public/ticketview/ticket-user.js new file mode 100644 index 0000000..089a63c --- /dev/null +++ b/public/ticketview/ticket-user.js @@ -0,0 +1,210 @@ +import { + fetchTicket, fetchComments, fetchHistory, + createComment, + formatDateTime, formatMinutes, getHistoryIcon +} from './ticket-shared.js'; + +document.addEventListener('DOMContentLoaded', () => { + document.body.classList.add('user-view'); + + const ticketId = new URLSearchParams(window.location.search).get('id'); + if (!ticketId) return window.location.href = '/'; + + // DOM elements + const ticketTitle = document.getElementById('ticket-title-text'); + const ticketIdElem = document.getElementById('ticket-id-val'); + const ticketStatus = document.getElementById('ticket-status-badge'); + const ticketPriority = document.getElementById('priority-text'); + const ticketDescription = document.getElementById('ticket-description-text'); + const ticketRequester = document.getElementById('ticket-requester'); + const ticketCreated = document.getElementById('ticket-created'); + const ticketUpdated = document.getElementById('ticket-updated'); + const ticketCategory = document.getElementById('ticket-category'); + const commentsContainer = document.getElementById('comments-container'); + const historyContainer = document.getElementById('history-container'); + const commentForm = document.getElementById('comment-form'); + const tabBtns = document.querySelectorAll('.tab-btn'); + const totalTimeDisplay = document.getElementById('total-time'); + + let webhookConfig = null; + + // Fetch Webhook Configuration + async function loadWebhookConfig() { + try { + const response = await fetch('/api/webhooks'); + if (response.ok) { + webhookConfig = await response.json(); + console.log('Webhook configuration loaded:', webhookConfig.configured); + } else { + console.warn('Failed to load webhook configuration'); + webhookConfig = { admin: null, user: null, configured: { admin: false, user: false } }; + } + } catch (error) { + console.error('Error loading webhook configuration:', error); + webhookConfig = { admin: null, user: null, configured: { admin: false, user: false } }; + } + } + + // Send Webhook Notification + async function sendAdminWebhook(message) { + if (!webhookConfig?.admin) { + console.log('Admin webhook not configured, skipping notification'); + return; + } + + try { + const res = await fetch(webhookConfig.admin, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: message }) + }); + + if (!res.ok) { + console.error('Admin webhook failed:', res.statusText); + } + } catch (err) { + console.error('Error sending admin webhook:', err); + } + } + + // Links automatically convert to clickable + function linkify(text) { + const urlPattern = /(\bhttps?:\/\/[^\s<]+)/g; + return text.replace(urlPattern, '$1'); + } + + let originalStatus = ''; + let originalPriority = ''; + + async function loadTicketDetails() { + const ticket = await fetchTicket(ticketId); + if (!ticket) return alert('Ticket not found'); + + ticketTitle.textContent = ticket.title; + ticketIdElem.textContent = ticket.id; + ticketStatus.textContent = ticket.status; + ticketDescription.innerHTML = linkify(ticket.description || ''); + ticketRequester.textContent = ticket.requester; + ticketCreated.textContent = formatDateTime(ticket.created_at); + ticketUpdated.textContent = formatDateTime(ticket.updated_at); + ticketPriority.textContent = ticket.priority; + + if (ticketCategory) { + ticketCategory.textContent = ticket.category || 'β€”'; + } + + ticketStatus.className = `ticket-status status-${ticket.status.replace(/ /g, '-')}`; + + const totalTimeDisplay = document.getElementById('total-time'); + if (totalTimeDisplay) { + totalTimeDisplay.textContent = formatMinutes(ticket.total_time || 0); + } + + originalStatus = ticket.status; + originalPriority = ticket.priority; + } + + async function loadComments() { + const comments = await fetchComments(ticketId); + commentsContainer.innerHTML = comments.map(comment => { + const role = comment.role ? comment.role.toLowerCase() : 'user'; + let avatarUrl = '/assets/user.jpg'; + if (role === 'admin') avatarUrl = '/assets/admin.jpg'; + else if (role === 'moderator') avatarUrl = '/assets/moderator.jpg'; + + // Comment content is linkified + const linkedContent = linkify(comment.content || ''); + + return ` +
+ ${role.charAt(0).toUpperCase() + role.slice(1)} Avatar +
+
+ ${comment.author} + ${formatDateTime(comment.date)} +
+
${linkedContent}
+
+
+ `; + }).join(''); + } + + async function loadHistory() { + const history = await fetchHistory(ticketId); + historyContainer.innerHTML = history.map(h => ` +
+
+
+
${h.action}
+
${h.message || h.details || ''}
+
+ By ${h.author || h.actor || 'unknown'} + ${formatDateTime(h.date)} +
+
+
+ `).join(''); + } + + if (commentForm) { + commentForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const author = document.getElementById('comment-author')?.value || 'Unknown'; + const content = document.getElementById('comment-content')?.value; + if (!content?.trim()) return alert('Please enter a comment'); + + const timestamp = new Date().toISOString(); + + try { + await createComment(ticketId, { author, content, timestamp }); + document.getElementById('comment-content').value = ''; + await loadComments(); + await loadHistory(); + await sendAdminWebhook(`New comment added by ${author}: "${content}" on ticket #${ticketId}`); + } catch (err) { + console.error('Failed to create comment:', err); + alert('Failed to submit comment. Please try again.'); + } + }); + } + + async function checkForChanges() { + const ticket = await fetchTicket(ticketId); + if (!ticket) return; + + // Update UI if status or priority changes server-side + if (ticket.status !== originalStatus) { + originalStatus = ticket.status; + ticketStatus.textContent = ticket.status; + ticketStatus.className = `ticket-status status-${ticket.status.replace(/ /g, '-')}`; + } + + if (ticket.priority !== originalPriority) { + originalPriority = ticket.priority; + ticketPriority.textContent = ticket.priority; + } + + // Check and update category if necessary + if (ticketCategory && ticket.category !== ticketCategory.textContent) { + ticketCategory.textContent = ticket.category || 'β€”'; + } + } + + tabBtns.forEach(btn => btn.addEventListener('click', () => { + tabBtns.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active')); + document.getElementById(`${btn.dataset.tab}-tab`).classList.add('active'); + })); + + // Initial load + loadWebhookConfig().then(() => { + loadTicketDetails(); + loadComments(); + loadHistory(); + }); + + // Periodic updates + setInterval(checkForChanges, 30000); +}); \ No newline at end of file diff --git a/public/user-script.js b/public/user-script.js new file mode 100644 index 0000000..f615a67 --- /dev/null +++ b/public/user-script.js @@ -0,0 +1,430 @@ +document.addEventListener('DOMContentLoaded', () => { + const isAdminView = document.body.classList.contains('admin-view'); + const createForm = document.getElementById('create-form'); + const ticketsContainer = document.getElementById('tickets-container'); + + const statusFilter = document.getElementById('status-filter'); + const categoryFilter = document.getElementById('category-filter'); + const searchInput = document.getElementById('search-input'); + + // Webhook configuration - will be loaded dynamically + let webhookConfig = null; + + // --- Ticket Modal Elements --- + const openModalBtn = document.getElementById('open-modal-btn'); + const modal = document.getElementById('create-ticket-modal'); + const closeModalBtn = document.getElementById('close-modal-btn'); + + // --- PFP Modal Elements --- + const updatePfpBtn = document.getElementById('update-pfp-btn'); + const pfpModal = document.getElementById('update-pfp-modal'); + const closePfpModalBtn = document.getElementById('close-pfp-modal-btn'); + const dropZone = document.getElementById('drop-zone'); + const fileInput = document.getElementById('pfp-file-input'); + const uploadPfpBtn = document.getElementById('upload-pfp-btn'); + + let selectedPfpFile = null; + let allTickets = []; + + // --- Helper --- + function formatStatusClass(status) { + if (!status) return 'Unknown'; + return status + .split(' ') + .map(s => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()) + .join('-'); + } + + // --- Webhook Functions --- + async function loadWebhookConfig() { + try { + const response = await fetch('/api/webhooks'); + if (response.ok) { + webhookConfig = await response.json(); + console.log('Webhook configuration loaded:', webhookConfig.configured); + } else { + console.warn('Failed to load webhook configuration'); + webhookConfig = { admin: null, user: null, configured: { admin: false, user: false } }; + } + } catch (error) { + console.error('Error loading webhook configuration:', error); + webhookConfig = { admin: null, user: null, configured: { admin: false, user: false } }; + } + } + + async function sendWebhookNotification(webhookUrl, message) { + if (!webhookUrl) { + console.log('No webhook URL provided, skipping notification'); + return; + } + + try { + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(message) + }); + + if (!response.ok) { + console.error(`Webhook notification failed: ${response.status} ${response.statusText}`); + } else { + console.log('Webhook notification sent successfully'); + } + } catch (error) { + console.error('Failed to send webhook notification:', error); + } + } + + async function sendAdminWebhook(ticket) { + if (!webhookConfig?.admin) { + console.log('Admin webhook not configured, skipping notification'); + return; + } + + const message = { + embeds: [{ + title: '🎫 New Ticket Created', + description: `**Ticket #${ticket.id}**: ${ticket.title}`, + fields: [ + { name: 'πŸ‘€ Requester', value: 'Laila' }, + { name: 'πŸ“Š Status', value: ticket.status || 'Open', inline: true }, + { name: '⚑ Priority', value: ticket.priority || 'None', inline: true }, + { name: 'πŸ“‚ Category', value: ticket.category || 'None', inline: true }, + { name: 'πŸ“ Description', value: ticket.description.length > 100 ? + ticket.description.substring(0, 100) + '...' : + ticket.description, inline: false } + ], + timestamp: new Date().toISOString(), + color: 0x00ff00, + footer: { + text: 'Ticket System', + icon_url: 'https://cdn-icons-png.flaticon.com/512/2666/2666505.png' + } + }] + }; + + await sendWebhookNotification(webhookConfig.admin, message); + } + + // --- Ticket Modal Open/Close --- + function openModal() { + if (modal) { + const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth; + modal.style.display = 'flex'; + document.body.style.overflow = 'hidden'; + document.documentElement.style.overflow = 'hidden'; + if (scrollBarWidth > 0) { + document.body.style.paddingRight = scrollBarWidth + 'px'; + } + } + } + function closeModal() { + if (modal) { + modal.style.display = 'none'; + document.body.style.overflow = ''; + document.documentElement.style.overflow = ''; + document.body.style.paddingRight = ''; + } + } + function outsideClickListener(event) { + if (event.target === modal) closeModal(); + } + function escKeyListener(event) { + if (event.key === 'Escape' && modal.style.display === 'flex') { + closeModal(); + } + } + if (openModalBtn) openModalBtn.addEventListener('click', openModal); + if (closeModalBtn) closeModalBtn.addEventListener('click', closeModal); + if (modal) { + window.addEventListener('click', outsideClickListener); + window.addEventListener('keydown', escKeyListener); + } + + // --- PFP Modal Open/Close --- + function openPfpModal() { + if (pfpModal) { + const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth; + pfpModal.style.display = 'flex'; + document.body.style.overflow = 'hidden'; + document.documentElement.style.overflow = 'hidden'; + if (scrollBarWidth > 0) { + document.body.style.paddingRight = scrollBarWidth + 'px'; + } + } + } + function closePfpModal() { + if (pfpModal) { + pfpModal.style.display = 'none'; + document.body.style.overflow = ''; + document.documentElement.style.overflow = ''; + document.body.style.paddingRight = ''; + } + } + if (updatePfpBtn) updatePfpBtn.addEventListener('click', openPfpModal); + if (closePfpModalBtn) closePfpModalBtn.addEventListener('click', closePfpModal); + if (pfpModal) { + window.addEventListener('click', (e) => { + if (e.target === pfpModal) closePfpModal(); + }); + window.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && pfpModal.style.display === 'flex') closePfpModal(); + }); + } + + // --- Drag & Drop Logic --- + if (dropZone) { + dropZone.addEventListener('click', () => fileInput.click()); + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.style.borderColor = '#10b981'; + }); + dropZone.addEventListener('dragleave', () => { + dropZone.style.borderColor = '#4b5563'; + }); + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.style.borderColor = '#4b5563'; + const file = e.dataTransfer.files[0]; + if (file && file.type.startsWith('image/')) { + selectedPfpFile = file; + dropZone.querySelector('p').textContent = file.name; + } + }); + } + + if (fileInput) { + fileInput.addEventListener('change', (e) => { + const file = e.target.files[0]; + if (file && file.type.startsWith('image/')) { + selectedPfpFile = file; + dropZone.querySelector('p').textContent = file.name; + } + }); + } + + // --- Upload PFP --- + if (uploadPfpBtn) { + uploadPfpBtn.addEventListener('click', async () => { + if (!selectedPfpFile) { + alert('Please select an image first.'); + return; + } + const formData = new FormData(); + formData.append('pfp', selectedPfpFile, 'user.jpg'); + try { + const res = await fetch('/api/upload-pfp', { + method: 'POST', + body: formData + }); + if (!res.ok) throw new Error('Upload failed'); + alert('Profile picture updated!'); + closePfpModal(); + window.location.reload(); + } catch (err) { + console.error(err); + alert('Error uploading profile picture'); + } + }); + } + + // --- API --- + async function fetchTickets() { + try { + const res = await fetch('/api/tickets'); + return await res.json(); + } catch (err) { + console.error('Error fetching tickets:', err); + return []; + } + } + async function createTicket(ticket) { + try { + const res = await fetch('/api/tickets', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(ticket) + }); + return await res.json(); + } catch (err) { + console.error('Error creating ticket:', err); + throw err; + } + } + + // --- Date Formatting --- + function formatDate(dateStr) { + return new Date(dateStr).toLocaleDateString(undefined, { + year: 'numeric', month: 'short', day: 'numeric' + }); + } + + // --- Render --- + function renderFilteredTickets() { + const status = statusFilter?.value || 'all'; + const category = categoryFilter?.value || 'all'; + const search = searchInput?.value.trim().toLowerCase() || ''; + + const openTickets = allTickets.filter(ticket => + (ticket.status || '').toLowerCase() !== 'closed' + ); + const closedTickets = allTickets.filter(ticket => + (ticket.status || '').toLowerCase() === 'closed' + ); + + const filteredOpen = openTickets.filter(ticket => { + const ticketStatus = (ticket.status || '').toLowerCase(); + const ticketCategory = (ticket.category || '').toLowerCase(); + const ticketTitle = (ticket.title || '').toLowerCase(); + const ticketDescription = (ticket.description || '').toLowerCase(); + const matchesStatus = status === 'all' || ticketStatus === status.toLowerCase(); + const matchesCategory = category === 'all' || ticketCategory === category.toLowerCase(); + const matchesSearch = + ticketTitle.includes(search) || + ticketDescription.includes(search); + return matchesStatus && matchesCategory && matchesSearch; + }); + + const filteredClosed = closedTickets.filter(ticket => { + const ticketCategory = (ticket.category || '').toLowerCase(); + const ticketTitle = (ticket.title || '').toLowerCase(); + const ticketDescription = (ticket.description || '').toLowerCase(); + const matchesCategory = category === 'all' || ticketCategory === category.toLowerCase(); + const matchesSearch = + ticketTitle.includes(search) || + ticketDescription.includes(search); + return matchesCategory && matchesSearch; + }); + + renderTickets([...filteredOpen, ...filteredClosed]); + } + + function renderTickets(tickets) { + const openContainer = document.getElementById('tickets-container'); + const closedContainer = document.getElementById('closed-tickets-container'); + openContainer.innerHTML = ''; + closedContainer.innerHTML = ''; + if (!tickets.length) { + openContainer.innerHTML = ` +
+ +

No tickets found

+

Try changing your filters or create a new ticket

+
`; + closedContainer.innerHTML = ''; + return; + } + tickets.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); + tickets.forEach(ticket => { + const statusSafe = formatStatusClass(ticket.status); + const prioritySafe = (ticket.priority || 'None'); + const el = document.createElement('div'); + el.className = `ticket status-${statusSafe.replace(/\s+/g, '-')}`; + el.innerHTML = ` +

${ticket.title} #${ticket.id} Time Cin: (${ticket.total_time} min)

+

${ticket.description}

+
+ ${ticket.requester} + ${formatDate(ticket.created_at)} + ${ticket.category ? ` ${ticket.category}` : ''} +
+
+
+ ${ticket.status} +
+
+ ${prioritySafe} +
+
+
+ + View Details + +
+ `; + if ((ticket.status || '').toLowerCase() === 'closed') { + closedContainer.appendChild(el); + } else { + openContainer.appendChild(el); + } + }); + } + + // --- Handlers --- + let isSubmittingTicket = false; // prevent double submit + + async function handleCreateTicket(e) { + e.preventDefault(); + if (isSubmittingTicket) return; + isSubmittingTicket = true; + + const title = document.getElementById('title')?.value.trim(); + const description = document.getElementById('description')?.value.trim(); + const priority = document.getElementById('priority')?.value; + const category = document.getElementById('category')?.value; + + if (!title || !description) { + alert('Please fill in all required fields'); + isSubmittingTicket = false; + return; + } + + const submitBtn = createForm.querySelector('.btn-primary'); + submitBtn.disabled = true; + + const ticket = { + title, + description, + priority, + category, + requester: 'Laila', + status: 'Open' + }; + + try { + const createdTicket = await createTicket(ticket); + createForm.reset(); + closeModal(); + await refreshTickets(); + await sendAdminWebhook({ + id: createdTicket.id, + title, + description, + priority, + category, + status: 'Open' + }); + + submitBtn.innerHTML = ' Created!'; + submitBtn.style.backgroundColor = '#28a745'; + setTimeout(() => { + submitBtn.innerHTML = ' Create Ticket'; + submitBtn.style.backgroundColor = ''; + submitBtn.disabled = false; + isSubmittingTicket = false; + }, 2000); + } catch { + alert('Failed to create ticket'); + submitBtn.disabled = false; + isSubmittingTicket = false; + } + } + + async function refreshTickets() { + allTickets = await fetchTickets(); + renderFilteredTickets(); + } + + // --- Init --- + async function initialize() { + await loadWebhookConfig(); + await refreshTickets(); + } + + if (createForm) createForm.addEventListener('submit', handleCreateTicket); + if (statusFilter) statusFilter.addEventListener('change', renderFilteredTickets); + if (categoryFilter) categoryFilter.addEventListener('change', renderFilteredTickets); + if (searchInput) searchInput.addEventListener('input', renderFilteredTickets); + + initialize(); +}); \ No newline at end of file diff --git a/public/user.css b/public/user.css new file mode 100644 index 0000000..bdb7200 --- /dev/null +++ b/public/user.css @@ -0,0 +1,657 @@ +/* user.css - Dark Mode Default - Improved */ + +/* ===== FONT IMPORT ===== */ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); + +/* ===== CSS CUSTOM PROPERTIES ===== */ +:root { + --primary-color: #4A90E2; + --primary-hover-color: #6fb4f5; + --secondary-color: #50E3C2; + --accent-color: #F5A623; + --success-color: #7ED321; + --danger-color: #D0021B; + --neutral-dark: #e0e0e0; + --neutral-medium: #aaaaaa; + --neutral-light: #777777; + --bg-color: #1e1e1e; + --card-bg-color: #2a2a2a; + --border-color: #444; + --shadow-color: rgba(0, 0, 0, 0.6); + --text-color: #e0e0e0; + --link-color: var(--primary-color); + --font-family: 'Inter', sans-serif; + --border-radius: 8px; + --card-padding: 15px; + --input-padding: 12px 15px; + --transition-speed: 0.2s ease-in-out; + --container-max-width: 1400px; +} + +/* ===== RESET & BASE STYLES ===== */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: var(--font-family); + background-color: var(--bg-color); + color: var(--text-color); + font-size: 16px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ===== TYPOGRAPHY ===== */ +h1, h2, h3, h4, h5, h6 { + color: var(--neutral-dark); + margin-bottom: 0.75em; + font-weight: 600; + line-height: 1.2; +} + +h1 { font-size: 2.2rem; } +h2 { font-size: 1.8rem; } +h3 { font-size: 1.4rem; } + +/* ===== LINKS ===== */ +a { + color: var(--link-color); + text-decoration: none; + transition: color var(--transition-speed); + cursor: pointer; +} + +a:hover, +a:focus { + color: var(--primary-hover-color); + text-decoration: underline; + outline: none; +} + +/* ===== LAYOUT ===== */ +.container { + max-width: var(--container-max-width); + margin: 0 auto; + padding: 20px 30px; + width: 100%; +} + +/* ===== HEADER ===== */ +.header { + background: var(--card-bg-color); + padding: 20px var(--card-padding); + border-radius: var(--border-radius); + box-shadow: 0 2px 10px var(--shadow-color); + margin-bottom: 30px; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; +} + +.header h1 { + color: var(--primary-color); + font-size: 1.7rem; + font-weight: 700; + display: flex; + align-items: center; + gap: 12px; +} + +.header h1 i { + font-size: 1.6rem; +} + +.user-actions { + display: flex; + gap: 20px; + align-items: center; + flex-wrap: wrap; +} + +.user-actions span { + color: var(--neutral-medium); + font-weight: 500; +} + +/* ===== FOOTER ===== */ +.footer { + text-align: center; + margin-top: 50px; + padding: 25px; + color: var(--neutral-light); + font-size: 0.9rem; + border-top: 1px solid var(--border-color); +} + +/* ===== FORM ELEMENTS ===== */ +.form-group { + margin-bottom: 20px; + width: 100%; +} + +.form-group input[type="text"], +.form-group textarea, +.form-group select { + width: 100%; + padding: var(--input-padding); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + background-color: var(--bg-color); + color: var(--text-color); + font-family: var(--font-family); + font-size: 1rem; + transition: border-color var(--transition-speed), box-shadow var(--transition-speed); +} + +.form-group input[type="text"]:focus, +.form-group textarea:focus, +.form-group select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.2); +} + +.form-group textarea { + resize: vertical; + min-height: 80px; + line-height: 1.4; +} + +.form-row { + display: flex; + gap: 15px; + flex-wrap: wrap; +} + +.form-row .form-group { + flex: 1; + min-width: 180px; +} + +.button-wrapper { + text-align: center; +} + +/* ===== BUTTONS ===== */ +.btn-primary { + padding: 10px 20px; + border: none; + border-radius: var(--border-radius); + cursor: pointer; + font-size: 1rem; + font-weight: 500; + transition: background-color var(--transition-speed), transform var(--transition-speed); + align-items: center; + justify-content: center; + gap: 8px; + background-color: var(--primary-color); + color: white; + user-select: none; + -webkit-tap-highlight-color: transparent; +} + +.btn-primary:hover, +.btn-primary:focus { + background-color: var(--primary-hover-color); + transform: translateY(-1px); + outline: none; +} + +.btn-primary:active { + transform: translateY(0); +} + +/* ===== CARDS & SECTIONS ===== */ +.ticket-form, +.tickets-section { + background: var(--card-bg-color); + padding: var(--card-padding); + border-radius: var(--border-radius); + box-shadow: 0 4px 15px var(--shadow-color); + margin-bottom: 30px; + max-width: 100%; + width: 100%; + box-sizing: border-box; +} + +/* Improved ticket-form styles for clearer layout */ +.ticket-form h2 { + margin-bottom: 20px; + color: var(--neutral-dark); + font-weight: 700; + font-size: 1.5rem; +} + +/* ===== SECTION HEADER ===== */ +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 25px; + flex-wrap: wrap; + gap: 15px; +} + +.section-header h2 { + margin: 0; + color: var(--neutral-dark); + font-weight: 700; + display: flex; + align-items: center; + gap: 10px; +} + +.section-header h2 i { + color: var(--primary-color); + font-size: 1.4rem; +} + +/* ===== FILTERS ===== */ +.filters-row { + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: center; +} + +.filters-row input, +.filters-row select { + padding: 6px 10px; + border-radius: 5px; + border: 1px solid var(--border-color); + background-color: var(--bg-color); + color: var(--text-color); + font-family: var(--font-family); + font-size: 0.95rem; + min-width: 120px; + transition: border-color var(--transition-speed), box-shadow var(--transition-speed); +} + +.filters-row input:focus, +.filters-row select:focus { + border-color: var(--primary-color); + box-shadow: 0 0 5px rgba(74, 144, 226, 0.3); + outline: none; +} + +/* ===== TICKETS GRID ===== */ +.tickets-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 25px; +} + +/* ===== TICKET CARDS ===== */ +.ticket { + background: var(--card-bg-color); + padding: var(--card-padding); + border-radius: var(--border-radius); + box-shadow: 0 2px 8px var(--shadow-color); + display: flex; + flex-direction: column; + transition: transform var(--transition-speed), box-shadow var(--transition-speed); + min-height: 250px; + position: relative; + overflow: hidden; +} + +.ticket:hover, +.ticket:focus-within { + transform: translateY(-3px); + box-shadow: 0 6px 12px var(--shadow-color); + outline: none; +} + +.ticket h3 { + margin-top: 0; + margin-bottom: 16px; + color: var(--neutral-dark); + font-size: 1.2rem; + font-weight: 600; + line-height: 1.3; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + overflow: hidden; + text-overflow: ellipsis; + position: relative; + padding-right: 80px; + padding-top: 8px; + user-select: text; +} + +.ticket .ticket-id { + background-color: var(--primary-color); + color: white; + font-size: 0.8rem; + font-weight: 700; + padding: 6px 10px; + border-radius: 12px; + position: absolute; + top: 5px; + right: 0; + letter-spacing: 0.5px; + user-select: none; +} + +.ticket .description { + color: var(--neutral-medium); + font-size: 0.95rem; + line-height: 1.4; + margin-bottom: 18px; + overflow: hidden; + word-wrap: break-word; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + text-overflow: ellipsis; + height: 22px; + user-select: text; +} + +.ticket .meta { + display: flex; + justify-content: space-between; + font-size: 0.85rem; + color: var(--neutral-light); + margin-bottom: 15px; + flex-shrink: 0; + user-select: none; +} + +.ticket .meta i { + margin-right: 5px; +} + +/* Status row and actions */ +.status-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + flex-shrink: 0; +} + +.ticket-actions { + margin-top: auto; + flex-shrink: 0; +} + +.action-btn { + display: block; + text-align: center; + padding: 10px 15px; + border-radius: var(--border-radius); + text-decoration: none; + font-size: 0.95rem; + font-weight: 500; + transition: all var(--transition-speed); + user-select: none; + cursor: pointer; +} + +.action-btn.view-btn { + background-color: var(--primary-color); + color: white; +} + +.action-btn.view-btn:hover, +.action-btn.view-btn:focus { + background-color: var(--primary-hover-color); + transform: translateY(-1px); + outline: none; +} + +.action-btn.view-btn:active { + transform: translateY(0); +} + +.total-time { + font-size: 0.8em; + color: #555; + margin-left: 8px; + user-select: none; +} + +/* ===== BADGES ===== */ +.status, +.priority { + display: inline-block; + padding: 5px 12px; + border-radius: 20px; + font-size: 0.85rem; + font-weight: 600; + text-transform: capitalize; + line-height: 1.4; + white-space: nowrap; + user-select: none; +} + +.status i, +.priority i { + margin-right: 5px; +} + +/* Status colors */ +.status-Open { + background-color: rgba(74, 144, 226, 0.15); + color: #4A90E2; +} +.status-In-Progress { + background-color: rgba(245, 166, 35, 0.15); + color: #F5A623; +} +.status-On-Hold { + background-color: rgba(155, 155, 155, 0.15); + color: #9B9B9B; +} +.status-Closed { + background-color: rgba(126, 211, 33, 0.15); + color: #7ED321; +} +.status-Cancelled { + background-color: rgba(208, 2, 27, 0.15); + color: #D0021B; +} + +/* Priority colors */ +.priority-Low { + background-color: rgba(80, 227, 194, 0.2); + color: #60b8a5; +} +.priority-Medium { + background-color: rgba(74, 144, 226, 0.2); + color: #4A90E2; +} +.priority-High { + background-color: rgba(245, 166, 35, 0.2); + color: #F5A623; +} +.priority-Urgent { + background-color: rgba(208, 2, 27, 0.15); + color: #D0021B; +} + +/* ===== MODAL ===== */ +.modal-overlay { + position: fixed; + inset: 0; + display: flex; + justify-content: center; + align-items: center; + z-index: 999; +} + +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: none; /* hidden by default */ + justify-content: center; /* center horizontally */ + align-items: center; /* center vertically */ + background: rgba(24, 23, 23, 0.6); + z-index: 9999; + overflow-y: auto; + padding: 40px 20px; +} + +.modal.show { + display: flex; + opacity: 1; + pointer-events: auto; +} + +.modal-content { + padding: 30px 35px; + max-width: 800px; + width: 100%; + animation: fadeInUp 0.3s ease; + position: relative; + box-sizing: border-box; +} + +/* Close button */ +.modal-close-btn, +.modal-close { + position: absolute; + top: 30px; + right: 35px; + font-size: 1.5rem; + background: none; + border: none; + cursor: pointer; + color: var(--neutral-light); + transition: color var(--transition-speed), background-color var(--transition-speed); + padding: 6px 10px; + border-radius: var(--border-radius); +} + +.modal-close-btn:hover, +.modal-close-btn:focus, +.modal-close:hover, +.modal-close:focus { + color: var(--primary-color); + background-color: rgba(74, 144, 226, 0.1); + outline: none; +} + +.modal-header { + margin-bottom: 20px; +} + +.modal-header h2 { + margin: 0; + font-weight: 700; + color: var(--primary-color); + font-size: 1.6rem; +} + +/* Animations */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(15px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ===== User PFP ===== */ + +.avatar { + width: 50px; + height: 50px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + +.btn-update-pfp { + background-color: #10b981; /* emerald green */ + color: #fff; + border: none; + border-radius: 8px; + padding: 6px 12px; + align-items: center; + gap: 6px; + cursor: pointer; + transition: background 0.2s ease; +} + +.btn-update-pfp:hover { + background-color: #059669; /* darker emerald */ +} + +/* Smaller modal size */ +.small-modal { + max-width: 350px; + text-align: center; +} + +/* Drag & drop zone */ +#drop-zone { + border: 2px dashed #4b5563; /* gray tone */ + background-color: #1f2937; /* dark mode box */ + border-radius: 8px; + padding: 20px; + cursor: pointer; + color: #9ca3af; + transition: border-color 0.2s ease, background 0.2s ease; +} + +#drop-zone:hover { + border-color: #10b981; /* emerald green accent */ + background-color: #111827; +} + +/* ===== RESPONSIVE ===== */ +@media (max-width: 991px) { + .header { + flex-direction: column; + gap: 15px; + } + .header h1 { + font-size: 1.4rem; + justify-content: center; + } + .user-actions { + justify-content: center; + } + .tickets-grid { + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + } + .ticket { + min-height: 230px; + } +} + +@media (max-width: 575px) { + .container { + padding: 15px 20px; + } + .ticket-form, + .tickets-section { + padding-left: 20px; + padding-right: 20px; + } + .tickets-grid { + grid-template-columns: 1fr; + } + .ticket h3 { + padding-right: 50px; + } + .header h1 { + font-size: 1.2rem; + } +} \ No newline at end of file