01
This commit is contained in:
parent
b250816730
commit
f9536ee19b
@ -1,403 +1,598 @@
|
||||
:root {
|
||||
--bg: #f3f5f7;
|
||||
--surface: #ffffff;
|
||||
--surface-muted: #f8fafc;
|
||||
--surface-soft: #eef2f6;
|
||||
--border: #d6dde6;
|
||||
--border-strong: #c4ced8;
|
||||
--text: #0f172a;
|
||||
--muted: #5b6b7d;
|
||||
--primary: #0f172a;
|
||||
--accent: #2563eb;
|
||||
--accent-soft: #dbe8ff;
|
||||
--success-bg: #eaf7ee;
|
||||
--success-text: #17603a;
|
||||
--warning-bg: #fff5e8;
|
||||
--warning-text: #a65a00;
|
||||
--danger-bg: #fdeeee;
|
||||
--danger-text: #b42318;
|
||||
--idle-bg: #edf1f5;
|
||||
--idle-text: #4b5f74;
|
||||
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||
--shadow-md: 0 12px 30px rgba(15, 23, 42, 0.06);
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--container-max: 1320px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
|
||||
background-size: 400% 400%;
|
||||
animation: gradient 15s ease infinite;
|
||||
color: #212529;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.main-wrapper {
|
||||
display: flex;
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
max-width: var(--container-max);
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 10px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@keyframes gradient {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 85vh;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.2);
|
||||
backdrop-filter: blur(15px);
|
||||
-webkit-backdrop-filter: blur(15px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
.brand-copy small,
|
||||
.nav-note,
|
||||
.helper-text,
|
||||
.form-text,
|
||||
.meta-copy,
|
||||
.section-subtitle,
|
||||
.page-eyebrow,
|
||||
.footer-copy,
|
||||
.overline {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 85%;
|
||||
padding: 0.85rem 1.1rem;
|
||||
border-radius: 16px;
|
||||
line-height: 1.5;
|
||||
font-size: 0.95rem;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
|
||||
animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px) scale(0.95); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.message.visitor {
|
||||
align-self: flex-end;
|
||||
background: linear-gradient(135deg, #212529 0%, #343a40 100%);
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.message.bot {
|
||||
align-self: flex-start;
|
||||
background: #ffffff;
|
||||
color: #212529;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
padding: 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.chat-input-area form {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.chat-input-area input {
|
||||
flex: 1;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem 1rem;
|
||||
outline: none;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.chat-input-area input:focus {
|
||||
border-color: #23a6d5;
|
||||
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2);
|
||||
}
|
||||
|
||||
.chat-input-area button {
|
||||
background: #212529;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.chat-input-area button:hover {
|
||||
background: #000;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* Background Animations */
|
||||
.bg-animations {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.blob {
|
||||
position: absolute;
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1);
|
||||
}
|
||||
|
||||
.blob-1 {
|
||||
top: -10%;
|
||||
left: -10%;
|
||||
background: rgba(238, 119, 82, 0.4);
|
||||
}
|
||||
|
||||
.blob-2 {
|
||||
bottom: -10%;
|
||||
right: -10%;
|
||||
background: rgba(35, 166, 213, 0.4);
|
||||
animation-delay: -7s;
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
}
|
||||
|
||||
.blob-3 {
|
||||
top: 40%;
|
||||
left: 30%;
|
||||
background: rgba(231, 60, 126, 0.3);
|
||||
animation-delay: -14s;
|
||||
width: 450px;
|
||||
height: 450px;
|
||||
}
|
||||
|
||||
@keyframes move {
|
||||
0% { transform: translate(0, 0) rotate(0deg) scale(1); }
|
||||
33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); }
|
||||
66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); }
|
||||
100% { transform: translate(0, 0) rotate(360deg) scale(1); }
|
||||
}
|
||||
|
||||
.header-link {
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.header-link:hover {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Admin Styles */
|
||||
.admin-container {
|
||||
max-width: 900px;
|
||||
margin: 3rem auto;
|
||||
padding: 2.5rem;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.admin-container h1 {
|
||||
margin-top: 0;
|
||||
color: #212529;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 8px;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 1rem;
|
||||
color: #6c757d;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.table td {
|
||||
background: #fff;
|
||||
padding: 1rem;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.table tr td:first-child { border-radius: 12px 0 0 12px; }
|
||||
.table tr td:last-child { border-radius: 0 12px 12px 0; }
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
.brand-copy strong {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover,
|
||||
.btn-primary:focus {
|
||||
background: #020617;
|
||||
border-color: #020617;
|
||||
}
|
||||
|
||||
.btn-outline-secondary {
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-outline-secondary:hover,
|
||||
.btn-outline-secondary:focus {
|
||||
background: #fff;
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 0.8fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.section-card,
|
||||
.metric-card,
|
||||
.message-item,
|
||||
.stat-chip,
|
||||
.detail-block,
|
||||
.mail-preview,
|
||||
.quick-step,
|
||||
.account-row {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.section-card,
|
||||
.mail-preview,
|
||||
.detail-block {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: clamp(1.9rem, 3vw, 2.65rem);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
margin: 0 0 0.9rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-eyebrow,
|
||||
.overline {
|
||||
text-transform: uppercase;
|
||||
font-size: 0.73rem;
|
||||
letter-spacing: 0.08em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hero-copy p,
|
||||
.section-subtitle {
|
||||
max-width: 56rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.1rem;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 1.55rem;
|
||||
font-weight: 700;
|
||||
margin-top: 0.35rem;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.metric-hint {
|
||||
margin-top: 0.35rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 12px;
|
||||
.surface-muted {
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.form-control,
|
||||
.form-select {
|
||||
border-radius: var(--radius-sm);
|
||||
border-color: var(--border-strong);
|
||||
background: #fff;
|
||||
transition: all 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
padding-top: 0.7rem;
|
||||
padding-bottom: 0.7rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #23a6d5;
|
||||
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1);
|
||||
.form-control:focus,
|
||||
.form-select:focus,
|
||||
.message-search input:focus,
|
||||
.btn:focus,
|
||||
.nav-link:focus,
|
||||
.list-link:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 0.25rem rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.header-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
.form-check-input:checked {
|
||||
background-color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.validation-note {
|
||||
font-size: 0.82rem;
|
||||
color: var(--danger-text);
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.inline-note {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
background: var(--surface-soft);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
font-size: 0.84rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.header-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
.stack-sm > * + * {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
padding: 2rem;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 2.5rem;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.admin-card h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
background: #212529;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
.stack-md > * + * {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: #0088cc;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.8rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
width: 100%;
|
||||
transition: all 0.3s ease;
|
||||
.status-badge,
|
||||
.soft-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
padding: 0.32rem 0.65rem;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.webhook-url {
|
||||
font-size: 0.85em;
|
||||
color: #555;
|
||||
.status-success {
|
||||
background: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
border-color: #cce7d4;
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-text);
|
||||
border-color: #f3dfbe;
|
||||
}
|
||||
|
||||
.status-danger {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger-text);
|
||||
border-color: #f2ceca;
|
||||
}
|
||||
|
||||
.status-idle {
|
||||
background: var(--idle-bg);
|
||||
color: var(--idle-text);
|
||||
border-color: #d9e2ea;
|
||||
}
|
||||
|
||||
.soft-badge {
|
||||
background: var(--surface-soft);
|
||||
border-color: var(--border);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.table-shell {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
background: var(--surface-muted);
|
||||
border-bottom-color: var(--border);
|
||||
color: var(--muted);
|
||||
font-size: 0.76rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.table td,
|
||||
.table th {
|
||||
padding: 0.95rem 1rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background: rgba(15, 23, 42, 0.025);
|
||||
}
|
||||
|
||||
.account-meta,
|
||||
.meta-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.meta-list {
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.meta-list strong {
|
||||
font-size: 0.76rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
display: block;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.quick-steps {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.quick-step {
|
||||
padding: 0.95rem 1rem;
|
||||
}
|
||||
|
||||
.quick-step h3 {
|
||||
margin: 0 0 0.3rem;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.quick-step p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.mailbox-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.mailbox-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.message-search {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.message-search input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.72rem 0.9rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
display: block;
|
||||
padding: 0.9rem 1rem;
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.message-item:hover {
|
||||
border-color: var(--primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.message-item.active {
|
||||
border-color: var(--primary);
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.message-subject {
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.message-from,
|
||||
.message-date,
|
||||
.message-preview,
|
||||
.empty-copy,
|
||||
.help-list li,
|
||||
.kicker {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.message-from,
|
||||
.message-date {
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.message-preview {
|
||||
font-size: 0.88rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.history-table-container {
|
||||
overflow-x: auto;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
.message-body {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.history-table {
|
||||
width: 100%;
|
||||
.message-header-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.history-table-time {
|
||||
width: 15%;
|
||||
white-space: nowrap;
|
||||
font-size: 0.85em;
|
||||
color: #555;
|
||||
.detail-block {
|
||||
padding: 0.95rem 1rem;
|
||||
}
|
||||
|
||||
.history-table-user {
|
||||
width: 35%;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
.detail-block strong {
|
||||
display: block;
|
||||
font-size: 0.76rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.history-table-ai {
|
||||
width: 50%;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.no-messages {
|
||||
.empty-panel {
|
||||
text-align: center;
|
||||
color: #777;
|
||||
}
|
||||
padding: 2rem 1.2rem;
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.nav-pills .nav-link {
|
||||
border-radius: 999px;
|
||||
color: var(--muted);
|
||||
border: 1px solid transparent;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-pills .nav-link.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-pills .nav-link.disabled {
|
||||
border-color: var(--border);
|
||||
color: var(--muted);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.toast-shell {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 1080;
|
||||
}
|
||||
|
||||
.toast {
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.footer-copy {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.help-list {
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.help-list li + li {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.hero-grid,
|
||||
.mailbox-shell,
|
||||
.message-header-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
padding: 0.85rem 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.hero-card,
|
||||
.section-card,
|
||||
.mail-preview,
|
||||
.detail-block {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.table-shell {
|
||||
border: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.table thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table,
|
||||
.table tbody,
|
||||
.table tr,
|
||||
.table td {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table tbody tr {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.table td {
|
||||
padding: 0.85rem 1rem 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.table td:last-child {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,39 +1,54 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const chatForm = document.getElementById('chat-form');
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
const chatMessages = document.getElementById('chat-messages');
|
||||
|
||||
const appendMessage = (text, sender) => {
|
||||
const msgDiv = document.createElement('div');
|
||||
msgDiv.classList.add('message', sender);
|
||||
msgDiv.textContent = text;
|
||||
chatMessages.appendChild(msgDiv);
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
};
|
||||
|
||||
chatForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const message = chatInput.value.trim();
|
||||
if (!message) return;
|
||||
|
||||
appendMessage(message, 'visitor');
|
||||
chatInput.value = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('api/chat.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message })
|
||||
if (window.bootstrap) {
|
||||
document.querySelectorAll('.toast').forEach((toastEl) => {
|
||||
const toast = new bootstrap.Toast(toastEl, {
|
||||
delay: 4200,
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
// Artificial delay for realism
|
||||
setTimeout(() => {
|
||||
appendMessage(data.reply, 'bot');
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
appendMessage("Sorry, something went wrong. Please try again.", 'bot');
|
||||
toast.show();
|
||||
});
|
||||
}
|
||||
|
||||
const searchInput = document.querySelector('[data-mail-search]');
|
||||
const messageItems = Array.from(document.querySelectorAll('[data-mail-item]'));
|
||||
const searchEmpty = document.querySelector('[data-mail-empty]');
|
||||
|
||||
if (searchInput && messageItems.length) {
|
||||
const applyFilter = () => {
|
||||
const term = searchInput.value.trim().toLowerCase();
|
||||
let visibleCount = 0;
|
||||
|
||||
messageItems.forEach((item) => {
|
||||
const haystack = (item.getAttribute('data-search') || '').toLowerCase();
|
||||
const matches = term === '' || haystack.includes(term);
|
||||
item.classList.toggle('d-none', !matches);
|
||||
if (matches) {
|
||||
visibleCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (searchEmpty) {
|
||||
searchEmpty.classList.toggle('d-none', visibleCount > 0);
|
||||
}
|
||||
};
|
||||
|
||||
searchInput.addEventListener('input', applyFilter);
|
||||
applyFilter();
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-security-select]').forEach((select) => {
|
||||
const portInput = document.querySelector(select.getAttribute('data-port-target'));
|
||||
if (!portInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
select.addEventListener('change', () => {
|
||||
const currentValue = portInput.value.trim();
|
||||
if (select.value === 'ssl' && (currentValue === '' || currentValue === '110')) {
|
||||
portInput.value = '995';
|
||||
}
|
||||
if (select.value === 'plain' && (currentValue === '' || currentValue === '995')) {
|
||||
portInput.value = '110';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
BIN
assets/pasted-20260524-070702-ebe35df4.png
Normal file
BIN
assets/pasted-20260524-070702-ebe35df4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
117
db/config.php
117
db/config.php
@ -1,21 +1,108 @@
|
||||
<?php
|
||||
// Generated by setup_mariadb_project.sh — database password must come from process env.
|
||||
declare(strict_types=1);
|
||||
|
||||
// Generated by setup_mariadb_project.sh — keep env keys intact and fall back to a local XAMPP profile.
|
||||
define('DB_HOST', getenv('DB_HOST') ?: '127.0.0.1');
|
||||
define('DB_NAME', getenv('DB_NAME') ?: 'app_default');
|
||||
define('DB_USER', getenv('DB_USER') ?: 'app_default');
|
||||
define('DB_PASS', getenv('DB_PASS') ?: '');
|
||||
define('DB_NAME', getenv('DB_NAME') ?: 'local_pop3_webmail');
|
||||
define('DB_USER', getenv('DB_USER') ?: 'root');
|
||||
|
||||
if (DB_PASS === '') {
|
||||
throw new RuntimeException('DB_PASS environment variable is not set.');
|
||||
$dbPass = getenv('DB_PASS');
|
||||
define('DB_PASS', $dbPass !== false ? (string) $dbPass : '');
|
||||
|
||||
$dbPort = getenv('DB_PORT');
|
||||
define('DB_PORT', max(1, (int) (($dbPort !== false && $dbPort !== '') ? $dbPort : 3306)));
|
||||
|
||||
function db_settings(): array
|
||||
{
|
||||
return [
|
||||
'host' => DB_HOST,
|
||||
'port' => DB_PORT,
|
||||
'name' => DB_NAME,
|
||||
'user' => DB_USER,
|
||||
'has_password' => DB_PASS !== '',
|
||||
];
|
||||
}
|
||||
|
||||
function db() {
|
||||
static $pdo;
|
||||
if (!$pdo) {
|
||||
$pdo = new PDO('mysql:host='.DB_HOST.';dbname='.DB_NAME.';charset=utf8mb4', DB_USER, DB_PASS, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]);
|
||||
}
|
||||
return $pdo;
|
||||
function db_dsn(?string $database = null): string
|
||||
{
|
||||
$dsn = 'mysql:host=' . DB_HOST . ';port=' . DB_PORT . ';charset=utf8mb4';
|
||||
|
||||
if ($database !== null && $database !== '') {
|
||||
$dsn .= ';dbname=' . $database;
|
||||
}
|
||||
|
||||
return $dsn;
|
||||
}
|
||||
|
||||
function db_options(): array
|
||||
{
|
||||
return [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
];
|
||||
}
|
||||
|
||||
function db(): PDO
|
||||
{
|
||||
static $pdo;
|
||||
|
||||
if (!$pdo) {
|
||||
try {
|
||||
$pdo = new PDO(db_dsn(DB_NAME), DB_USER, DB_PASS, db_options());
|
||||
} catch (Throwable $exception) {
|
||||
throw new RuntimeException(db_human_error($exception), 0, $exception);
|
||||
}
|
||||
}
|
||||
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
function db_server(): PDO
|
||||
{
|
||||
try {
|
||||
return new PDO(db_dsn(null), DB_USER, DB_PASS, db_options());
|
||||
} catch (Throwable $exception) {
|
||||
throw new RuntimeException(db_human_error($exception), 0, $exception);
|
||||
}
|
||||
}
|
||||
|
||||
function db_initialize(string $migrationFile): void
|
||||
{
|
||||
$server = db_server();
|
||||
$databaseName = str_replace('`', '``', DB_NAME);
|
||||
$server->exec("CREATE DATABASE IF NOT EXISTS `{$databaseName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
|
||||
|
||||
$pdo = new PDO(db_dsn(DB_NAME), DB_USER, DB_PASS, db_options());
|
||||
$sql = file_get_contents($migrationFile);
|
||||
|
||||
if ($sql === false) {
|
||||
throw new RuntimeException('Ne mogu pročitati SQL migraciju za mailbox tablicu.');
|
||||
}
|
||||
|
||||
$pdo->exec($sql);
|
||||
}
|
||||
|
||||
function db_human_error(Throwable $exception): string
|
||||
{
|
||||
$message = trim($exception->getMessage());
|
||||
$messageLower = strtolower($message);
|
||||
|
||||
if (str_contains($messageLower, 'unknown database')) {
|
||||
return 'MySQL radi, ali baza `' . DB_NAME . '` još ne postoji. Pokreni `xampp-setup.php` ili je kreiraj u phpMyAdminu. Detalj: ' . $message;
|
||||
}
|
||||
|
||||
if (str_contains($messageLower, 'access denied')) {
|
||||
return 'MySQL je dostupan, ali korisničko ime ili lozinka nisu prihvaćeni. Provjeri `db/config.php`. Detalj: ' . $message;
|
||||
}
|
||||
|
||||
if (
|
||||
str_contains($messageLower, 'connection refused')
|
||||
|| str_contains($messageLower, 'no such file or directory')
|
||||
|| str_contains($messageLower, "can't connect")
|
||||
|| str_contains($messageLower, 'sqlstate[hy000] [2002]')
|
||||
) {
|
||||
return 'Ne mogu se spojiti na MySQL na ' . DB_HOST . ':' . DB_PORT . '. Pokreni MySQL u XAMPP-u i pokušaj ponovno. Detalj: ' . $message;
|
||||
}
|
||||
|
||||
return $message !== '' ? $message : 'Nepoznata greška pri spajanju na bazu.';
|
||||
}
|
||||
|
||||
19
db/migrations/20260524_create_mail_accounts.sql
Normal file
19
db/migrations/20260524_create_mail_accounts.sql
Normal file
@ -0,0 +1,19 @@
|
||||
CREATE TABLE IF NOT EXISTS mail_accounts (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
email_address VARCHAR(190) DEFAULT NULL,
|
||||
pop3_host VARCHAR(190) NOT NULL,
|
||||
pop3_port SMALLINT UNSIGNED NOT NULL DEFAULT 110,
|
||||
security_mode VARCHAR(20) NOT NULL DEFAULT 'plain',
|
||||
username VARCHAR(190) NOT NULL,
|
||||
password_ciphertext TEXT NOT NULL,
|
||||
sync_limit SMALLINT UNSIGNED NOT NULL DEFAULT 15,
|
||||
leave_on_server TINYINT(1) NOT NULL DEFAULT 1,
|
||||
last_status VARCHAR(255) DEFAULT NULL,
|
||||
last_message_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
last_sync_at DATETIME DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_mail_accounts_created_at (created_at),
|
||||
INDEX idx_mail_accounts_last_sync_at (last_sync_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
30
healthz.php
Normal file
30
healthz.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/includes/app.php';
|
||||
|
||||
app_boot();
|
||||
|
||||
$status = [
|
||||
'status' => 'ok',
|
||||
'db' => false,
|
||||
'timestamp' => gmdate(DATE_ATOM),
|
||||
];
|
||||
|
||||
try {
|
||||
$pdo = app_db();
|
||||
|
||||
if (!$pdo) {
|
||||
throw new RuntimeException(app_db_error() ?: 'Database connection is not available.');
|
||||
}
|
||||
|
||||
$statement = $pdo->prepare('SELECT 1');
|
||||
$statement->execute();
|
||||
$status['db'] = true;
|
||||
} catch (Throwable $exception) {
|
||||
$status['status'] = 'degraded';
|
||||
}
|
||||
|
||||
http_response_code($status['status'] === 'ok' ? 200 : 503);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($status, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
378
includes/app.php
Normal file
378
includes/app.php
Normal file
@ -0,0 +1,378 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../db/config.php';
|
||||
|
||||
function h(?string $value): string
|
||||
{
|
||||
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
function project_name(): string
|
||||
{
|
||||
$name = $_SERVER['PROJECT_NAME'] ?? getenv('PROJECT_NAME') ?: 'Local POP3 Webmail';
|
||||
|
||||
return trim((string) $name);
|
||||
}
|
||||
|
||||
function project_description_default(string $fallback): string
|
||||
{
|
||||
$description = $_SERVER['PROJECT_DESCRIPTION'] ?? getenv('PROJECT_DESCRIPTION') ?: $fallback;
|
||||
|
||||
return trim((string) $description);
|
||||
}
|
||||
|
||||
function project_image_url(): string
|
||||
{
|
||||
$image = $_SERVER['PROJECT_IMAGE_URL'] ?? getenv('PROJECT_IMAGE_URL') ?: '';
|
||||
|
||||
return trim((string) $image);
|
||||
}
|
||||
|
||||
function asset_version(string $relativePath): string
|
||||
{
|
||||
$fullPath = dirname(__DIR__) . '/' . ltrim($relativePath, '/');
|
||||
|
||||
return (string) (file_exists($fullPath) ? filemtime($fullPath) : time());
|
||||
}
|
||||
|
||||
function app_db(): ?PDO
|
||||
{
|
||||
static $pdo = null;
|
||||
static $attempted = false;
|
||||
|
||||
if ($attempted) {
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
$attempted = true;
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
} catch (Throwable $exception) {
|
||||
$pdo = null;
|
||||
$GLOBALS['APP_DB_ERROR'] = $exception->getMessage();
|
||||
}
|
||||
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
function app_db_error(): ?string
|
||||
{
|
||||
return $GLOBALS['APP_DB_ERROR'] ?? null;
|
||||
}
|
||||
|
||||
function ensure_mail_schema(): bool
|
||||
{
|
||||
static $ensured = false;
|
||||
|
||||
if ($ensured) {
|
||||
return app_db() instanceof PDO;
|
||||
}
|
||||
|
||||
$ensured = true;
|
||||
$pdo = app_db();
|
||||
|
||||
if (!$pdo) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$migrationFile = __DIR__ . '/../db/migrations/20260524_create_mail_accounts.sql';
|
||||
|
||||
try {
|
||||
$sql = file_get_contents($migrationFile);
|
||||
|
||||
if ($sql === false) {
|
||||
throw new RuntimeException('Unable to read the mailbox migration file.');
|
||||
}
|
||||
|
||||
$pdo->exec($sql);
|
||||
|
||||
return true;
|
||||
} catch (Throwable $exception) {
|
||||
$GLOBALS['APP_DB_ERROR'] = $exception->getMessage();
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function app_boot(): void
|
||||
{
|
||||
ensure_mail_schema();
|
||||
}
|
||||
|
||||
function db_ready(): bool
|
||||
{
|
||||
return app_db() instanceof PDO;
|
||||
}
|
||||
|
||||
function flash(string $type, string $message): void
|
||||
{
|
||||
$_SESSION['flash'] = [
|
||||
'type' => $type,
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
|
||||
function pull_flash(): ?array
|
||||
{
|
||||
if (empty($_SESSION['flash']) || !is_array($_SESSION['flash'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$flash = $_SESSION['flash'];
|
||||
unset($_SESSION['flash']);
|
||||
|
||||
return $flash;
|
||||
}
|
||||
|
||||
function mail_cipher_key(): string
|
||||
{
|
||||
return hash('sha256', DB_HOST . '|' . DB_NAME . '|' . DB_USER . '|' . DB_PASS, true);
|
||||
}
|
||||
|
||||
function encrypt_secret(string $plaintext): string
|
||||
{
|
||||
$cipher = 'aes-256-cbc';
|
||||
$ivLength = openssl_cipher_iv_length($cipher);
|
||||
$iv = random_bytes($ivLength);
|
||||
$encrypted = openssl_encrypt($plaintext, $cipher, mail_cipher_key(), OPENSSL_RAW_DATA, $iv);
|
||||
|
||||
if ($encrypted === false) {
|
||||
throw new RuntimeException('Unable to securely store the POP3 password.');
|
||||
}
|
||||
|
||||
return base64_encode($iv . $encrypted);
|
||||
}
|
||||
|
||||
function decrypt_secret(string $ciphertext): string
|
||||
{
|
||||
$decoded = base64_decode($ciphertext, true);
|
||||
|
||||
if ($decoded === false) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$cipher = 'aes-256-cbc';
|
||||
$ivLength = openssl_cipher_iv_length($cipher);
|
||||
$iv = substr($decoded, 0, $ivLength);
|
||||
$payload = substr($decoded, $ivLength);
|
||||
$decrypted = openssl_decrypt($payload, $cipher, mail_cipher_key(), OPENSSL_RAW_DATA, $iv);
|
||||
|
||||
return $decrypted === false ? '' : $decrypted;
|
||||
}
|
||||
|
||||
function default_mail_account_input(): array
|
||||
{
|
||||
return [
|
||||
'label' => '',
|
||||
'email_address' => '',
|
||||
'pop3_host' => '127.0.0.1',
|
||||
'pop3_port' => 110,
|
||||
'security_mode' => 'plain',
|
||||
'username' => '',
|
||||
'password' => '',
|
||||
'sync_limit' => 15,
|
||||
'leave_on_server' => 1,
|
||||
];
|
||||
}
|
||||
|
||||
function validate_mail_account_input(array $input): array
|
||||
{
|
||||
$clean = [
|
||||
'label' => trim((string) ($input['label'] ?? '')),
|
||||
'email_address' => trim((string) ($input['email_address'] ?? '')),
|
||||
'pop3_host' => trim((string) ($input['pop3_host'] ?? '')),
|
||||
'pop3_port' => (int) ($input['pop3_port'] ?? 110),
|
||||
'security_mode' => in_array(($input['security_mode'] ?? 'plain'), ['plain', 'ssl'], true) ? (string) $input['security_mode'] : 'plain',
|
||||
'username' => trim((string) ($input['username'] ?? '')),
|
||||
'password' => trim((string) ($input['password'] ?? '')),
|
||||
'sync_limit' => (int) ($input['sync_limit'] ?? 15),
|
||||
'leave_on_server' => isset($input['leave_on_server']) ? 1 : 0,
|
||||
];
|
||||
|
||||
$errors = [];
|
||||
|
||||
if ($clean['label'] === '' || strlen($clean['label']) < 2) {
|
||||
$errors['label'] = 'Unesite naziv mailboxa (najmanje 2 znaka).';
|
||||
}
|
||||
|
||||
if ($clean['email_address'] !== '' && !filter_var($clean['email_address'], FILTER_VALIDATE_EMAIL)) {
|
||||
$errors['email_address'] = 'Email adresa nije ispravna.';
|
||||
}
|
||||
|
||||
if ($clean['pop3_host'] === '' || strlen($clean['pop3_host']) < 2) {
|
||||
$errors['pop3_host'] = 'POP3 host je obavezan.';
|
||||
}
|
||||
|
||||
if ($clean['pop3_port'] < 1 || $clean['pop3_port'] > 65535) {
|
||||
$errors['pop3_port'] = 'POP3 port mora biti između 1 i 65535.';
|
||||
}
|
||||
|
||||
if ($clean['username'] === '') {
|
||||
$errors['username'] = 'Korisničko ime je obavezno.';
|
||||
}
|
||||
|
||||
if ($clean['password'] === '') {
|
||||
$errors['password'] = 'Lozinka je obavezna.';
|
||||
}
|
||||
|
||||
if ($clean['sync_limit'] < 5 || $clean['sync_limit'] > 50) {
|
||||
$errors['sync_limit'] = 'Prikaži između 5 i 50 poruka po sinkronizaciji.';
|
||||
}
|
||||
|
||||
return [$clean, $errors];
|
||||
}
|
||||
|
||||
function save_mail_account(array $data): int
|
||||
{
|
||||
$pdo = app_db();
|
||||
|
||||
if (!$pdo) {
|
||||
throw new RuntimeException('Baza trenutno nije dostupna.');
|
||||
}
|
||||
|
||||
$statement = $pdo->prepare(
|
||||
'INSERT INTO mail_accounts (label, email_address, pop3_host, pop3_port, security_mode, username, password_ciphertext, sync_limit, leave_on_server, last_status)
|
||||
VALUES (:label, :email_address, :pop3_host, :pop3_port, :security_mode, :username, :password_ciphertext, :sync_limit, :leave_on_server, :last_status)'
|
||||
);
|
||||
|
||||
$statement->bindValue(':label', $data['label']);
|
||||
$statement->bindValue(':email_address', $data['email_address'] !== '' ? $data['email_address'] : null, PDO::PARAM_STR);
|
||||
$statement->bindValue(':pop3_host', $data['pop3_host']);
|
||||
$statement->bindValue(':pop3_port', (int) $data['pop3_port'], PDO::PARAM_INT);
|
||||
$statement->bindValue(':security_mode', $data['security_mode']);
|
||||
$statement->bindValue(':username', $data['username']);
|
||||
$statement->bindValue(':password_ciphertext', encrypt_secret($data['password']));
|
||||
$statement->bindValue(':sync_limit', (int) $data['sync_limit'], PDO::PARAM_INT);
|
||||
$statement->bindValue(':leave_on_server', (int) $data['leave_on_server'], PDO::PARAM_INT);
|
||||
$statement->bindValue(':last_status', 'Ready to connect');
|
||||
$statement->execute();
|
||||
|
||||
return (int) $pdo->lastInsertId();
|
||||
}
|
||||
|
||||
function get_mail_accounts(): array
|
||||
{
|
||||
$pdo = app_db();
|
||||
|
||||
if (!$pdo) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$statement = $pdo->prepare(
|
||||
'SELECT id, label, email_address, pop3_host, pop3_port, security_mode, username, sync_limit, leave_on_server, last_status, last_message_count, last_sync_at, created_at, updated_at
|
||||
FROM mail_accounts
|
||||
ORDER BY created_at DESC, id DESC'
|
||||
);
|
||||
$statement->execute();
|
||||
|
||||
return $statement->fetchAll() ?: [];
|
||||
}
|
||||
|
||||
function find_mail_account(int $id): ?array
|
||||
{
|
||||
$pdo = app_db();
|
||||
|
||||
if (!$pdo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$statement = $pdo->prepare(
|
||||
'SELECT id, label, email_address, pop3_host, pop3_port, security_mode, username, password_ciphertext, sync_limit, leave_on_server, last_status, last_message_count, last_sync_at, created_at, updated_at
|
||||
FROM mail_accounts
|
||||
WHERE id = :id
|
||||
LIMIT 1'
|
||||
);
|
||||
$statement->bindValue(':id', $id, PDO::PARAM_INT);
|
||||
$statement->execute();
|
||||
|
||||
$account = $statement->fetch();
|
||||
|
||||
return $account ?: null;
|
||||
}
|
||||
|
||||
function update_mail_account_sync(int $id, string $status, int $messageCount): void
|
||||
{
|
||||
$pdo = app_db();
|
||||
|
||||
if (!$pdo) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statement = $pdo->prepare(
|
||||
'UPDATE mail_accounts
|
||||
SET last_status = :last_status,
|
||||
last_message_count = :last_message_count,
|
||||
last_sync_at = NOW()
|
||||
WHERE id = :id'
|
||||
);
|
||||
$statement->bindValue(':last_status', substr($status, 0, 255));
|
||||
$statement->bindValue(':last_message_count', max(0, $messageCount), PDO::PARAM_INT);
|
||||
$statement->bindValue(':id', $id, PDO::PARAM_INT);
|
||||
$statement->execute();
|
||||
}
|
||||
|
||||
function format_datetime(?string $value, string $fallback = 'Not yet'): string
|
||||
{
|
||||
if (!$value) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
return (new DateTimeImmutable($value))->format('M j, Y · H:i');
|
||||
} catch (Throwable $exception) {
|
||||
return $fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function status_tone(?string $status): string
|
||||
{
|
||||
$value = strtolower(trim((string) $status));
|
||||
|
||||
if ($value === '') {
|
||||
return 'status-idle';
|
||||
}
|
||||
|
||||
if (str_contains($value, 'fail') || str_contains($value, 'error')) {
|
||||
return 'status-danger';
|
||||
}
|
||||
|
||||
if (str_contains($value, 'empty')) {
|
||||
return 'status-warning';
|
||||
}
|
||||
|
||||
if (str_contains($value, 'connected') || str_contains($value, 'ready')) {
|
||||
return 'status-success';
|
||||
}
|
||||
|
||||
return 'status-idle';
|
||||
}
|
||||
|
||||
function security_label(string $mode): string
|
||||
{
|
||||
return $mode === 'ssl' ? 'SSL/TLS' : 'Plain';
|
||||
}
|
||||
|
||||
function truncate_text(string $text, int $length = 160): string
|
||||
{
|
||||
$text = trim(preg_replace('/\s+/', ' ', $text) ?? $text);
|
||||
|
||||
if ($text === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (function_exists('iconv_strlen') && function_exists('iconv_substr')) {
|
||||
$currentLength = iconv_strlen($text, 'UTF-8');
|
||||
|
||||
if ($currentLength !== false && $currentLength > $length) {
|
||||
return rtrim((string) iconv_substr($text, 0, $length, 'UTF-8')) . '…';
|
||||
}
|
||||
}
|
||||
|
||||
return strlen($text) > $length ? rtrim(substr($text, 0, $length)) . '…' : $text;
|
||||
}
|
||||
342
includes/pop3_client.php
Normal file
342
includes/pop3_client.php
Normal file
@ -0,0 +1,342 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class Pop3Client
|
||||
{
|
||||
private string $host;
|
||||
private int $port;
|
||||
private string $security;
|
||||
private int $timeout;
|
||||
|
||||
/** @var resource|null */
|
||||
private $stream = null;
|
||||
|
||||
public function __construct(string $host, int $port = 110, string $security = 'plain', int $timeout = 12)
|
||||
{
|
||||
$this->host = $host;
|
||||
$this->port = $port;
|
||||
$this->security = $security;
|
||||
$this->timeout = $timeout;
|
||||
}
|
||||
|
||||
public function connect(): void
|
||||
{
|
||||
$target = ($this->security === 'ssl' ? 'ssl://' : '') . $this->host;
|
||||
$errno = 0;
|
||||
$errstr = '';
|
||||
$stream = @fsockopen($target, $this->port, $errno, $errstr, $this->timeout);
|
||||
|
||||
if (!is_resource($stream)) {
|
||||
throw new RuntimeException(sprintf('Ne mogu otvoriti POP3 vezu prema %s:%d.', $this->host, $this->port));
|
||||
}
|
||||
|
||||
stream_set_timeout($stream, $this->timeout);
|
||||
$this->stream = $stream;
|
||||
|
||||
$greeting = $this->readLine();
|
||||
|
||||
if (stripos($greeting, '+OK') !== 0) {
|
||||
throw new RuntimeException('POP3 server je odbio početni pozdrav.');
|
||||
}
|
||||
}
|
||||
|
||||
public function login(string $username, string $password): void
|
||||
{
|
||||
$this->simpleCommand('USER ' . $username, 'Korisničko ime nije prihvaćeno na POP3 serveru.');
|
||||
$this->simpleCommand('PASS ' . $password, 'Lozinka nije prihvaćena na POP3 serveru.');
|
||||
}
|
||||
|
||||
public function stat(): array
|
||||
{
|
||||
$response = $this->simpleCommand('STAT', 'Ne mogu očitati stanje inboxa.');
|
||||
|
||||
if (preg_match('/^\+OK\s+(\d+)\s+(\d+)/', $response, $matches)) {
|
||||
return [
|
||||
'count' => (int) $matches[1],
|
||||
'size' => (int) $matches[2],
|
||||
];
|
||||
}
|
||||
|
||||
return ['count' => 0, 'size' => 0];
|
||||
}
|
||||
|
||||
public function fetchRecent(int $limit = 15): array
|
||||
{
|
||||
$stats = $this->stat();
|
||||
$count = $stats['count'];
|
||||
|
||||
if ($count <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$messages = [];
|
||||
$start = max(1, $count - $limit + 1);
|
||||
|
||||
for ($number = $count; $number >= $start; $number--) {
|
||||
try {
|
||||
$raw = $this->multilineCommand('TOP ' . $number . ' 18', 'Ne mogu dohvatiti pregled poruke.');
|
||||
} catch (Throwable $exception) {
|
||||
$raw = $this->multilineCommand('RETR ' . $number, 'Ne mogu preuzeti poruku s POP3 servera.');
|
||||
}
|
||||
|
||||
$messages[] = $this->parseMessage($number, $raw, false);
|
||||
}
|
||||
|
||||
return $messages;
|
||||
}
|
||||
|
||||
public function fetchMessage(int $number): array
|
||||
{
|
||||
$raw = $this->multilineCommand('RETR ' . $number, 'Ne mogu otvoriti traženu poruku.');
|
||||
|
||||
return $this->parseMessage($number, $raw, true);
|
||||
}
|
||||
|
||||
public function quit(): void
|
||||
{
|
||||
if (is_resource($this->stream)) {
|
||||
try {
|
||||
$this->simpleCommand('QUIT');
|
||||
} catch (Throwable $exception) {
|
||||
// ignore close failures
|
||||
}
|
||||
|
||||
fclose($this->stream);
|
||||
$this->stream = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function __destruct()
|
||||
{
|
||||
$this->quit();
|
||||
}
|
||||
|
||||
private function parseMessage(int $number, string $rawMessage, bool $includeBody): array
|
||||
{
|
||||
[$headerText, $bodyText] = self::splitMessage($rawMessage);
|
||||
$headers = self::parseHeaders($headerText);
|
||||
$decodedBody = self::extractBodyText($headers, $bodyText);
|
||||
$normalizedBody = trim(preg_replace("/
|
||||
?|
/", "
|
||||
", $decodedBody) ?? $decodedBody);
|
||||
$preview = truncate_text($normalizedBody !== '' ? $normalizedBody : 'Nema pregleda za ovu poruku.', 180);
|
||||
|
||||
return [
|
||||
'number' => $number,
|
||||
'subject' => self::headerValue($headers, 'subject', '(Bez naslova)'),
|
||||
'from' => self::headerValue($headers, 'from', 'Nepoznati pošiljatelj'),
|
||||
'date' => self::headerValue($headers, 'date', ''),
|
||||
'message_id' => self::headerValue($headers, 'message-id', 'POP3-' . $number),
|
||||
'preview' => $preview !== '' ? $preview : 'Nema pregleda za ovu poruku.',
|
||||
'body_text' => $includeBody ? ($normalizedBody !== '' ? $normalizedBody : 'Poruka nema tekstualni sadržaj za prikaz.') : '',
|
||||
];
|
||||
}
|
||||
|
||||
private static function splitMessage(string $rawMessage): array
|
||||
{
|
||||
$parts = preg_split("/
?
|
||||
?
|
||||
/", $rawMessage, 2);
|
||||
|
||||
return [
|
||||
$parts[0] ?? '',
|
||||
$parts[1] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
public static function parseHeaders(string $headerText): array
|
||||
{
|
||||
$headers = [];
|
||||
$current = null;
|
||||
$lines = preg_split("/
?
|
||||
/", $headerText) ?: [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^[ ]+/', $line) === 1 && $current !== null) {
|
||||
$headers[$current] .= ' ' . trim($line);
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = explode(':', $line, 2);
|
||||
|
||||
if (count($parts) !== 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$current = strtolower(trim($parts[0]));
|
||||
$headers[$current] = trim($parts[1]);
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
private static function extractBodyText(array $headers, string $body): string
|
||||
{
|
||||
$contentType = strtolower((string) ($headers['content-type'] ?? 'text/plain; charset=UTF-8'));
|
||||
$encoding = strtolower((string) ($headers['content-transfer-encoding'] ?? ''));
|
||||
$charset = 'UTF-8';
|
||||
|
||||
if (preg_match('/charset="?([^";]+)"?/i', $contentType, $charsetMatch) === 1) {
|
||||
$charset = trim($charsetMatch[1]);
|
||||
}
|
||||
|
||||
if (str_starts_with($contentType, 'multipart/') && preg_match('/boundary="?([^";]+)"?/i', $contentType, $boundaryMatch) === 1) {
|
||||
$boundary = $boundaryMatch[1];
|
||||
$delimiter = '--' . $boundary;
|
||||
$parts = explode($delimiter, $body);
|
||||
$plain = '';
|
||||
$html = '';
|
||||
|
||||
foreach ($parts as $part) {
|
||||
$part = ltrim($part, "
|
||||
");
|
||||
$part = preg_replace('/--\s*$/', '', $part) ?? $part;
|
||||
|
||||
if (trim($part) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$partHeadersText, $partBody] = self::splitMessage($part);
|
||||
$partHeaders = self::parseHeaders($partHeadersText);
|
||||
$partText = trim(self::extractBodyText($partHeaders, $partBody));
|
||||
$partType = strtolower((string) ($partHeaders['content-type'] ?? 'text/plain'));
|
||||
|
||||
if ($partText === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_contains($partType, 'text/plain')) {
|
||||
return $partText;
|
||||
}
|
||||
|
||||
if ($plain === '') {
|
||||
$plain = $partText;
|
||||
}
|
||||
|
||||
if ($html === '' && str_contains($partType, 'text/html')) {
|
||||
$html = $partText;
|
||||
}
|
||||
}
|
||||
|
||||
return $plain !== '' ? $plain : $html;
|
||||
}
|
||||
|
||||
$decoded = self::decodeBody($body, $encoding);
|
||||
|
||||
if ($charset !== '' && strtoupper($charset) !== 'UTF-8' && function_exists('iconv')) {
|
||||
$converted = @iconv($charset, 'UTF-8//IGNORE', $decoded);
|
||||
|
||||
if ($converted !== false) {
|
||||
$decoded = $converted;
|
||||
}
|
||||
}
|
||||
|
||||
if (str_contains($contentType, 'text/html')) {
|
||||
$decoded = html_entity_decode(strip_tags($decoded), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
}
|
||||
|
||||
return trim($decoded);
|
||||
}
|
||||
|
||||
private static function decodeBody(string $body, string $encoding): string
|
||||
{
|
||||
return match ($encoding) {
|
||||
'base64' => base64_decode($body, true) ?: $body,
|
||||
'quoted-printable' => quoted_printable_decode($body),
|
||||
default => $body,
|
||||
};
|
||||
}
|
||||
|
||||
private static function headerValue(array $headers, string $key, string $fallback): string
|
||||
{
|
||||
$value = trim((string) ($headers[$key] ?? ''));
|
||||
|
||||
if ($value === '') {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
if (function_exists('iconv_mime_decode')) {
|
||||
$decoded = @iconv_mime_decode($value, ICONV_MIME_DECODE_CONTINUE_ON_ERROR, 'UTF-8');
|
||||
|
||||
if (is_string($decoded) && $decoded !== '') {
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function simpleCommand(string $command, ?string $fallbackMessage = null): string
|
||||
{
|
||||
$this->write($command . "
|
||||
");
|
||||
$response = $this->readLine();
|
||||
|
||||
if (stripos($response, '+OK') !== 0) {
|
||||
$serverMessage = preg_replace('/^-ERR\s*/i', '', trim($response)) ?: trim($response);
|
||||
throw new RuntimeException($fallbackMessage ?: ($serverMessage !== '' ? $serverMessage : 'POP3 naredba nije uspjela.'));
|
||||
}
|
||||
|
||||
return trim($response);
|
||||
}
|
||||
|
||||
private function multilineCommand(string $command, ?string $fallbackMessage = null): string
|
||||
{
|
||||
$this->write($command . "
|
||||
");
|
||||
$response = $this->readLine();
|
||||
|
||||
if (stripos($response, '+OK') !== 0) {
|
||||
$serverMessage = preg_replace('/^-ERR\s*/i', '', trim($response)) ?: trim($response);
|
||||
throw new RuntimeException($fallbackMessage ?: ($serverMessage !== '' ? $serverMessage : 'POP3 naredba nije uspjela.'));
|
||||
}
|
||||
|
||||
$lines = [];
|
||||
|
||||
while (($line = $this->readLine()) !== '.') {
|
||||
if (str_starts_with($line, '..')) {
|
||||
$line = substr($line, 1);
|
||||
}
|
||||
|
||||
$lines[] = $line;
|
||||
}
|
||||
|
||||
return implode("
|
||||
", $lines);
|
||||
}
|
||||
|
||||
private function write(string $payload): void
|
||||
{
|
||||
if (!is_resource($this->stream)) {
|
||||
throw new RuntimeException('POP3 veza nije aktivna.');
|
||||
}
|
||||
|
||||
fwrite($this->stream, $payload);
|
||||
}
|
||||
|
||||
private function readLine(): string
|
||||
{
|
||||
if (!is_resource($this->stream)) {
|
||||
throw new RuntimeException('POP3 veza nije aktivna.');
|
||||
}
|
||||
|
||||
$line = fgets($this->stream, 8192);
|
||||
|
||||
if ($line === false) {
|
||||
$meta = stream_get_meta_data($this->stream);
|
||||
|
||||
if (!empty($meta['timed_out'])) {
|
||||
throw new RuntimeException('POP3 server nije odgovorio na vrijeme.');
|
||||
}
|
||||
|
||||
throw new RuntimeException('POP3 server je zatvorio vezu.');
|
||||
}
|
||||
|
||||
return rtrim($line, "
|
||||
");
|
||||
}
|
||||
}
|
||||
453
index.php
453
index.php
@ -1,150 +1,333 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
@ini_set('display_errors', '1');
|
||||
@error_reporting(E_ALL);
|
||||
@date_default_timezone_set('UTC');
|
||||
|
||||
$phpVersion = PHP_VERSION;
|
||||
$now = date('Y-m-d H:i:s');
|
||||
require_once __DIR__ . '/includes/app.php';
|
||||
|
||||
app_boot();
|
||||
|
||||
$defaults = default_mail_account_input();
|
||||
$formData = $defaults;
|
||||
$errors = [];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save_account') {
|
||||
[$formData, $errors] = validate_mail_account_input($_POST);
|
||||
|
||||
if (empty($errors)) {
|
||||
try {
|
||||
$accountId = save_mail_account($formData);
|
||||
flash('success', 'Mailbox je spremljen. Otvaram live inbox pregled.');
|
||||
header('Location: mailbox.php?id=' . $accountId);
|
||||
exit;
|
||||
} catch (Throwable $exception) {
|
||||
$errors['form'] = 'Spremanje nije uspjelo. Provjeri bazu i pokušaj ponovno.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$accounts = get_mail_accounts();
|
||||
$flash = pull_flash();
|
||||
$dbReady = db_ready();
|
||||
$dbError = app_db_error();
|
||||
$latestSync = 'Not yet';
|
||||
$syncedAccounts = 0;
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
if (!empty($account['last_sync_at'])) {
|
||||
$syncedAccounts++;
|
||||
if ($latestSync === 'Not yet' || strtotime((string) $account['last_sync_at']) > strtotime((string) $latestSync)) {
|
||||
$latestSync = (string) $account['last_sync_at'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$pageTitle = project_name() . ' — POP3 mailbox dashboard';
|
||||
$projectBaseDescription = trim((string) ($_SERVER['PROJECT_DESCRIPTION'] ?? getenv('PROJECT_DESCRIPTION') ?: ''));
|
||||
$pageDescription = $projectBaseDescription !== ''
|
||||
? $projectBaseDescription . ' — Dashboard for POP3 mailbox setup, MySQL account storage, and live inbox access.'
|
||||
: 'Configure local POP3 mailboxes, store connection settings in MySQL, and open a clean inbox view from one lightweight PHP interface.';
|
||||
$projectDescription = $projectBaseDescription !== '' ? $projectBaseDescription : $pageDescription;
|
||||
$projectImageUrl = project_image_url();
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="hr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>New Style</title>
|
||||
<?php
|
||||
// Read project preview data from environment
|
||||
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
|
||||
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||
?>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= h($pageTitle) ?></title>
|
||||
<meta name="description" content="<?= h($pageDescription) ?>">
|
||||
<?php if ($projectDescription): ?>
|
||||
<!-- Meta description -->
|
||||
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
|
||||
<!-- Open Graph meta tags -->
|
||||
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<!-- Twitter meta tags -->
|
||||
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<meta property="og:description" content="<?= h($projectDescription) ?>">
|
||||
<meta property="twitter:description" content="<?= h($projectDescription) ?>">
|
||||
<?php endif; ?>
|
||||
<?php if ($projectImageUrl): ?>
|
||||
<!-- Open Graph image -->
|
||||
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<!-- Twitter image -->
|
||||
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<meta property="og:image" content="<?= h($projectImageUrl) ?>">
|
||||
<meta property="twitter:image" content="<?= h($projectImageUrl) ?>">
|
||||
<?php endif; ?>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-color-start: #6a11cb;
|
||||
--bg-color-end: #2575fc;
|
||||
--text-color: #ffffff;
|
||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
body::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
|
||||
animation: bg-pan 20s linear infinite;
|
||||
z-index: -1;
|
||||
}
|
||||
@keyframes bg-pan {
|
||||
0% { background-position: 0% 0%; }
|
||||
100% { background-position: 100% 100%; }
|
||||
}
|
||||
main {
|
||||
padding: 2rem;
|
||||
}
|
||||
.card {
|
||||
background: var(--card-bg-color);
|
||||
border: 1px solid var(--card-border-color);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.loader {
|
||||
margin: 1.25rem auto 1.25rem;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.25);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.hint {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap; border: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
p {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
code {
|
||||
background: rgba(0,0,0,0.2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
footer {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
<meta property="og:title" content="<?= h($pageTitle) ?>">
|
||||
<meta property="twitter:title" content="<?= h($pageTitle) ?>">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="assets/css/custom.css?v=<?= h(asset_version('assets/css/custom.css')) ?>">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="card">
|
||||
<h1>Analyzing your requirements and generating your website…</h1>
|
||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
||||
<span class="sr-only">Loading…</span>
|
||||
</div>
|
||||
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
|
||||
<p class="hint">This page will update automatically as the plan is implemented.</p>
|
||||
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
|
||||
<?php if ($flash): ?>
|
||||
<div class="toast-shell">
|
||||
<div class="toast align-items-center border-0" role="status" aria-live="polite" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body"><?= h($flash['message']) ?></div>
|
||||
<button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
Page updated: <?= htmlspecialchars($now) ?> (UTC)
|
||||
</footer>
|
||||
<?php endif; ?>
|
||||
<div class="container app-shell py-4 py-lg-5">
|
||||
<nav class="navbar app-nav navbar-expand-lg">
|
||||
<div class="container-fluid px-0">
|
||||
<a class="navbar-brand d-flex align-items-center gap-3 m-0" href="index.php">
|
||||
<span class="brand-mark">WM</span>
|
||||
<span class="brand-copy">
|
||||
<strong><?= h(project_name()) ?></strong>
|
||||
<small>Local POP3 workspace</small>
|
||||
</span>
|
||||
</a>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 ms-auto">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="#setup">Dodaj račun</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="#accounts">Računi</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="xampp-setup.php">XAMPP setup</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="healthz.php" target="_blank" rel="noopener">Healthz</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<header class="hero-grid">
|
||||
<section class="hero-card">
|
||||
<div class="page-eyebrow mb-2">Initial MVP slice</div>
|
||||
<h1 class="hero-title">Dodaj POP3 mailbox i odmah otvori inbox iz preglednika.</h1>
|
||||
<p class="section-subtitle">Ova prva verzija pokriva najbitniji tok: spremi POP3 postavke u MySQL, otvori live pregled inboxa i pročitaj poruku bez izlaska iz aplikacije. Dizajn je namjerno čist i lagan za lokalni XAMPP setup.</p>
|
||||
<div class="hero-actions">
|
||||
<a class="btn btn-primary" href="#setup">Konfiguriraj mailbox</a>
|
||||
<a class="btn btn-outline-secondary" href="#accounts">Pogledaj spremljene račune</a>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 mt-3">
|
||||
<span class="inline-note">POP3 read workflow</span>
|
||||
<span class="inline-note">MySQL account storage</span>
|
||||
<span class="inline-note">Inbox detail view</span>
|
||||
</div>
|
||||
</section>
|
||||
<aside class="metrics-grid">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Configured accounts</div>
|
||||
<div class="metric-value"><?= h((string) count($accounts)) ?></div>
|
||||
<div class="metric-hint">Broj mailboxa spremljenih u lokalnoj bazi.</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Successful syncs</div>
|
||||
<div class="metric-value"><?= h((string) $syncedAccounts) ?></div>
|
||||
<div class="metric-hint">Računi koji su već otvoreni kroz inbox ekran.</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Environment</div>
|
||||
<div class="metric-value" style="font-size:1.1rem;">PHP <?= h(PHP_VERSION) ?></div>
|
||||
<div class="metric-hint">Vrijeme: <?= h(gmdate('Y-m-d H:i')) ?> UTC · <a class="text-decoration-underline" href="xampp-setup.php">xampp-setup.php</a> · <a class="text-decoration-underline" href="healthz.php" target="_blank" rel="noopener">/healthz</a></div>
|
||||
</div>
|
||||
</aside>
|
||||
</header>
|
||||
|
||||
<?php if (!$dbReady): ?>
|
||||
<div class="alert alert-danger border-0 mb-4" role="alert">
|
||||
<strong>Baza nije dostupna.</strong> Forma i lista su prikazane, ali spremanje neće raditi dok se MySQL veza ne podigne.
|
||||
<div class="small mt-2">Za lokalni XAMPP pokreni Apache + MySQL, zatim otvori <a class="text-decoration-underline" href="xampp-setup.php">xampp-setup.php</a> da automatski kreiraš bazu <strong><?= h(DB_NAME) ?></strong>.</div>
|
||||
<div class="small mt-2">Trenutna konfiguracija: <?= h(DB_USER) ?>@<?= h(DB_HOST) ?>:<?= h((string) DB_PORT) ?> / <?= h(DB_NAME) ?><?= DB_PASS === '' ? ' · bez lozinke' : ' · lozinka postavljena' ?></div>
|
||||
<?php if ($dbError): ?>
|
||||
<div class="small mt-2"><?= h($dbError) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<main class="row g-4">
|
||||
<section id="setup" class="col-12 col-xl-7">
|
||||
<div class="section-card">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
|
||||
<div>
|
||||
<div class="overline mb-1">Create / input</div>
|
||||
<h2 class="section-title">Dodaj POP3 račun</h2>
|
||||
</div>
|
||||
<span class="soft-badge">Server-side validation · encrypted password at rest</span>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($errors['form'])): ?>
|
||||
<div class="alert alert-danger border-0 mb-4" role="alert"><?= h($errors['form']) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" class="row g-3" novalidate>
|
||||
<input type="hidden" name="action" value="save_account">
|
||||
<div class="col-md-6">
|
||||
<label for="label" class="form-label">Naziv mailboxa</label>
|
||||
<input type="text" class="form-control<?= isset($errors['label']) ? ' is-invalid' : '' ?>" id="label" name="label" value="<?= h((string) $formData['label']) ?>" placeholder="npr. Lokalni support" <?= $dbReady ? '' : 'disabled' ?>>
|
||||
<?php if (isset($errors['label'])): ?><div class="validation-note"><?= h($errors['label']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="email_address" class="form-label">Email adresa <span class="helper-text">(opcionalno)</span></label>
|
||||
<input type="email" class="form-control<?= isset($errors['email_address']) ? ' is-invalid' : '' ?>" id="email_address" name="email_address" value="<?= h((string) $formData['email_address']) ?>" placeholder="mailbox@example.local" <?= $dbReady ? '' : 'disabled' ?>>
|
||||
<?php if (isset($errors['email_address'])): ?><div class="validation-note"><?= h($errors['email_address']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label for="pop3_host" class="form-label">POP3 host</label>
|
||||
<input type="text" class="form-control<?= isset($errors['pop3_host']) ? ' is-invalid' : '' ?>" id="pop3_host" name="pop3_host" value="<?= h((string) $formData['pop3_host']) ?>" placeholder="127.0.0.1" <?= $dbReady ? '' : 'disabled' ?>>
|
||||
<?php if (isset($errors['pop3_host'])): ?><div class="validation-note"><?= h($errors['pop3_host']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="pop3_port" class="form-label">Port</label>
|
||||
<input type="number" class="form-control<?= isset($errors['pop3_port']) ? ' is-invalid' : '' ?>" id="pop3_port" name="pop3_port" value="<?= h((string) $formData['pop3_port']) ?>" min="1" max="65535" <?= $dbReady ? '' : 'disabled' ?>>
|
||||
<?php if (isset($errors['pop3_port'])): ?><div class="validation-note"><?= h($errors['pop3_port']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="security_mode" class="form-label">Sigurnost veze</label>
|
||||
<select class="form-select" id="security_mode" name="security_mode" data-security-select data-port-target="#pop3_port" <?= $dbReady ? '' : 'disabled' ?>>
|
||||
<option value="plain" <?= ($formData['security_mode'] ?? '') === 'plain' ? 'selected' : '' ?>>Plain (110)</option>
|
||||
<option value="ssl" <?= ($formData['security_mode'] ?? '') === 'ssl' ? 'selected' : '' ?>>SSL/TLS (995)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="sync_limit" class="form-label">Poruka za dohvat</label>
|
||||
<input type="number" class="form-control<?= isset($errors['sync_limit']) ? ' is-invalid' : '' ?>" id="sync_limit" name="sync_limit" value="<?= h((string) $formData['sync_limit']) ?>" min="5" max="50" <?= $dbReady ? '' : 'disabled' ?>>
|
||||
<?php if (isset($errors['sync_limit'])): ?><div class="validation-note"><?= h($errors['sync_limit']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="username" class="form-label">Korisničko ime</label>
|
||||
<input type="text" class="form-control<?= isset($errors['username']) ? ' is-invalid' : '' ?>" id="username" name="username" value="<?= h((string) $formData['username']) ?>" placeholder="korisnik ili email" <?= $dbReady ? '' : 'disabled' ?>>
|
||||
<?php if (isset($errors['username'])): ?><div class="validation-note"><?= h($errors['username']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="password" class="form-label">Lozinka</label>
|
||||
<input type="password" class="form-control<?= isset($errors['password']) ? ' is-invalid' : '' ?>" id="password" name="password" value="<?= h((string) $formData['password']) ?>" placeholder="••••••••" <?= $dbReady ? '' : 'disabled' ?>>
|
||||
<?php if (isset($errors['password'])): ?><div class="validation-note"><?= h($errors['password']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" value="1" id="leave_on_server" name="leave_on_server" <?= !empty($formData['leave_on_server']) ? 'checked' : '' ?> <?= $dbReady ? '' : 'disabled' ?>>
|
||||
<label class="form-check-label" for="leave_on_server">Ostavi poruke na serveru (read-only POP3 slice)</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 d-flex flex-wrap align-items-center gap-3 pt-2">
|
||||
<button type="submit" class="btn btn-primary" <?= $dbReady ? '' : 'disabled' ?>>Spremi mailbox</button>
|
||||
<span class="helper-text">Savjet za lokalni test: host <strong>127.0.0.1</strong>, port <strong>110</strong>, bez enkripcije.</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="col-12 col-xl-5">
|
||||
<div class="section-card stack-md h-100">
|
||||
<div>
|
||||
<div class="overline mb-1">Confirmation / guide</div>
|
||||
<h2 class="section-title">Što dobivaš u ovoj isporuci</h2>
|
||||
</div>
|
||||
<div class="quick-steps">
|
||||
<article class="quick-step">
|
||||
<h3>1. Spremanje računa</h3>
|
||||
<p>POP3 postavke se validiraju na serveru i spremaju u MySQL, a lozinka se šifrira prije upisa u bazu.</p>
|
||||
</article>
|
||||
<article class="quick-step">
|
||||
<h3>2. Live inbox ekran</h3>
|
||||
<p>Nakon spremanja otvara se mailbox detalj koji čita najnovije poruke direktno preko POP3 veze.</p>
|
||||
</article>
|
||||
<article class="quick-step">
|
||||
<h3>3. Pregled statusa</h3>
|
||||
<p>Na dashboardu ostaju vidljivi status zadnje sinkronizacije, broj poruka i brzi linkovi prema svakom inboxu.</p>
|
||||
</article>
|
||||
</div>
|
||||
<div class="surface-muted p-3 rounded-4 border">
|
||||
<div class="overline mb-2">Next after MVP</div>
|
||||
<ul class="help-list">
|
||||
<li>SMTP compose + Sent folder tok.</li>
|
||||
<li>Lokalni cache headera/poruka za bržu pretragu.</li>
|
||||
<li>Uređivanje i deaktivacija mailbox računa.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section id="accounts" class="col-12">
|
||||
<div class="section-card">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
|
||||
<div>
|
||||
<div class="overline mb-1">List</div>
|
||||
<h2 class="section-title">Spremljeni mailbox računi</h2>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<span class="soft-badge">Last sync: <?= h($latestSync === 'Not yet' ? $latestSync : format_datetime($latestSync)) ?></span>
|
||||
<span class="soft-badge">Accounts: <?= h((string) count($accounts)) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($accounts): ?>
|
||||
<div class="table-shell">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mailbox</th>
|
||||
<th>POP3</th>
|
||||
<th>Status</th>
|
||||
<th>Zadnja sinkronizacija</th>
|
||||
<th class="text-end">Akcija</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($accounts as $account): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-semibold"><?= h($account['label']) ?></div>
|
||||
<div class="small text-secondary"><?= h((string) ($account['email_address'] ?: $account['username'])) ?></div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-semibold"><?= h($account['pop3_host']) ?>:<?= h((string) $account['pop3_port']) ?></div>
|
||||
<div class="small text-secondary"><?= h(security_label((string) $account['security_mode'])) ?> · limit <?= h((string) $account['sync_limit']) ?></div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge <?= h(status_tone((string) $account['last_status'])) ?>"><?= h((string) ($account['last_status'] ?: 'Ready')) ?></span>
|
||||
<div class="small text-secondary mt-1"><?= h((string) $account['last_message_count']) ?> poruka</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-semibold"><?= h(format_datetime($account['last_sync_at'])) ?></div>
|
||||
<div class="small text-secondary">Dodano <?= h(format_datetime($account['created_at'])) ?></div>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a class="btn btn-sm btn-primary" href="mailbox.php?id=<?= h((string) $account['id']) ?>">Otvori inbox</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="empty-panel">
|
||||
<h3 class="h5 mb-2">Još nema spremljenih mailbox računa.</h3>
|
||||
<p class="empty-copy mb-3">Dodaj prvi POP3 račun kako bi dashboard dobio live inbox i detail prikaz poruka.</p>
|
||||
<div class="d-flex justify-content-center flex-wrap gap-2">
|
||||
<span class="soft-badge">Primjer hosta: 127.0.0.1</span>
|
||||
<span class="soft-badge">Primjer porta: 110</span>
|
||||
<span class="soft-badge">Sigurnost: plain</span>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="d-flex flex-column flex-lg-row justify-content-between gap-3 pt-4 footer-copy">
|
||||
<div>Thin slice is ready: create account → confirmation redirect → list → inbox detail.</div>
|
||||
<div class="d-flex gap-3 flex-wrap">
|
||||
<a class="text-decoration-underline" href="healthz.php" target="_blank" rel="noopener">Open /healthz</a>
|
||||
<?php if ($accounts): ?>
|
||||
<a class="text-decoration-underline" href="mailbox.php?id=<?= h((string) $accounts[0]['id']) ?>">Open latest inbox</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" defer></script>
|
||||
<script src="assets/js/main.js?v=<?= h(asset_version('assets/js/main.js')) ?>" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
279
mailbox.php
Normal file
279
mailbox.php
Normal file
@ -0,0 +1,279 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/includes/app.php';
|
||||
require_once __DIR__ . '/includes/pop3_client.php';
|
||||
|
||||
app_boot();
|
||||
|
||||
$accountId = (int) ($_GET['id'] ?? 0);
|
||||
$account = $accountId > 0 ? find_mail_account($accountId) : null;
|
||||
$flash = pull_flash();
|
||||
$messages = [];
|
||||
$selectedMessage = null;
|
||||
$selectedNumber = max(0, (int) ($_GET['message'] ?? 0));
|
||||
$syncError = null;
|
||||
$totalRemoteMessages = 0;
|
||||
$displayedMessages = 0;
|
||||
$currentStatus = $account ? (string) ($account['last_status'] ?: 'Ready') : 'Ready';
|
||||
$currentLastSync = $account['last_sync_at'] ?? null;
|
||||
|
||||
if ($account) {
|
||||
$client = null;
|
||||
|
||||
try {
|
||||
$password = decrypt_secret((string) $account['password_ciphertext']);
|
||||
|
||||
if ($password === '') {
|
||||
throw new RuntimeException('Spremljena lozinka se ne može dešifrirati. Ponovno spremi mailbox račun.');
|
||||
}
|
||||
|
||||
$client = new Pop3Client((string) $account['pop3_host'], (int) $account['pop3_port'], (string) $account['security_mode']);
|
||||
$client->connect();
|
||||
$client->login((string) $account['username'], $password);
|
||||
|
||||
$stats = $client->stat();
|
||||
$totalRemoteMessages = (int) $stats['count'];
|
||||
$messages = $client->fetchRecent((int) $account['sync_limit']);
|
||||
$displayedMessages = count($messages);
|
||||
|
||||
if ($selectedNumber <= 0 && !empty($messages)) {
|
||||
$selectedNumber = (int) $messages[0]['number'];
|
||||
}
|
||||
|
||||
if ($selectedNumber > $totalRemoteMessages && !empty($messages)) {
|
||||
$selectedNumber = (int) $messages[0]['number'];
|
||||
}
|
||||
|
||||
if ($selectedNumber > 0 && $totalRemoteMessages > 0) {
|
||||
$selectedMessage = $client->fetchMessage($selectedNumber);
|
||||
}
|
||||
|
||||
$status = $totalRemoteMessages > 0 ? 'Connected' : 'Connected — empty mailbox';
|
||||
update_mail_account_sync($accountId, $status, $totalRemoteMessages);
|
||||
$currentStatus = $status;
|
||||
$currentLastSync = gmdate('Y-m-d H:i:s');
|
||||
} catch (Throwable $exception) {
|
||||
$syncError = $exception->getMessage();
|
||||
update_mail_account_sync($accountId, 'Sync failed', 0);
|
||||
$currentStatus = 'Sync failed';
|
||||
$currentLastSync = gmdate('Y-m-d H:i:s');
|
||||
} finally {
|
||||
if ($client instanceof Pop3Client) {
|
||||
$client->quit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$pageLabel = $account ? ($account['label'] . ' — Inbox') : 'Mailbox not found';
|
||||
$pageTitle = project_name() . ' — ' . $pageLabel;
|
||||
$projectBaseDescription = trim((string) ($_SERVER['PROJECT_DESCRIPTION'] ?? getenv('PROJECT_DESCRIPTION') ?: ''));
|
||||
$pageDescription = $projectBaseDescription !== ''
|
||||
? $projectBaseDescription . ' — Live POP3 inbox detail with message reading and fetched-list search.'
|
||||
: 'Read the latest POP3 messages in a clean split-view inbox with server-side search helpers and mailbox status.';
|
||||
$projectDescription = $projectBaseDescription !== '' ? $projectBaseDescription : $pageDescription;
|
||||
$projectImageUrl = project_image_url();
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="hr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= h($pageTitle) ?></title>
|
||||
<meta name="description" content="<?= h($pageDescription) ?>">
|
||||
<?php if ($projectDescription): ?>
|
||||
<meta property="og:description" content="<?= h($projectDescription) ?>">
|
||||
<meta property="twitter:description" content="<?= h($projectDescription) ?>">
|
||||
<?php endif; ?>
|
||||
<?php if ($projectImageUrl): ?>
|
||||
<meta property="og:image" content="<?= h($projectImageUrl) ?>">
|
||||
<meta property="twitter:image" content="<?= h($projectImageUrl) ?>">
|
||||
<?php endif; ?>
|
||||
<meta property="og:title" content="<?= h($pageTitle) ?>">
|
||||
<meta property="twitter:title" content="<?= h($pageTitle) ?>">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="assets/css/custom.css?v=<?= h(asset_version('assets/css/custom.css')) ?>">
|
||||
</head>
|
||||
<body>
|
||||
<?php if ($flash): ?>
|
||||
<div class="toast-shell">
|
||||
<div class="toast align-items-center border-0" role="status" aria-live="polite" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body"><?= h($flash['message']) ?></div>
|
||||
<button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="container app-shell py-4 py-lg-5">
|
||||
<nav class="navbar app-nav navbar-expand-lg">
|
||||
<div class="container-fluid px-0">
|
||||
<a class="navbar-brand d-flex align-items-center gap-3 m-0" href="index.php">
|
||||
<span class="brand-mark">WM</span>
|
||||
<span class="brand-copy">
|
||||
<strong><?= h(project_name()) ?></strong>
|
||||
<small>Mailbox detail</small>
|
||||
</span>
|
||||
</a>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 ms-auto">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="index.php">Natrag na dashboard</a>
|
||||
<?php if ($account): ?>
|
||||
<a class="btn btn-sm btn-primary" href="mailbox.php?id=<?= h((string) $account['id']) ?>">Refresh inbox</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<?php if (!$account): ?>
|
||||
<div class="section-card empty-panel">
|
||||
<h1 class="h4 mb-2">Mailbox nije pronađen.</h1>
|
||||
<p class="empty-copy mb-3">Vrati se na dashboard i dodaj POP3 račun da bi otvorio inbox pregled.</p>
|
||||
<a class="btn btn-primary" href="index.php">Idi na dashboard</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<header class="hero-grid mb-4">
|
||||
<section class="hero-card">
|
||||
<div class="page-eyebrow mb-2">Detail</div>
|
||||
<h1 class="hero-title mb-3"><?= h($account['label']) ?></h1>
|
||||
<p class="section-subtitle"><?= h((string) ($account['email_address'] ?: $account['username'])) ?> · POP3 <?= h($account['pop3_host']) ?>:<?= h((string) $account['pop3_port']) ?> · <?= h(security_label((string) $account['security_mode'])) ?></p>
|
||||
<div class="d-flex flex-wrap gap-2 mt-3">
|
||||
<span class="status-badge <?= h(status_tone($currentStatus)) ?>"><?= h($currentStatus) ?></span>
|
||||
<span class="soft-badge">Leave on server: <?= !empty($account['leave_on_server']) ? 'Yes' : 'No' ?></span>
|
||||
<span class="soft-badge">Inbox only in this slice</span>
|
||||
</div>
|
||||
<ul class="nav nav-pills gap-2 mt-4">
|
||||
<li class="nav-item"><span class="nav-link active">Inbox</span></li>
|
||||
<li class="nav-item"><span class="nav-link disabled">Sent · next</span></li>
|
||||
<li class="nav-item"><span class="nav-link disabled">Compose · next</span></li>
|
||||
</ul>
|
||||
</section>
|
||||
<aside class="metrics-grid">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Remote messages</div>
|
||||
<div class="metric-value"><?= h((string) $totalRemoteMessages) ?></div>
|
||||
<div class="metric-hint">Ukupan broj poruka koje je POP3 prijavio.</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Displayed now</div>
|
||||
<div class="metric-value"><?= h((string) $displayedMessages) ?></div>
|
||||
<div class="metric-hint">Prikazujemo zadnjih <?= h((string) $account['sync_limit']) ?> poruka.</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Last sync</div>
|
||||
<div class="metric-value" style="font-size:1.1rem;"><?= h(format_datetime($currentLastSync)) ?></div>
|
||||
<div class="metric-hint">Status se zapisuje u bazu pri svakom otvaranju inboxa.</div>
|
||||
</div>
|
||||
</aside>
|
||||
</header>
|
||||
|
||||
<?php if ($syncError): ?>
|
||||
<div class="alert alert-danger border-0 mb-4" role="alert">
|
||||
<strong>Sinkronizacija nije uspjela.</strong> <?= h($syncError) ?>
|
||||
<div class="small mt-2">Provjeri host, port, sigurnost veze i POP3 korisničke podatke. Za lokalni test često vrijedi 127.0.0.1:110 bez enkripcije.</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<main class="mailbox-shell">
|
||||
<section class="section-card">
|
||||
<div class="d-flex align-items-start justify-content-between gap-3 mb-3">
|
||||
<div>
|
||||
<div class="overline mb-1">List</div>
|
||||
<h2 class="section-title">Inbox poruke</h2>
|
||||
</div>
|
||||
<span class="soft-badge">Search in fetched set</span>
|
||||
</div>
|
||||
<div class="message-search">
|
||||
<input type="search" placeholder="Pretraži subject, from ili preview…" aria-label="Pretraži poruke" data-mail-search>
|
||||
</div>
|
||||
<?php if ($messages): ?>
|
||||
<div class="mailbox-list">
|
||||
<?php foreach ($messages as $message): ?>
|
||||
<?php $isActive = (int) $message['number'] === $selectedNumber; ?>
|
||||
<a
|
||||
class="message-item<?= $isActive ? ' active' : '' ?>"
|
||||
href="mailbox.php?id=<?= h((string) $account['id']) ?>&message=<?= h((string) $message['number']) ?>"
|
||||
data-mail-item
|
||||
data-search="<?= h(strtolower($message['subject'] . ' ' . $message['from'] . ' ' . $message['preview'])) ?>"
|
||||
>
|
||||
<div class="d-flex justify-content-between gap-3 align-items-start">
|
||||
<p class="message-subject"><?= h($message['subject']) ?></p>
|
||||
<span class="message-date"><?= h($message['date'] !== '' ? $message['date'] : ('#' . $message['number'])) ?></span>
|
||||
</div>
|
||||
<div class="message-from"><?= h($message['from']) ?></div>
|
||||
<div class="message-preview"><?= h($message['preview']) ?></div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="empty-panel d-none mt-3" data-mail-empty>
|
||||
<p class="empty-copy mb-0">Nijedna poruka iz dohvaćenog seta ne odgovara trenutnoj pretrazi.</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="empty-panel">
|
||||
<h3 class="h6 mb-2">Inbox je trenutno prazan.</h3>
|
||||
<p class="empty-copy mb-0">Ako očekuješ poruke, klikni refresh ili provjeri da POP3 server zaista ima mail u sandučiću.</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="section-card">
|
||||
<div class="d-flex align-items-start justify-content-between gap-3 mb-3">
|
||||
<div>
|
||||
<div class="overline mb-1">Read / detail</div>
|
||||
<h2 class="section-title">Detalj poruke</h2>
|
||||
</div>
|
||||
<span class="soft-badge">Server-rendered preview</span>
|
||||
</div>
|
||||
|
||||
<?php if ($selectedMessage): ?>
|
||||
<div class="message-header-grid">
|
||||
<div class="detail-block">
|
||||
<strong>Subject</strong>
|
||||
<div><?= h($selectedMessage['subject']) ?></div>
|
||||
</div>
|
||||
<div class="detail-block">
|
||||
<strong>From</strong>
|
||||
<div><?= h($selectedMessage['from']) ?></div>
|
||||
</div>
|
||||
<div class="detail-block">
|
||||
<strong>Date / POP3 #</strong>
|
||||
<div><?= h($selectedMessage['date'] !== '' ? $selectedMessage['date'] : ('Message #' . $selectedMessage['number'])) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<article class="mail-preview">
|
||||
<div class="message-body"><?= nl2br(h($selectedMessage['body_text'])) ?></div>
|
||||
</article>
|
||||
<?php else: ?>
|
||||
<div class="empty-panel">
|
||||
<h3 class="h6 mb-2">Odaberi poruku s lijeve strane.</h3>
|
||||
<p class="empty-copy mb-0">Kad odabereš mail, ovdje ćeš vidjeti subject, sender, datum i tekstualni sadržaj poruke.</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="surface-muted p-3 rounded-4 border mt-3">
|
||||
<div class="overline mb-2">Mailbox settings</div>
|
||||
<div class="meta-list">
|
||||
<div>
|
||||
<strong>POP3 endpoint</strong>
|
||||
<span><?= h($account['pop3_host']) ?>:<?= h((string) $account['pop3_port']) ?> · <?= h(security_label((string) $account['security_mode'])) ?></span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Username</strong>
|
||||
<span><?= h($account['username']) ?></span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Saved in database</strong>
|
||||
<span><?= h(format_datetime($account['created_at'])) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" defer></script>
|
||||
<script src="assets/js/main.js?v=<?= h(asset_version('assets/js/main.js')) ?>" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
199
xampp-setup.php
Normal file
199
xampp-setup.php
Normal file
@ -0,0 +1,199 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/includes/app.php';
|
||||
|
||||
$settings = db_settings();
|
||||
$setupError = null;
|
||||
$setupMessage = null;
|
||||
$scriptDir = trim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '')), '/.');
|
||||
$localBaseUrl = 'http://localhost' . ($scriptDir !== '' ? '/' . $scriptDir : '');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'initialize_database') {
|
||||
try {
|
||||
db_initialize(__DIR__ . '/db/migrations/20260524_create_mail_accounts.sql');
|
||||
flash('success', 'XAMPP baza je spremna. Sada možeš dodati POP3 račun.');
|
||||
header('Location: index.php');
|
||||
exit;
|
||||
} catch (Throwable $exception) {
|
||||
$setupError = $exception->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
app_boot();
|
||||
|
||||
$dbReady = db_ready();
|
||||
$dbError = $setupError ?: app_db_error();
|
||||
$pageTitle = project_name() . ' — XAMPP setup';
|
||||
$projectBaseDescription = trim((string) ($_SERVER['PROJECT_DESCRIPTION'] ?? getenv('PROJECT_DESCRIPTION') ?: ''));
|
||||
$pageDescription = $projectBaseDescription !== ''
|
||||
? $projectBaseDescription . ' — Local XAMPP setup for the POP3 webmail database and first run.'
|
||||
: 'Prepare the POP3 webmail app for local XAMPP by initializing the MySQL database and verifying the runtime configuration.';
|
||||
$projectDescription = $projectBaseDescription !== '' ? $projectBaseDescription : $pageDescription;
|
||||
$projectImageUrl = project_image_url();
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="hr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= h($pageTitle) ?></title>
|
||||
<meta name="description" content="<?= h($pageDescription) ?>">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<?php if ($projectDescription): ?>
|
||||
<meta property="og:description" content="<?= h($projectDescription) ?>">
|
||||
<meta property="twitter:description" content="<?= h($projectDescription) ?>">
|
||||
<?php endif; ?>
|
||||
<?php if ($projectImageUrl): ?>
|
||||
<meta property="og:image" content="<?= h($projectImageUrl) ?>">
|
||||
<meta property="twitter:image" content="<?= h($projectImageUrl) ?>">
|
||||
<?php endif; ?>
|
||||
<meta property="og:title" content="<?= h($pageTitle) ?>">
|
||||
<meta property="twitter:title" content="<?= h($pageTitle) ?>">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="assets/css/custom.css?v=<?= h(asset_version('assets/css/custom.css')) ?>">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container app-shell py-4 py-lg-5">
|
||||
<nav class="navbar app-nav navbar-expand-lg">
|
||||
<div class="container-fluid px-0">
|
||||
<a class="navbar-brand d-flex align-items-center gap-3 m-0" href="index.php">
|
||||
<span class="brand-mark">WM</span>
|
||||
<span class="brand-copy">
|
||||
<strong><?= h(project_name()) ?></strong>
|
||||
<small>XAMPP setup</small>
|
||||
</span>
|
||||
</a>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 ms-auto">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="index.php">Dashboard</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="healthz.php" target="_blank" rel="noopener">Healthz</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<header class="hero-grid">
|
||||
<section class="hero-card">
|
||||
<div class="page-eyebrow mb-2">Local install</div>
|
||||
<h1 class="hero-title">Priprema za XAMPP lokalni rad</h1>
|
||||
<p class="section-subtitle">Otvori ovu stranicu unutar <strong>htdocs</strong>, pokreni Apache i MySQL u XAMPP-u, pa jednim klikom kreiraj bazu i mailbox tablicu. Nakon toga dashboard na <code>index.php</code> radi lokalno na <code>localhost</code>.</p>
|
||||
<div class="hero-actions">
|
||||
<?php if ($dbReady): ?>
|
||||
<a class="btn btn-primary" href="index.php">Otvori dashboard</a>
|
||||
<?php else: ?>
|
||||
<form method="post" class="d-inline-flex m-0">
|
||||
<input type="hidden" name="action" value="initialize_database">
|
||||
<button type="submit" class="btn btn-primary">Kreiraj bazu i tablicu</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<a class="btn btn-outline-secondary" href="index.php">Natrag na aplikaciju</a>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 mt-3">
|
||||
<span class="inline-note">Apache + MySQL</span>
|
||||
<span class="inline-note">phpMyAdmin optional</span>
|
||||
<span class="inline-note">Local URL ready</span>
|
||||
</div>
|
||||
</section>
|
||||
<aside class="metrics-grid">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Config profile</div>
|
||||
<div class="metric-value" style="font-size:1.1rem;">PHP <?= h(PHP_VERSION) ?></div>
|
||||
<div class="metric-hint">MySQL: <?= h($settings['user']) ?>@<?= h($settings['host']) ?>:<?= h((string) $settings['port']) ?></div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Database</div>
|
||||
<div class="metric-value" style="font-size:1.1rem;"><?= h($settings['name']) ?></div>
|
||||
<div class="metric-hint">Lozinka: <?= $settings['has_password'] ? 'postavljena' : 'prazna / XAMPP default' ?></div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Open locally</div>
|
||||
<div class="metric-value" style="font-size:1rem;"><?= h($localBaseUrl !== '' ? $localBaseUrl : 'http://localhost') ?></div>
|
||||
<div class="metric-hint">Zatim otvori <a class="text-decoration-underline" href="healthz.php" target="_blank" rel="noopener">/healthz</a></div>
|
||||
</div>
|
||||
</aside>
|
||||
</header>
|
||||
|
||||
<?php if ($dbReady): ?>
|
||||
<div class="alert alert-success border-0 mb-4" role="alert">
|
||||
<strong>Baza je spremna.</strong> Možeš se vratiti na dashboard i dodati prvi POP3 račun.
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="alert alert-warning border-0 mb-4" role="alert">
|
||||
<strong>Baza još nije spremna.</strong> Ako koristiš tipični XAMPP, ostavi <code>root</code> korisnika i praznu lozinku ili po potrebi prvo prilagodi <code>db/config.php</code>.
|
||||
<?php if ($dbError): ?>
|
||||
<div class="small mt-2"><?= h($dbError) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<main class="row g-4">
|
||||
<section class="col-12 col-xl-7">
|
||||
<div class="section-card stack-md h-100">
|
||||
<div>
|
||||
<div class="overline mb-1">3 steps</div>
|
||||
<h2 class="section-title">Kako pokrenuti aplikaciju na XAMPP-u</h2>
|
||||
</div>
|
||||
<div class="quick-steps">
|
||||
<article class="quick-step">
|
||||
<h3>1. Kopiraj projekt u htdocs</h3>
|
||||
<p>Primjer: <code>xampp/htdocs/pop3-webmail</code>. Lokalni URL će tada biti sličan <code><?= h($localBaseUrl) ?>/index.php</code>.</p>
|
||||
</article>
|
||||
<article class="quick-step">
|
||||
<h3>2. Pokreni Apache i MySQL</h3>
|
||||
<p>U XAMPP Control Panelu uključi oba servisa. Bez aktivnog MySQL-a spremanje mailboxa neće raditi.</p>
|
||||
</article>
|
||||
<article class="quick-step">
|
||||
<h3>3. Inicijaliziraj bazu</h3>
|
||||
<p>Klikni gumb <strong>Kreiraj bazu i tablicu</strong>. To će napraviti bazu <code><?= h($settings['name']) ?></code> i tablicu <code>mail_accounts</code>.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="col-12 col-xl-5">
|
||||
<div class="section-card stack-md h-100">
|
||||
<div>
|
||||
<div class="overline mb-1">Current config</div>
|
||||
<h2 class="section-title">Vrijednosti koje aplikacija koristi</h2>
|
||||
</div>
|
||||
<div class="surface-muted p-3 rounded-4 border">
|
||||
<div class="meta-list">
|
||||
<div>
|
||||
<strong>DB host</strong>
|
||||
<span><?= h($settings['host']) ?></span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>DB port</strong>
|
||||
<span><?= h((string) $settings['port']) ?></span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>DB name</strong>
|
||||
<span><?= h($settings['name']) ?></span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>DB user</strong>
|
||||
<span><?= h($settings['user']) ?></span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>DB password</strong>
|
||||
<span><?= $settings['has_password'] ? 'Configured' : 'Empty (XAMPP default)' ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="surface-muted p-3 rounded-4 border">
|
||||
<div class="overline mb-2">Manual fallback</div>
|
||||
<p class="small mb-2">Ako želiš ručno kroz phpMyAdmin, prvo kreiraj bazu:</p>
|
||||
<pre class="mb-0"><code>CREATE DATABASE IF NOT EXISTS `<?= h($settings['name']) ?>`
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;</code></pre>
|
||||
<p class="small mt-3 mb-0">Zatim importaj SQL iz <code>db/migrations/20260524_create_mail_accounts.sql</code>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user