forked from izu/student-web-if-development-kit
refactor(tapops): modularize page code
This commit is contained in:
+1
-1328
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
||||
/* --- Document Cards Grid --- */
|
||||
|
||||
.document-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.document-card {
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 30px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.3s cubic-bezier(0.165, 0.84, 0.44, 1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.document-card:hover {
|
||||
border-color: var(--secondary-gold);
|
||||
box-shadow: 0 20px 40px rgba(0, 49, 80, 0.05);
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.doc-type-icon {
|
||||
font-size: 2.2rem;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.doc-type-pdf {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.doc-type-docx {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.doc-title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
color: var(--primary-blue);
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.doc-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 25px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.doc-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 800;
|
||||
padding: 4px 10px;
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.doc-badge.date {
|
||||
background: hsl(200, 95%, 95%);
|
||||
color: hsl(200, 95%, 30%);
|
||||
}
|
||||
|
||||
/* --- Tag Badges --- */
|
||||
|
||||
.doc-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.doc-tags-icon {
|
||||
font-size: 0.7rem;
|
||||
color: #94a3b8;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.doc-tag {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
padding: 3px 10px;
|
||||
background: hsl(43, 100%, 95%);
|
||||
color: hsl(43, 80%, 35%);
|
||||
border: 1px solid hsl(43, 80%, 85%);
|
||||
border-radius: 20px;
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.3px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.doc-tag:hover {
|
||||
background: hsl(43, 80%, 88%);
|
||||
border-color: hsl(43, 80%, 70%);
|
||||
}
|
||||
|
||||
.doc-actions {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-action-primary,
|
||||
.btn-action-outline {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
font-weight: 800;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
a.btn-action-primary {
|
||||
background: var(--primary-blue);
|
||||
color: white;
|
||||
border: 1px solid var(--primary-blue);
|
||||
}
|
||||
|
||||
a.btn-action-primary:hover {
|
||||
background: var(--secondary-gold);
|
||||
border-color: var(--secondary-gold);
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
a.btn-action-outline {
|
||||
background: transparent;
|
||||
color: var(--primary-blue);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
a.btn-action-outline:hover {
|
||||
border-color: var(--primary-blue);
|
||||
background: #f8fafc;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/* --- Content Area & Search --- */
|
||||
|
||||
.repo-main-content {
|
||||
min-height: 800px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.repo-content-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
.header-title-area .section-title {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 0.95rem;
|
||||
color: #64748b;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.repo-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 24px;
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.search-container {
|
||||
flex: 1;
|
||||
min-width: 260px;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.search-input-group {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #94a3b8;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
#doc-search {
|
||||
width: 100%;
|
||||
padding: 16px 16px 16px 50px;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
font-size: 0.95rem;
|
||||
color: var(--primary-blue);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
#doc-search:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* --- Filter Controls --- */
|
||||
|
||||
.filter-controls {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.date-range-group {
|
||||
min-width: 300px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.control-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.select-wrapper {
|
||||
position: relative;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
#sort-filter-wrapper {
|
||||
position: relative;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.select-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--primary-blue);
|
||||
font-size: 0.8rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
/* --- Date Range Filter & Popover --- */
|
||||
.date-picker-trigger {
|
||||
width: 100%;
|
||||
padding: 14px 18px;
|
||||
background: white;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-blue);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.date-picker-trigger:hover {
|
||||
border-color: var(--secondary-gold);
|
||||
background: #fffdf5;
|
||||
}
|
||||
|
||||
.date-picker-trigger:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--primary-blue);
|
||||
box-shadow: 0 0 0 3px rgba(0, 49, 80, 0.08);
|
||||
}
|
||||
|
||||
.date-picker-trigger .trigger-left-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.date-picker-trigger .trigger-arrow {
|
||||
font-size: 0.8rem;
|
||||
transition: transform 0.25s ease;
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.date-range-group.active .date-picker-trigger .trigger-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.date-range-group.active .date-picker-trigger {
|
||||
border-color: var(--primary-blue);
|
||||
box-shadow: 0 0 0 3px rgba(0, 49, 80, 0.08);
|
||||
}
|
||||
|
||||
/* Backdrop */
|
||||
.date-picker-backdrop {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
/* Popover */
|
||||
.date-picker-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
width: 630px;
|
||||
background: white;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
z-index: 45;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.date-range-group.active .date-picker-popover {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Top Bar */
|
||||
.date-picker-top-bar {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: #f8fafc;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.date-display-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.date-display-field input {
|
||||
width: 110px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary-blue);
|
||||
text-align: center;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.date-picker-separator {
|
||||
color: #94a3b8;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.date-picker-body {
|
||||
display: flex;
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* Calendar Pane */
|
||||
.calendar-container {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.calendar-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.month-labels {
|
||||
display: flex;
|
||||
gap: 90px;
|
||||
color: var(--primary-blue);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.month-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.year-select-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.year-select-wrapper:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.calendar-year-select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 2px 16px 2px 6px;
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-blue);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.year-select-wrapper .select-arrow {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 0.6rem;
|
||||
color: var(--primary-blue);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: #f1f5f9;
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.calendar-grids {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.calendar-grid-pane {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.day-headers {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
text-align: center;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.days-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
row-gap: 4px;
|
||||
column-gap: 2px;
|
||||
}
|
||||
|
||||
.day-cell {
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.day-cell:hover {
|
||||
background: #f1f5f9;
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.day-cell.empty {
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.day-cell.disabled {
|
||||
color: #cbd5e1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.day-cell.range-start {
|
||||
background: var(--primary-blue) !important;
|
||||
color: white !important;
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
.day-cell.range-end {
|
||||
background: var(--primary-blue) !important;
|
||||
color: white !important;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.day-cell.range-start.range-end {
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
.day-cell.range-between {
|
||||
background: #eff6ff !important;
|
||||
color: var(--primary-blue) !important;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Presets Pane */
|
||||
.presets-container {
|
||||
width: 190px;
|
||||
border-left: 1px solid var(--border-color);
|
||||
padding: 16px;
|
||||
background: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.presets-title {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.presets-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.preset-btn:hover {
|
||||
background: #f8fafc;
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.preset-btn.active {
|
||||
background: #eff6ff;
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.preset-btn.active::after {
|
||||
content: '✓';
|
||||
font-weight: 700;
|
||||
color: var(--primary-blue);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Footer Action Buttons */
|
||||
.date-picker-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 8px 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: white;
|
||||
border: 1px solid #cbd5e1;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #94a3b8;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.apply-btn {
|
||||
background: var(--primary-blue);
|
||||
border: 1px solid var(--primary-blue);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.apply-btn:hover {
|
||||
background: #002238;
|
||||
border-color: #002238;
|
||||
}
|
||||
|
||||
.range-divider {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.filter-select,
|
||||
select#sort-filter {
|
||||
width: 100%;
|
||||
padding: 12px 12px 12px 36px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-blue);
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.filter-select:hover,
|
||||
select#sort-filter:hover {
|
||||
border-color: var(--secondary-gold);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.filter-select:focus,
|
||||
select#sort-filter:focus {
|
||||
outline: none;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/* --- Custom UI Dropdown --- */
|
||||
.custom-dropdown {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.custom-dropdown-trigger {
|
||||
width: 100%;
|
||||
padding: 14px 18px;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-blue);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.custom-dropdown-trigger:hover {
|
||||
border-color: var(--secondary-gold);
|
||||
background: #fffdf5;
|
||||
}
|
||||
|
||||
.custom-dropdown-trigger:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--primary-blue);
|
||||
box-shadow: 0 0 0 3px rgba(0, 49, 80, 0.08);
|
||||
}
|
||||
|
||||
.custom-dropdown-trigger .trigger-arrow {
|
||||
font-size: 0.8rem;
|
||||
transition: transform 0.25s ease;
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.custom-dropdown.active .custom-dropdown-trigger .trigger-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.custom-dropdown.active .custom-dropdown-trigger {
|
||||
border-color: var(--primary-blue);
|
||||
box-shadow: 0 0 0 3px rgba(0, 49, 80, 0.08);
|
||||
}
|
||||
|
||||
.custom-dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||
z-index: 100;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-8px);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.custom-dropdown.active .custom-dropdown-menu {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.custom-dropdown-item {
|
||||
padding: 12px 18px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.custom-dropdown-item:hover {
|
||||
background: #f1f5f9;
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.custom-dropdown-item.active {
|
||||
background: #f0f7ff;
|
||||
color: var(--primary-blue);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Tick mark for selected item */
|
||||
.custom-dropdown-item.active::after {
|
||||
content: "\f00c";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 900;
|
||||
font-size: 0.75rem;
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
/* --- Filter Skeleton Loading --- */
|
||||
|
||||
.filter-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-skeleton-item {
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, #f1f5f9 25%, #e2e8f0 50%, #f1f5f9 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.filter-skeleton-item.short {
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.sidebar-info-box {
|
||||
background: #f1f5f9;
|
||||
padding: 20px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sidebar-info-box i {
|
||||
color: var(--primary-blue);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.sidebar-info-box p {
|
||||
font-size: 0.82rem;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@import url("layout.css");
|
||||
@import url("dropdown.css");
|
||||
@import url("controls.css");
|
||||
@import url("date-picker.css");
|
||||
@import url("cards.css");
|
||||
@import url("responsive.css");
|
||||
@import url("pagination.css");
|
||||
@@ -0,0 +1,81 @@
|
||||
/* ===========================================
|
||||
Document Hub & Dashboard Layout
|
||||
=========================================== */
|
||||
|
||||
.documents-section {
|
||||
padding: 120px 5%;
|
||||
background: hsl(0, 0%, 100%);
|
||||
}
|
||||
|
||||
.repo-dashboard-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: 40px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* --- Sidebar Styling --- */
|
||||
|
||||
.repo-sidebar {
|
||||
position: sticky;
|
||||
top: 150px;
|
||||
}
|
||||
|
||||
/* Mobile category dropdown wrapper (hidden on desktop) */
|
||||
.mobile-category-control-group {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 2px;
|
||||
font-weight: 800;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 20px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.document-filter-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
/* Desktop: show buttons, hide dropdown */
|
||||
.filter-btn-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-select-group {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.doc-filter-btn {
|
||||
text-align: left;
|
||||
padding: 14px 18px;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-blue);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.doc-filter-btn.active {
|
||||
background: var(--primary-blue);
|
||||
color: white;
|
||||
border-color: var(--primary-blue);
|
||||
box-shadow: 0 4px 12px rgba(0, 49, 80, 0.15);
|
||||
}
|
||||
|
||||
.doc-filter-btn:hover:not(.active) {
|
||||
border-color: var(--secondary-gold);
|
||||
background: #fffdf5;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/* ===========================================
|
||||
Pagination
|
||||
=========================================== */
|
||||
|
||||
.doc-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32px 0 0;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.pagination-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
color: var(--primary-blue);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.pagination-btn:hover:not(.disabled):not(.active) {
|
||||
border-color: var(--secondary-gold);
|
||||
background: #fffdf5;
|
||||
}
|
||||
|
||||
.pagination-btn.active {
|
||||
background: var(--primary-blue);
|
||||
border-color: var(--primary-blue);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pagination-btn.disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pagination-ellipsis {
|
||||
min-width: 32px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #94a3b8;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.doc-pagination {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.pagination-btn {
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.pagination-nav {
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Responsive — Phone (≤480px)
|
||||
=========================================== */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.documents-section {
|
||||
padding: 50px 12px;
|
||||
}
|
||||
|
||||
.custom-dropdown-trigger {
|
||||
padding: 12px 14px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Single column cards */
|
||||
.document-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.document-card {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.doc-title {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.doc-type-icon {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.doc-meta {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.doc-tags {
|
||||
margin-bottom: 10px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.doc-tag {
|
||||
font-size: 0.6rem;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.btn-action-primary,
|
||||
.btn-action-outline {
|
||||
padding: 10px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.doc-actions {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.repo-controls {
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
#doc-search {
|
||||
padding: 12px 12px 12px 40px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
left: 14px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.repo-content-header {
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/* ===========================================
|
||||
Responsive — Tablet (≤1024px)
|
||||
=========================================== */
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.repo-dashboard-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.repo-sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-category-control-group {
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Mobile: show dropdown */
|
||||
.filter-select-group {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Skeleton single bar on mobile */
|
||||
.filter-skeleton {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filter-skeleton-item {
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.filter-skeleton-item.short {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.filter-skeleton-item:not(.short) ~ .filter-skeleton-item:not(.short) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.repo-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Responsive — Tablet / Large Phone (≤768px)
|
||||
=========================================== */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.documents-section {
|
||||
padding: 80px 16px;
|
||||
}
|
||||
|
||||
.repo-content-header {
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.repo-controls {
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#doc-search {
|
||||
padding: 14px 14px 14px 44px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.date-range-group {
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body.date-picker-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.date-picker-open .date-picker-backdrop {
|
||||
display: block;
|
||||
z-index: 998;
|
||||
}
|
||||
|
||||
.date-picker-popover {
|
||||
/* Default mobile popover positioning inside media query is overridden below,
|
||||
but let's define the base transitions or layouts. */
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.date-picker-popover {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
right: auto;
|
||||
transform: translate(-50%, -50%);
|
||||
width: calc(100% - 32px);
|
||||
max-width: 380px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.15), 0 10px 10px -5px rgba(0, 0, 0, 0.1);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.date-picker-top-bar {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.calendar-grids {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
#right-calendar-pane {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.month-labels {
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#right-month-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.date-picker-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.presets-container {
|
||||
width: 100%;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.presets-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
padding: 6px 10px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.select-wrapper,
|
||||
#sort-filter-wrapper {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Cards: 2-column when space permits, else single */
|
||||
.document-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.document-card {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.document-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.doc-type-icon {
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.doc-title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.doc-meta {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.doc-tags {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.repo-main-content {
|
||||
min-height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Responsive — Phone (≤480px)
|
||||
=========================================== */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.date-dd, .date-mm {
|
||||
width: 26px;
|
||||
}
|
||||
|
||||
.date-yyyy {
|
||||
min-width: 38px;
|
||||
}
|
||||
|
||||
.date-calendar-btn {
|
||||
padding-left: 4px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
}
|
||||
+5
-1303
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
import { CounterAnimation } from './components/counter-animation.js';
|
||||
import { AccessibilityToggleController } from './controllers/accessibility-toggle-controller.js';
|
||||
import { DocumentRepository } from './controllers/document-repository.js';
|
||||
import { MobileMenuController } from './controllers/mobile-menu-controller.js';
|
||||
import { NavigationController } from './controllers/navigation-controller.js';
|
||||
import { pageTemplate } from './templates/page-template.js';
|
||||
|
||||
class TapopsApp {
|
||||
constructor(root = document.getElementById('tapops-page-root')) {
|
||||
this.root = root;
|
||||
}
|
||||
|
||||
init() {
|
||||
if (!this.root) return;
|
||||
|
||||
this.root.innerHTML = pageTemplate();
|
||||
window.applyLang?.();
|
||||
window.applyTp?.(this.root);
|
||||
|
||||
new NavigationController().init();
|
||||
new MobileMenuController().init();
|
||||
new AccessibilityToggleController().init();
|
||||
new CounterAnimation().init();
|
||||
new DocumentRepository().init();
|
||||
}
|
||||
}
|
||||
|
||||
new TapopsApp().init();
|
||||
@@ -0,0 +1,44 @@
|
||||
export class CounterAnimation {
|
||||
static DURATION_MS = 2000;
|
||||
|
||||
init() {
|
||||
const counters = document.querySelectorAll('.counter-value[data-target]');
|
||||
if (counters.length === 0) return;
|
||||
|
||||
if ('IntersectionObserver' in window) {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
this._animate(entry.target);
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.2 });
|
||||
counters.forEach(c => observer.observe(c));
|
||||
} else {
|
||||
counters.forEach(c => this._animate(c));
|
||||
}
|
||||
}
|
||||
|
||||
_animate(el) {
|
||||
const target = Number.parseInt((el.dataset.target || '0').replace(/[^0-9]/g, ''), 10);
|
||||
if (Number.isNaN(target)) return;
|
||||
CounterAnimation.animateValue(el, target, el.dataset.suffix || '');
|
||||
}
|
||||
|
||||
// Reusable easing tween from 0 → target. Callable for stats whose
|
||||
// value is only known after an async fetch (no data-target needed).
|
||||
static animateValue(el, target, suffix = '') {
|
||||
if (!el || Number.isNaN(target)) return;
|
||||
const startTime = performance.now();
|
||||
|
||||
const tick = (now) => {
|
||||
const progress = Math.min((now - startTime) / CounterAnimation.DURATION_MS, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
el.textContent = Math.floor(eased * target) + suffix;
|
||||
if (progress < 1) requestAnimationFrame(tick);
|
||||
else el.textContent = target + suffix;
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
export class CustomDropdown {
|
||||
constructor({ container, options, defaultValue, onChange, prefixIcon }) {
|
||||
this.container = container;
|
||||
this.options = options; // Array of { value, text, i18nKey }
|
||||
this.onChange = onChange;
|
||||
this.currentValue = defaultValue || options[0]?.value;
|
||||
this.prefixIcon = prefixIcon; // optional class name like 'fas fa-sort'
|
||||
this.isOpen = false;
|
||||
|
||||
this.elements = {};
|
||||
this._build();
|
||||
this._bindEvents();
|
||||
}
|
||||
|
||||
_build() {
|
||||
this.container.innerHTML = '';
|
||||
this.container.classList.add('custom-dropdown');
|
||||
|
||||
// Trigger Button
|
||||
const trigger = document.createElement('button');
|
||||
trigger.className = 'custom-dropdown-trigger';
|
||||
trigger.setAttribute('aria-haspopup', 'listbox');
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
trigger.type = 'button';
|
||||
|
||||
const triggerLeft = document.createElement('div');
|
||||
triggerLeft.className = 'trigger-left-content';
|
||||
triggerLeft.style.display = 'flex';
|
||||
triggerLeft.style.alignItems = 'center';
|
||||
triggerLeft.style.gap = '10px';
|
||||
|
||||
if (this.prefixIcon) {
|
||||
const icon = document.createElement('i');
|
||||
icon.className = this.prefixIcon;
|
||||
triggerLeft.appendChild(icon);
|
||||
}
|
||||
|
||||
const labelSpan = document.createElement('span');
|
||||
labelSpan.className = 'trigger-label';
|
||||
|
||||
// Find default text
|
||||
const defaultOpt = this.options.find(opt => opt.value === this.currentValue) || this.options[0];
|
||||
labelSpan.textContent = defaultOpt ? defaultOpt.text : '';
|
||||
if (defaultOpt?.i18nKey) {
|
||||
labelSpan.setAttribute('data-tp-i18n', defaultOpt.i18nKey);
|
||||
}
|
||||
|
||||
triggerLeft.appendChild(labelSpan);
|
||||
trigger.appendChild(triggerLeft);
|
||||
|
||||
const arrowIcon = document.createElement('i');
|
||||
arrowIcon.className = 'fas fa-chevron-down trigger-arrow';
|
||||
|
||||
trigger.appendChild(arrowIcon);
|
||||
this.container.appendChild(trigger);
|
||||
|
||||
// Menu wrapper
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'custom-dropdown-menu';
|
||||
menu.setAttribute('role', 'listbox');
|
||||
|
||||
// Populate options
|
||||
this.options.forEach(opt => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'custom-dropdown-item' + (opt.value === this.currentValue ? ' active' : '');
|
||||
item.setAttribute('role', 'option');
|
||||
item.setAttribute('data-value', opt.value);
|
||||
|
||||
const itemText = document.createElement('span');
|
||||
itemText.textContent = opt.text;
|
||||
if (opt.i18nKey) {
|
||||
itemText.setAttribute('data-tp-i18n', opt.i18nKey);
|
||||
}
|
||||
|
||||
item.appendChild(itemText);
|
||||
menu.appendChild(item);
|
||||
});
|
||||
|
||||
this.container.appendChild(menu);
|
||||
|
||||
this.elements = { trigger, labelSpan, menu };
|
||||
}
|
||||
|
||||
_bindEvents() {
|
||||
// Toggle dropdown on trigger click
|
||||
this.elements.trigger.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggle();
|
||||
});
|
||||
|
||||
// Handle item selection
|
||||
this.elements.menu.addEventListener('click', (e) => {
|
||||
const item = e.target.closest('.custom-dropdown-item');
|
||||
if (!item) return;
|
||||
|
||||
const value = item.getAttribute('data-value');
|
||||
this.select(value);
|
||||
this.close();
|
||||
});
|
||||
|
||||
// Close when clicking outside
|
||||
document.addEventListener('click', () => {
|
||||
this.close();
|
||||
});
|
||||
|
||||
// Close on escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') this.close();
|
||||
});
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.isOpen) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
// Close other custom dropdowns
|
||||
document.querySelectorAll('.custom-dropdown.active').forEach(dropdown => {
|
||||
if (dropdown !== this.container) {
|
||||
dropdown.classList.remove('active');
|
||||
dropdown.querySelector('.custom-dropdown-trigger')?.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
|
||||
this.isOpen = true;
|
||||
this.container.classList.add('active');
|
||||
this.elements.trigger.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.container.classList.remove('active');
|
||||
this.elements.trigger.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
select(value) {
|
||||
if (value === this.currentValue) return;
|
||||
|
||||
this.currentValue = value;
|
||||
|
||||
// Update active class in menu items
|
||||
this.elements.menu.querySelectorAll('.custom-dropdown-item').forEach(item => {
|
||||
const isCurrent = item.getAttribute('data-value') === value;
|
||||
item.classList.toggle('active', isCurrent);
|
||||
});
|
||||
|
||||
// Update trigger label
|
||||
const selectedOpt = this.options.find(opt => opt.value === value);
|
||||
if (selectedOpt) {
|
||||
this.elements.labelSpan.textContent = selectedOpt.text;
|
||||
if (selectedOpt.i18nKey) {
|
||||
this.elements.labelSpan.setAttribute('data-tp-i18n', selectedOpt.i18nKey);
|
||||
} else {
|
||||
this.elements.labelSpan.removeAttribute('data-tp-i18n');
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger callback
|
||||
if (typeof this.onChange === 'function') {
|
||||
this.onChange(value);
|
||||
}
|
||||
}
|
||||
|
||||
setValue(value) {
|
||||
this.select(value);
|
||||
}
|
||||
|
||||
getValue() {
|
||||
return this.currentValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
export class DateRangePicker {
|
||||
constructor({ containerId, onChange, initialStartDate = '', initialEndDate = '' }) {
|
||||
this.container = document.getElementById(containerId);
|
||||
if (!this.container) return;
|
||||
|
||||
this.trigger = this.container.querySelector('#date-picker-trigger');
|
||||
this.popover = this.container.querySelector('#date-picker-popover');
|
||||
this.backdrop = this.container.querySelector('#date-picker-backdrop');
|
||||
this.label = this.container.querySelector('#date-picker-label');
|
||||
|
||||
this.startDisplay = this.container.querySelector('#picker-start-display');
|
||||
this.endDisplay = this.container.querySelector('#picker-end-display');
|
||||
|
||||
this.prevBtn = this.container.querySelector('#prev-month-btn');
|
||||
this.nextBtn = this.container.querySelector('#next-month-btn');
|
||||
this.leftMonthLabel = this.container.querySelector('#left-month-label');
|
||||
this.rightMonthLabel = this.container.querySelector('#right-month-label');
|
||||
|
||||
this.leftMonthName = this.container.querySelector('#left-month-name');
|
||||
this.rightMonthName = this.container.querySelector('#right-month-name');
|
||||
this.leftYearSelect = this.container.querySelector('#left-year-select');
|
||||
this.rightYearSelect = this.container.querySelector('#right-year-select');
|
||||
|
||||
this.leftPane = this.container.querySelector('#left-calendar-pane');
|
||||
this.rightPane = this.container.querySelector('#right-calendar-pane');
|
||||
|
||||
this.leftDaysContainer = this.container.querySelector('#left-days-container');
|
||||
this.rightDaysContainer = this.container.querySelector('#right-days-container');
|
||||
|
||||
this.cancelBtn = this.container.querySelector('#picker-cancel-btn');
|
||||
this.applyBtn = this.container.querySelector('#picker-apply-btn');
|
||||
|
||||
this.onChange = onChange;
|
||||
|
||||
// Active dates (saved)
|
||||
this.startDate = initialStartDate ? new Date(initialStartDate) : null;
|
||||
this.endDate = initialEndDate ? new Date(initialEndDate) : null;
|
||||
|
||||
// Temporary dates (being edited)
|
||||
this.tempStartDate = this.startDate ? new Date(this.startDate) : null;
|
||||
this.tempEndDate = this.endDate ? new Date(this.endDate) : null;
|
||||
|
||||
// Calendar view month/year
|
||||
const today = new Date();
|
||||
if (this.endDate) {
|
||||
this.currentLeftMonth = this.endDate.getMonth();
|
||||
this.currentLeftYear = this.endDate.getFullYear();
|
||||
} else {
|
||||
this.currentLeftMonth = today.getMonth();
|
||||
this.currentLeftYear = today.getFullYear();
|
||||
}
|
||||
|
||||
this.isOpen = false;
|
||||
this._init();
|
||||
}
|
||||
|
||||
_init() {
|
||||
this._populateYearSelects();
|
||||
this._bindEvents();
|
||||
this._bindDisplayInputs();
|
||||
this._updateTriggerLabel();
|
||||
|
||||
// Listen to language changes
|
||||
document.addEventListener('languageChanged', () => {
|
||||
if (this.isOpen) {
|
||||
this._renderCalendars();
|
||||
}
|
||||
this._updateTriggerLabel();
|
||||
});
|
||||
}
|
||||
|
||||
_isEnglish() {
|
||||
if (typeof window.getLang === 'function') {
|
||||
const l = window.getLang();
|
||||
if (l === 'id' || l === 'en') return l === 'en';
|
||||
}
|
||||
try {
|
||||
const saved = localStorage.getItem('if_untan_lang');
|
||||
if (saved === 'id' || saved === 'en') return saved === 'en';
|
||||
} catch (e) {}
|
||||
const nav = (navigator.language || 'id').toLowerCase();
|
||||
return nav.startsWith('en');
|
||||
}
|
||||
|
||||
_clearTime(date) {
|
||||
if (!date) return null;
|
||||
const d = new Date(date);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
_datesEqual(d1, d2) {
|
||||
if (!d1 || !d2) return false;
|
||||
return d1.getFullYear() === d2.getFullYear() &&
|
||||
d1.getMonth() === d2.getMonth() &&
|
||||
d1.getDate() === d2.getDate();
|
||||
}
|
||||
|
||||
_getRightMonthYear() {
|
||||
let m = this.currentLeftMonth + 1;
|
||||
let y = this.currentLeftYear;
|
||||
if (m > 11) {
|
||||
m = 0;
|
||||
y += 1;
|
||||
}
|
||||
return { month: m, year: y };
|
||||
}
|
||||
|
||||
_navigateMonths(direction) {
|
||||
this.currentLeftMonth += direction;
|
||||
if (this.currentLeftMonth > 11) {
|
||||
this.currentLeftMonth = 0;
|
||||
this.currentLeftYear += 1;
|
||||
} else if (this.currentLeftMonth < 0) {
|
||||
this.currentLeftMonth = 11;
|
||||
this.currentLeftYear -= 1;
|
||||
}
|
||||
this._renderCalendars();
|
||||
}
|
||||
|
||||
_renderCalendars() {
|
||||
const isEn = this._isEnglish();
|
||||
const monthNames = isEn ?
|
||||
['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] :
|
||||
['Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'];
|
||||
|
||||
const dayHeaders = isEn ?
|
||||
['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] :
|
||||
['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min'];
|
||||
|
||||
if (this.leftMonthName) {
|
||||
this.leftMonthName.textContent = monthNames[this.currentLeftMonth];
|
||||
}
|
||||
if (this.leftYearSelect) {
|
||||
this.leftYearSelect.value = this.currentLeftYear;
|
||||
}
|
||||
|
||||
const rightMY = this._getRightMonthYear();
|
||||
if (this.rightMonthName) {
|
||||
this.rightMonthName.textContent = monthNames[rightMY.month];
|
||||
}
|
||||
if (this.rightYearSelect) {
|
||||
this.rightYearSelect.value = rightMY.year;
|
||||
}
|
||||
|
||||
const drawHeaders = (pane) => {
|
||||
if (!pane) return;
|
||||
const dayHeadersEl = pane.querySelector('.day-headers');
|
||||
if (!dayHeadersEl) return;
|
||||
dayHeadersEl.innerHTML = '';
|
||||
dayHeaders.forEach(day => {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = day;
|
||||
dayHeadersEl.appendChild(span);
|
||||
});
|
||||
};
|
||||
|
||||
drawHeaders(this.leftPane);
|
||||
drawHeaders(this.rightPane);
|
||||
|
||||
if (this.leftDaysContainer) {
|
||||
this._populateDaysContainer(this.leftDaysContainer, this.currentLeftMonth, this.currentLeftYear);
|
||||
}
|
||||
if (this.rightDaysContainer) {
|
||||
this._populateDaysContainer(this.rightDaysContainer, rightMY.month, rightMY.year);
|
||||
}
|
||||
|
||||
this._updateDisplayInputs();
|
||||
this._updatePresetSelectionVisuals();
|
||||
}
|
||||
|
||||
_populateDaysContainer(container, month, year) {
|
||||
container.innerHTML = '';
|
||||
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const numDays = new Date(year, month + 1, 0).getDate();
|
||||
|
||||
let offset = (firstDay.getDay() + 6) % 7;
|
||||
|
||||
for (let i = 0; i < offset; i++) {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'day-cell empty';
|
||||
container.appendChild(cell);
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(23, 59, 59, 999);
|
||||
|
||||
for (let day = 1; day <= numDays; day++) {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'day-cell';
|
||||
cell.textContent = day;
|
||||
|
||||
const cellDate = new Date(year, month, day);
|
||||
cellDate.setHours(0, 0, 0, 0);
|
||||
|
||||
if (cellDate > today) {
|
||||
cell.classList.add('disabled');
|
||||
} else {
|
||||
if (this.tempStartDate && this.tempEndDate) {
|
||||
const start = this._clearTime(this.tempStartDate);
|
||||
const end = this._clearTime(this.tempEndDate);
|
||||
|
||||
if (this._datesEqual(cellDate, start)) {
|
||||
cell.classList.add('range-start');
|
||||
}
|
||||
if (this._datesEqual(cellDate, end)) {
|
||||
cell.classList.add('range-end');
|
||||
}
|
||||
if (cellDate > start && cellDate < end) {
|
||||
cell.classList.add('range-between');
|
||||
}
|
||||
} else if (this.tempStartDate && this._datesEqual(cellDate, this._clearTime(this.tempStartDate))) {
|
||||
cell.classList.add('range-start');
|
||||
cell.classList.add('range-end');
|
||||
}
|
||||
|
||||
cell.addEventListener('click', () => {
|
||||
this._handleDayClick(cellDate);
|
||||
});
|
||||
}
|
||||
|
||||
container.appendChild(cell);
|
||||
}
|
||||
}
|
||||
|
||||
_handleDayClick(date) {
|
||||
if (!this.tempStartDate || (this.tempStartDate && this.tempEndDate)) {
|
||||
this.tempStartDate = new Date(date);
|
||||
this.tempEndDate = null;
|
||||
} else {
|
||||
if (date < this.tempStartDate) {
|
||||
this.tempStartDate = new Date(date);
|
||||
this.tempEndDate = null;
|
||||
} else {
|
||||
this.tempEndDate = new Date(date);
|
||||
}
|
||||
}
|
||||
this._renderCalendars();
|
||||
}
|
||||
|
||||
_formatDateDisplay(date) {
|
||||
if (!date) return '';
|
||||
const d = new Date(date);
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const yyyy = d.getFullYear();
|
||||
return `${dd}/${mm}/${yyyy}`;
|
||||
}
|
||||
|
||||
_updateDisplayInputs() {
|
||||
if (this.startDisplay) {
|
||||
this.startDisplay.value = this._formatDateDisplay(this.tempStartDate);
|
||||
}
|
||||
if (this.endDisplay) {
|
||||
this.endDisplay.value = this._formatDateDisplay(this.tempEndDate);
|
||||
}
|
||||
}
|
||||
|
||||
_calculatePreset(presetName) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
let start = null;
|
||||
let end = null;
|
||||
|
||||
switch (presetName) {
|
||||
case 'today':
|
||||
start = new Date(today);
|
||||
end = new Date(today);
|
||||
break;
|
||||
case 'yesterday':
|
||||
start = new Date(today);
|
||||
start.setDate(today.getDate() - 1);
|
||||
end = new Date(start);
|
||||
break;
|
||||
case 'last7':
|
||||
end = new Date(today);
|
||||
start = new Date(today);
|
||||
start.setDate(today.getDate() - 6);
|
||||
break;
|
||||
case 'last14':
|
||||
end = new Date(today);
|
||||
start = new Date(today);
|
||||
start.setDate(today.getDate() - 13);
|
||||
break;
|
||||
case 'last30':
|
||||
end = new Date(today);
|
||||
start = new Date(today);
|
||||
start.setDate(today.getDate() - 29);
|
||||
break;
|
||||
case 'thisWeek':
|
||||
end = new Date(today);
|
||||
start = new Date(today);
|
||||
const day = today.getDay();
|
||||
const diff = today.getDate() - day + (day === 0 ? -6 : 1);
|
||||
start.setDate(diff);
|
||||
break;
|
||||
case 'lastWeek':
|
||||
const temp = new Date(today);
|
||||
const tempDay = temp.getDay();
|
||||
const tempDiff = temp.getDate() - tempDay + (tempDay === 0 ? -6 : 1);
|
||||
start = new Date(temp);
|
||||
start.setDate(tempDiff - 7);
|
||||
end = new Date(temp);
|
||||
end.setDate(tempDiff - 1);
|
||||
break;
|
||||
case 'thisMonth':
|
||||
start = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
end = new Date(today);
|
||||
break;
|
||||
case 'lastMonth':
|
||||
start = new Date(today.getFullYear(), today.getMonth() - 1, 1);
|
||||
end = new Date(today.getFullYear(), today.getMonth(), 0);
|
||||
break;
|
||||
case 'all':
|
||||
default:
|
||||
start = null;
|
||||
end = null;
|
||||
break;
|
||||
}
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
_handlePresetClick(presetName) {
|
||||
const { start, end } = this._calculatePreset(presetName);
|
||||
this.tempStartDate = start;
|
||||
this.tempEndDate = end;
|
||||
|
||||
const today = new Date();
|
||||
if (this.tempEndDate) {
|
||||
this.currentLeftMonth = this.tempEndDate.getMonth();
|
||||
this.currentLeftYear = this.tempEndDate.getFullYear();
|
||||
} else {
|
||||
this.currentLeftMonth = today.getMonth();
|
||||
this.currentLeftYear = today.getFullYear();
|
||||
}
|
||||
this._renderCalendars();
|
||||
}
|
||||
|
||||
_updatePresetSelectionVisuals() {
|
||||
this.container.querySelectorAll('.preset-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
|
||||
if (!this.tempStartDate && !this.tempEndDate) {
|
||||
this.container.querySelector('[data-preset="all"]')?.classList.add('active');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.tempStartDate && this.tempEndDate) {
|
||||
const activePreset = ['today', 'yesterday', 'last7', 'last14', 'last30', 'thisWeek', 'lastWeek', 'thisMonth', 'lastMonth'].find(preset => {
|
||||
const { start, end } = this._calculatePreset(preset);
|
||||
return start && end &&
|
||||
this._datesEqual(this.tempStartDate, start) &&
|
||||
this._datesEqual(this.tempEndDate, end);
|
||||
});
|
||||
|
||||
if (activePreset) {
|
||||
this.container.querySelector(`[data-preset="${activePreset}"]`)?.classList.add('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_updateTriggerLabel() {
|
||||
if (!this.startDate && !this.endDate) {
|
||||
if (this.label) {
|
||||
this.label.textContent = window.tpT?.('filter_all_time') || 'Semua Waktu';
|
||||
this.label.setAttribute('data-tp-i18n', 'filter_all_time');
|
||||
}
|
||||
} else {
|
||||
if (this.label) {
|
||||
this.label.removeAttribute('data-tp-i18n');
|
||||
const startFormatted = this._formatDateDisplay(this.startDate);
|
||||
const endFormatted = this._formatDateDisplay(this.endDate);
|
||||
if (this._datesEqual(this.startDate, this.endDate)) {
|
||||
this.label.textContent = startFormatted;
|
||||
} else {
|
||||
this.label.textContent = `${startFormatted} - ${endFormatted}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_bindEvents() {
|
||||
this.trigger.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggle();
|
||||
});
|
||||
|
||||
this.popover.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
if (this.prevBtn) {
|
||||
this.prevBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._navigateMonths(-1);
|
||||
});
|
||||
}
|
||||
if (this.nextBtn) {
|
||||
this.nextBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._navigateMonths(1);
|
||||
});
|
||||
}
|
||||
|
||||
const presetsList = this.container.querySelector('.presets-list');
|
||||
if (presetsList) {
|
||||
presetsList.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.preset-btn');
|
||||
if (!btn) return;
|
||||
e.stopPropagation();
|
||||
const preset = btn.getAttribute('data-preset');
|
||||
this._handlePresetClick(preset);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.cancelBtn) {
|
||||
this.cancelBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._handleCancel();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.applyBtn) {
|
||||
this.applyBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._handleApply();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.backdrop) {
|
||||
this.backdrop.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._handleCancel();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', () => {
|
||||
if (this.isOpen) {
|
||||
this._handleCancel();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && this.isOpen) {
|
||||
this._handleCancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_bindDisplayInputs() {
|
||||
const parseDate = (val) => {
|
||||
const m = val.trim().match(/^(\d{1,2})[\/\-.](\d{1,2})[\/\-.](\d{4})$/);
|
||||
if (!m) return null;
|
||||
const d = parseInt(m[1], 10);
|
||||
const mon = parseInt(m[2], 10) - 1;
|
||||
const y = parseInt(m[3], 10);
|
||||
|
||||
if (mon < 0 || mon > 11 || d < 1 || d > 31 || y < 1000 || y > 9999) return null;
|
||||
|
||||
const testDate = new Date(y, mon, d);
|
||||
if (testDate.getFullYear() !== y || testDate.getMonth() !== mon || testDate.getDate() !== d) {
|
||||
return null;
|
||||
}
|
||||
return testDate;
|
||||
};
|
||||
|
||||
const setupInput = (input, isStart) => {
|
||||
if (!input) return;
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
const CTRL_KEYS = new Set(['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'Enter']);
|
||||
if (!/^\d$/.test(e.key) && e.key !== '/' && e.key !== '-' && e.key !== '.' && !CTRL_KEYS.has(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('input', (e) => {
|
||||
if (e.inputType && e.inputType.startsWith('delete')) {
|
||||
return;
|
||||
}
|
||||
|
||||
let val = input.value.replace(/[^\d]/g, '');
|
||||
let formatted = val;
|
||||
if (val.length > 2) {
|
||||
formatted = val.substring(0, 2) + '/' + val.substring(2);
|
||||
}
|
||||
if (val.length > 4) {
|
||||
formatted = val.substring(0, 2) + '/' + val.substring(2, 4) + '/' + val.substring(4, 8);
|
||||
}
|
||||
input.value = formatted;
|
||||
|
||||
const parsed = parseDate(formatted);
|
||||
if (parsed) {
|
||||
const today = new Date();
|
||||
today.setHours(23, 59, 59, 999);
|
||||
if (parsed <= today) {
|
||||
if (isStart) {
|
||||
this.tempStartDate = parsed;
|
||||
if (this.tempEndDate && this.tempEndDate < this.tempStartDate) {
|
||||
this.tempEndDate = new Date(this.tempStartDate);
|
||||
}
|
||||
} else {
|
||||
this.tempEndDate = parsed;
|
||||
if (this.tempStartDate && this.tempStartDate > this.tempEndDate) {
|
||||
this.tempStartDate = new Date(this.tempEndDate);
|
||||
}
|
||||
}
|
||||
|
||||
this.currentLeftMonth = parsed.getMonth();
|
||||
this.currentLeftYear = parsed.getFullYear();
|
||||
|
||||
const rightMY = this._getRightMonthYear();
|
||||
const isEn = this._isEnglish();
|
||||
const monthNames = isEn ?
|
||||
['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] :
|
||||
['Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'];
|
||||
if (this.leftMonthName) this.leftMonthName.textContent = monthNames[this.currentLeftMonth];
|
||||
if (this.leftYearSelect) this.leftYearSelect.value = this.currentLeftYear;
|
||||
if (this.rightMonthName) this.rightMonthName.textContent = monthNames[rightMY.month];
|
||||
if (this.rightYearSelect) this.rightYearSelect.value = rightMY.year;
|
||||
if (this.leftDaysContainer) this._populateDaysContainer(this.leftDaysContainer, this.currentLeftMonth, this.currentLeftYear);
|
||||
if (this.rightDaysContainer) this._populateDaysContainer(this.rightDaysContainer, rightMY.month, rightMY.year);
|
||||
this._updatePresetSelectionVisuals();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('blur', () => {
|
||||
const parsed = parseDate(input.value);
|
||||
if (!parsed) {
|
||||
if (isStart) {
|
||||
input.value = this._formatDateDisplay(this.tempStartDate);
|
||||
} else {
|
||||
input.value = this._formatDateDisplay(this.tempEndDate);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
setupInput(this.startDisplay, true);
|
||||
setupInput(this.endDisplay, false);
|
||||
}
|
||||
|
||||
_populateYearSelects() {
|
||||
if (!this.leftYearSelect || !this.rightYearSelect) return;
|
||||
|
||||
this.leftYearSelect.innerHTML = '';
|
||||
this.rightYearSelect.innerHTML = '';
|
||||
|
||||
const startYear = 2020;
|
||||
const endYear = new Date().getFullYear() + 4;
|
||||
|
||||
for (let y = startYear; y <= endYear; y++) {
|
||||
const optLeft = document.createElement('option');
|
||||
optLeft.value = y;
|
||||
optLeft.textContent = y;
|
||||
this.leftYearSelect.appendChild(optLeft);
|
||||
|
||||
const optRight = document.createElement('option');
|
||||
optRight.value = y;
|
||||
optRight.textContent = y;
|
||||
this.rightYearSelect.appendChild(optRight);
|
||||
}
|
||||
|
||||
this.leftYearSelect.addEventListener('change', () => {
|
||||
this._handleYearChange(parseInt(this.leftYearSelect.value, 10), true);
|
||||
});
|
||||
|
||||
this.rightYearSelect.addEventListener('change', () => {
|
||||
this._handleYearChange(parseInt(this.rightYearSelect.value, 10), false);
|
||||
});
|
||||
}
|
||||
|
||||
_handleYearChange(year, isLeft) {
|
||||
if (isLeft) {
|
||||
this.currentLeftYear = year;
|
||||
} else {
|
||||
const rightMY = this._getRightMonthYear();
|
||||
const rightMonth = rightMY.month;
|
||||
if (rightMonth === 0) {
|
||||
this.currentLeftYear = year - 1;
|
||||
} else {
|
||||
this.currentLeftYear = year;
|
||||
}
|
||||
}
|
||||
this._renderCalendars();
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.isOpen) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
document.querySelectorAll('.custom-dropdown.active').forEach(dd => {
|
||||
dd.classList.remove('active');
|
||||
dd.querySelector('.custom-dropdown-trigger')?.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
this.isOpen = true;
|
||||
this.container.classList.add('active');
|
||||
this.trigger.setAttribute('aria-expanded', 'true');
|
||||
|
||||
this.tempStartDate = this.startDate ? new Date(this.startDate) : null;
|
||||
this.tempEndDate = this.endDate ? new Date(this.endDate) : null;
|
||||
|
||||
const today = new Date();
|
||||
if (this.endDate) {
|
||||
this.currentLeftMonth = this.endDate.getMonth();
|
||||
this.currentLeftYear = this.endDate.getFullYear();
|
||||
} else {
|
||||
this.currentLeftMonth = today.getMonth();
|
||||
this.currentLeftYear = today.getFullYear();
|
||||
}
|
||||
|
||||
this._renderCalendars();
|
||||
|
||||
if (window.innerWidth <= 768) {
|
||||
document.body.classList.add('date-picker-open');
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.container.classList.remove('active');
|
||||
this.trigger.setAttribute('aria-expanded', 'false');
|
||||
document.body.classList.remove('date-picker-open');
|
||||
}
|
||||
|
||||
_handleCancel() {
|
||||
this.tempStartDate = this.startDate ? new Date(this.startDate) : null;
|
||||
this.tempEndDate = this.endDate ? new Date(this.endDate) : null;
|
||||
this.close();
|
||||
}
|
||||
|
||||
_handleApply() {
|
||||
this.startDate = this.tempStartDate;
|
||||
this.endDate = this.tempEndDate;
|
||||
|
||||
this._updateTriggerLabel();
|
||||
this.close();
|
||||
|
||||
const startStr = this._getDateString(this.startDate);
|
||||
const endStr = this._getDateString(this.endDate);
|
||||
if (this.onChange) {
|
||||
this.onChange(startStr, endStr);
|
||||
}
|
||||
}
|
||||
|
||||
_getDateString(date) {
|
||||
if (!date) return '';
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { DirectusClient } from '../services/directus-client.js';
|
||||
import { FileDownloader } from '../services/file-downloader.js';
|
||||
import { DocumentFormatter } from '../utils/document-formatter.js';
|
||||
|
||||
export class DocumentCardView {
|
||||
constructor(item) {
|
||||
this.item = item;
|
||||
this.title = item.nama_berkas;
|
||||
this.tags = DocumentFormatter.parseTags(item.tag);
|
||||
this.category = DocumentFormatter.categoryFromTags(this.tags);
|
||||
this.fileType = DocumentFormatter.fileTypeFromName(this.title);
|
||||
this.assetUrl = DirectusClient.assetUrl(item.file);
|
||||
this.viewerUrl = DirectusClient.viewerUrl(item);
|
||||
this.downloadName = DocumentFormatter.downloadFilename(this.title, this.fileType);
|
||||
this.dateIso = item.tanggal_upload || '';
|
||||
this.dateKey = this.dateIso ? this.dateIso.substring(0, 10) : '';
|
||||
}
|
||||
|
||||
render() {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'document-card';
|
||||
card.dataset.date = this.dateKey;
|
||||
|
||||
const typeUpper = this.fileType.toUpperCase();
|
||||
const typeClass = this.fileType === 'pdf' ? 'doc-type-pdf' : 'doc-type-docx';
|
||||
const iconClass = this.fileType === 'pdf' ? 'fas fa-file-pdf' : 'fas fa-file-word';
|
||||
const formattedDate = DocumentFormatter.formatDate(this.dateIso);
|
||||
|
||||
const safeTitle = DocumentFormatter.escapeHtml(this.title);
|
||||
const safeViewerUrl = DocumentFormatter.escapeAttr(this.viewerUrl);
|
||||
const safeAssetUrl = DocumentFormatter.escapeAttr(this.assetUrl);
|
||||
|
||||
// Build tag badges HTML.
|
||||
const tagBadgesHtml = this.tags.length
|
||||
? `<div class="doc-tags"><i class="fas fa-tags doc-tags-icon"></i>${this.tags.map(t => `<span class="doc-tag">${DocumentFormatter.escapeHtml(t)}</span>`).join('')}</div>`
|
||||
: '';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="doc-type-icon ${typeClass}"><i class="${iconClass}"></i></div>
|
||||
<h3 class="doc-title">${safeTitle}</h3>
|
||||
<div class="doc-meta">
|
||||
<span class="doc-badge">${typeUpper}</span>
|
||||
<span class="doc-badge date"><i class="far fa-calendar-alt"></i> ${formattedDate}</span>
|
||||
</div>
|
||||
${tagBadgesHtml}
|
||||
<div class="doc-actions">
|
||||
<a href="${safeViewerUrl}" class="btn-action-primary" target="_blank" rel="noopener"><span data-tp-i18n="card_open">Buka Berkas</span> <i class="fas fa-external-link-alt"></i></a>
|
||||
<a href="${safeAssetUrl}" class="btn-action-outline"><span data-tp-i18n="card_download">Unduh Berkas</span> <i class="fas fa-download"></i></a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
card.querySelector('.btn-action-outline').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
FileDownloader.download(this.assetUrl, this.downloadName);
|
||||
});
|
||||
|
||||
// Prefetch the asset when the user hovers over the card so the
|
||||
// browser cache already has the file by the time the viewer loads.
|
||||
let prefetched = false;
|
||||
card.addEventListener('mouseenter', () => {
|
||||
if (prefetched) return;
|
||||
prefetched = true;
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'prefetch';
|
||||
link.as = 'fetch';
|
||||
link.crossOrigin = 'anonymous';
|
||||
link.href = this.assetUrl;
|
||||
document.head.appendChild(link);
|
||||
}, { once: true });
|
||||
|
||||
return card;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export class AccessibilityToggleController {
|
||||
constructor({
|
||||
triggerSelector = '[data-accessibility-toggle]',
|
||||
panelId = 'access-panel',
|
||||
} = {}) {
|
||||
this.triggers = Array.from(document.querySelectorAll(triggerSelector));
|
||||
this.panel = document.getElementById(panelId);
|
||||
}
|
||||
|
||||
init() {
|
||||
if (this.triggers.length === 0) return;
|
||||
this.triggers.forEach(trigger => {
|
||||
trigger.addEventListener('click', () => this.toggle());
|
||||
});
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (!this.panel) return;
|
||||
this.panel.classList.toggle('hidden');
|
||||
this.panel.classList.toggle('flex');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
import { DirectusClient } from '../services/directus-client.js';
|
||||
import { DocumentCardView } from '../components/document-card-view.js';
|
||||
import { CustomDropdown } from '../components/custom-dropdown.js';
|
||||
import { DateRangePicker } from '../components/date-range-picker.js';
|
||||
import { CounterAnimation } from '../components/counter-animation.js';
|
||||
|
||||
export class DocumentRepository {
|
||||
static PAGE_SIZE = 6;
|
||||
|
||||
constructor() {
|
||||
this.gridContainer = document.getElementById('document-grid');
|
||||
this.filterWrapper = document.getElementById('tag-filter-wrapper');
|
||||
this.mobileFilterWrapper = document.getElementById('mobile-tag-filter-wrapper');
|
||||
this.searchInput = document.getElementById('doc-search');
|
||||
this.sortFilterWrapper = document.getElementById('sort-filter-wrapper');
|
||||
|
||||
// Hero stat values (filled from real API data after fetch).
|
||||
this.statActiveEl = document.getElementById('stat-active-value');
|
||||
this.statUpdatesEl = document.getElementById('stat-updates-value');
|
||||
|
||||
this.cards = [];
|
||||
this.paginationContainer = null;
|
||||
this.state = {
|
||||
filter: 'all', // 'all' or a specific tag string
|
||||
search: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
sort: 'newest',
|
||||
page: 1,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
async init() {
|
||||
if (!this.gridContainer) return;
|
||||
this._bindControls();
|
||||
this._initPagination();
|
||||
try {
|
||||
const items = await DirectusClient.fetchPublished('berkas');
|
||||
this._renderCards(items);
|
||||
this._buildCategoryFilters(items);
|
||||
this._renderStats(items);
|
||||
this._applySort();
|
||||
this._applyFilters();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
this._renderError();
|
||||
this._renderStatsError();
|
||||
}
|
||||
}
|
||||
|
||||
_initPagination() {
|
||||
const pag = document.createElement('div');
|
||||
pag.id = 'doc-pagination';
|
||||
pag.className = 'doc-pagination';
|
||||
this.gridContainer.insertAdjacentElement('afterend', pag);
|
||||
this.paginationContainer = pag;
|
||||
}
|
||||
|
||||
// Days within which an upload counts as a "recent update".
|
||||
static RECENT_DAYS = 30;
|
||||
|
||||
// Populate the hero stats from real data:
|
||||
// Dokumen Aktif → number of published documents
|
||||
// Pembaruan Terbaru → uploads within the last RECENT_DAYS days
|
||||
_renderStats(items) {
|
||||
const active = items.length;
|
||||
|
||||
const windowMs = DocumentRepository.RECENT_DAYS * 24 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const recent = items.filter(item => {
|
||||
if (!item.tanggal_upload) return false;
|
||||
const t = new Date(item.tanggal_upload).getTime();
|
||||
return !Number.isNaN(t) && now - t <= windowMs;
|
||||
}).length;
|
||||
|
||||
CounterAnimation.animateValue(this.statActiveEl, active);
|
||||
CounterAnimation.animateValue(this.statUpdatesEl, recent);
|
||||
}
|
||||
|
||||
_renderStatsError() {
|
||||
if (this.statActiveEl) this.statActiveEl.textContent = '—';
|
||||
if (this.statUpdatesEl) this.statUpdatesEl.textContent = '—';
|
||||
}
|
||||
/**
|
||||
* Fixed category definitions for the sidebar filter.
|
||||
* Each has a key, an i18n key for bilingual labels, and a fallback label.
|
||||
*/
|
||||
static CATEGORIES = [
|
||||
{ key: 'all', i18n: 'filter_all', label: 'Semua Berkas' },
|
||||
{ key: 'skripsi', i18n: 'filter_skripsi', label: 'Panduan Skripsi' },
|
||||
{ key: 'akademik', i18n: 'filter_akademik', label: 'Kalender Akademik' },
|
||||
{ key: 'praktik', i18n: 'filter_praktik', label: 'Kerja Praktik' },
|
||||
{ key: 'umum', i18n: 'filter_umum', label: 'Formulir Umum' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Build category filter controls:
|
||||
* – Desktop: sidebar buttons (hidden on mobile via CSS)
|
||||
* – Mobile: a <select> dropdown (hidden on desktop via CSS)
|
||||
* Both stay in sync through a shared _setFilter() helper.
|
||||
*/
|
||||
_buildCategoryFilters(items) {
|
||||
if (!this.filterWrapper) return;
|
||||
|
||||
// Clear skeleton placeholder.
|
||||
this.filterWrapper.innerHTML = '';
|
||||
|
||||
// ── Desktop buttons ──
|
||||
const btnGroup = document.createElement('div');
|
||||
btnGroup.className = 'filter-btn-group';
|
||||
|
||||
DocumentRepository.CATEGORIES.forEach(({ key, i18n, label }) => {
|
||||
const isActive = key === 'all';
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'doc-filter-btn' + (isActive ? ' active' : '');
|
||||
btn.dataset.filter = key;
|
||||
btn.textContent = (window.tpT?.(i18n)) || label;
|
||||
btn.setAttribute('data-tp-i18n', i18n);
|
||||
|
||||
btn.addEventListener('click', () => this._setFilter(key));
|
||||
btnGroup.appendChild(btn);
|
||||
});
|
||||
|
||||
this.filterWrapper.appendChild(btnGroup);
|
||||
|
||||
// ── Mobile Custom UI Dropdown ──
|
||||
if (this.mobileFilterWrapper) {
|
||||
const dropdownOptions = DocumentRepository.CATEGORIES.map(({ key, i18n, label }) => ({
|
||||
value: key,
|
||||
i18nKey: i18n,
|
||||
text: (window.tpT?.(i18n)) || label
|
||||
}));
|
||||
|
||||
this._mobileDropdown = new CustomDropdown({
|
||||
container: this.mobileFilterWrapper,
|
||||
options: dropdownOptions,
|
||||
defaultValue: this.state.filter,
|
||||
onChange: (value) => this._setFilter(value)
|
||||
});
|
||||
}
|
||||
|
||||
// Store refs for syncing.
|
||||
this._filterBtnGroup = btnGroup;
|
||||
|
||||
// Re-translate if i18n is active.
|
||||
window.applyTp?.(this.filterWrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared filter setter — keeps buttons + dropdown in sync.
|
||||
*/
|
||||
_setFilter(key) {
|
||||
this.state.filter = key;
|
||||
this.state.page = 1;
|
||||
|
||||
// Sync buttons.
|
||||
if (this._filterBtnGroup) {
|
||||
this._filterBtnGroup.querySelectorAll('.doc-filter-btn')
|
||||
.forEach(b => b.classList.toggle('active', b.dataset.filter === key));
|
||||
}
|
||||
|
||||
// Sync custom dropdown.
|
||||
if (this._mobileDropdown && this._mobileDropdown.getValue() !== key) {
|
||||
this._mobileDropdown.setValue(key);
|
||||
}
|
||||
|
||||
this._applyFilters();
|
||||
}
|
||||
|
||||
_bindControls() {
|
||||
this.searchInput?.addEventListener('input', (e) => {
|
||||
this.state.search = e.target.value.toLowerCase().trim();
|
||||
this.state.page = 1;
|
||||
this._applyFilters();
|
||||
});
|
||||
|
||||
// Initialize Custom Date Range Picker
|
||||
const datePickerGroup = document.getElementById('date-range-control-group');
|
||||
if (datePickerGroup) {
|
||||
this.dateRangePicker = new DateRangePicker({
|
||||
containerId: 'date-range-control-group',
|
||||
onChange: (startDate, endDate) => {
|
||||
this.state.startDate = startDate;
|
||||
this.state.endDate = endDate;
|
||||
this.state.page = 1;
|
||||
this._applyFilters();
|
||||
}
|
||||
});
|
||||
window.applyTp?.(datePickerGroup);
|
||||
}
|
||||
|
||||
if (this.sortFilterWrapper) {
|
||||
const sortOptions = [
|
||||
{ value: 'newest', i18nKey: 'sort_newest', text: (window.tpT?.('sort_newest')) || 'Terbaru' },
|
||||
{ value: 'oldest', i18nKey: 'sort_oldest', text: (window.tpT?.('sort_oldest')) || 'Terlama' },
|
||||
{ value: 'az', i18nKey: 'sort_az', text: (window.tpT?.('sort_az')) || 'Nama A-Z' }
|
||||
];
|
||||
|
||||
this._sortDropdown = new CustomDropdown({
|
||||
container: this.sortFilterWrapper,
|
||||
options: sortOptions,
|
||||
defaultValue: this.state.sort,
|
||||
prefixIcon: 'fas fa-sort-amount-down',
|
||||
onChange: (value) => {
|
||||
this.state.sort = value;
|
||||
this.state.page = 1;
|
||||
this._applySort();
|
||||
this._applyFilters();
|
||||
}
|
||||
});
|
||||
|
||||
window.applyTp?.(this.sortFilterWrapper);
|
||||
}
|
||||
}
|
||||
|
||||
_renderCards(items) {
|
||||
this.gridContainer.innerHTML = '';
|
||||
|
||||
if (items.length === 0) {
|
||||
this._renderState('repo-empty', 'fas fa-folder-open', '#cbd5e1', '#64748b',
|
||||
'state_empty', 'Belum ada dokumen yang tersedia di repositori.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.cards = items.map(item => {
|
||||
const view = new DocumentCardView(item);
|
||||
const element = view.render();
|
||||
this.gridContainer.appendChild(element);
|
||||
return { view, element };
|
||||
});
|
||||
|
||||
// Translate the freshly-rendered card labels (Buka/Unduh Berkas).
|
||||
window.applyTp?.(this.gridContainer);
|
||||
}
|
||||
|
||||
_renderError() {
|
||||
this._renderState('repo-error', 'fas fa-exclamation-triangle', '#f87171', '#ef4444',
|
||||
'state_error', 'Gagal memuat berkas. Silakan periksa koneksi internet Anda atau coba lagi nanti.');
|
||||
}
|
||||
|
||||
// Build the empty/error placeholder via safe DOM APIs (no innerHTML).
|
||||
// `i18nKey` lets the Tapops i18n layer re-translate the message on toggle.
|
||||
_renderState(className, iconClass, iconColor, textColor, i18nKey, message) {
|
||||
this.cards = [];
|
||||
this.gridContainer.innerHTML = '';
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = className;
|
||||
Object.assign(wrapper.style, {
|
||||
gridColumn: '1 / -1',
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: textColor,
|
||||
fontWeight: '700',
|
||||
fontSize: '1rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '15px',
|
||||
});
|
||||
|
||||
const icon = document.createElement('i');
|
||||
icon.className = iconClass;
|
||||
Object.assign(icon.style, { fontSize: '3rem', color: iconColor });
|
||||
|
||||
const span = document.createElement('span');
|
||||
if (i18nKey) span.setAttribute('data-tp-i18n', i18nKey);
|
||||
span.textContent = (window.tpT?.(i18nKey)) || message;
|
||||
|
||||
wrapper.appendChild(icon);
|
||||
wrapper.appendChild(span);
|
||||
this.gridContainer.appendChild(wrapper);
|
||||
}
|
||||
|
||||
_applyFilters() {
|
||||
const matched = this.cards.filter(({ view }) => this._cardMatches(view));
|
||||
const total = matched.length;
|
||||
|
||||
// Handle zero-results state when cards exist but none match the filter.
|
||||
this._toggleNoResultsState(total === 0 && this.cards.length > 0);
|
||||
|
||||
if (total === 0) {
|
||||
this.cards.forEach(({ element }) => { element.style.display = 'none'; });
|
||||
this._renderPagination(0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / DocumentRepository.PAGE_SIZE);
|
||||
|
||||
// Clamp page to valid range after filter changes.
|
||||
this.state.page = Math.min(Math.max(this.state.page, 1), totalPages);
|
||||
|
||||
const start = (this.state.page - 1) * DocumentRepository.PAGE_SIZE;
|
||||
const pageSet = new Set(
|
||||
matched.slice(start, start + DocumentRepository.PAGE_SIZE).map(c => c.element)
|
||||
);
|
||||
|
||||
this.cards.forEach(({ element }) => {
|
||||
const show = pageSet.has(element);
|
||||
element.style.display = show ? 'flex' : 'none';
|
||||
if (show) {
|
||||
setTimeout(() => {
|
||||
element.style.opacity = '1';
|
||||
element.style.transform = 'translateY(0)';
|
||||
}, 10);
|
||||
} else {
|
||||
element.style.opacity = '0';
|
||||
element.style.transform = 'translateY(10px)';
|
||||
}
|
||||
});
|
||||
|
||||
this._renderPagination(totalPages, total);
|
||||
}
|
||||
|
||||
_toggleNoResultsState(show) {
|
||||
const existing = this.gridContainer.querySelector('.repo-no-results');
|
||||
if (show && !existing) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'repo-no-results';
|
||||
Object.assign(wrapper.style, {
|
||||
gridColumn: '1 / -1',
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: '#64748b',
|
||||
fontWeight: '700',
|
||||
fontSize: '1rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '15px',
|
||||
});
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'fas fa-search';
|
||||
Object.assign(icon.style, { fontSize: '3rem', color: '#cbd5e1' });
|
||||
const span = document.createElement('span');
|
||||
span.setAttribute('data-tp-i18n', 'state_no_results');
|
||||
span.textContent = (window.tpT?.('state_no_results')) || 'Tidak ada berkas yang cocok dengan filter saat ini.';
|
||||
wrapper.appendChild(icon);
|
||||
wrapper.appendChild(span);
|
||||
this.gridContainer.appendChild(wrapper);
|
||||
} else if (!show && existing) {
|
||||
existing.remove();
|
||||
}
|
||||
}
|
||||
|
||||
_renderPagination(totalPages, total) {
|
||||
if (!this.paginationContainer) return;
|
||||
this.paginationContainer.innerHTML = '';
|
||||
if (total === 0 || totalPages <= 1) return;
|
||||
|
||||
const current = this.state.page;
|
||||
const start = (current - 1) * DocumentRepository.PAGE_SIZE + 1;
|
||||
const end = Math.min(current * DocumentRepository.PAGE_SIZE, total);
|
||||
|
||||
const info = document.createElement('span');
|
||||
info.className = 'pagination-info';
|
||||
info.textContent = `${start}–${end} dari ${total} berkas`;
|
||||
this.paginationContainer.appendChild(info);
|
||||
|
||||
const nav = document.createElement('nav');
|
||||
nav.className = 'pagination-nav';
|
||||
nav.setAttribute('aria-label', 'Navigasi halaman dokumen');
|
||||
|
||||
const prev = document.createElement('button');
|
||||
prev.className = 'pagination-btn' + (current <= 1 ? ' disabled' : '');
|
||||
prev.disabled = current <= 1;
|
||||
prev.setAttribute('aria-label', 'Halaman sebelumnya');
|
||||
const prevIcon = document.createElement('i');
|
||||
prevIcon.className = 'fas fa-chevron-left';
|
||||
prev.appendChild(prevIcon);
|
||||
prev.addEventListener('click', () => {
|
||||
if (this.state.page > 1) { this.state.page--; this._applyFilters(); this._scrollToGrid(); }
|
||||
});
|
||||
nav.appendChild(prev);
|
||||
|
||||
this._paginationRange(current, totalPages).forEach(p => {
|
||||
if (p === '...') {
|
||||
const el = document.createElement('span');
|
||||
el.className = 'pagination-ellipsis';
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
el.textContent = '…';
|
||||
nav.appendChild(el);
|
||||
} else {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'pagination-btn pagination-number' + (p === current ? ' active' : '');
|
||||
btn.textContent = p;
|
||||
btn.setAttribute('aria-label', `Halaman ${p}`);
|
||||
if (p === current) btn.setAttribute('aria-current', 'page');
|
||||
btn.addEventListener('click', () => {
|
||||
this.state.page = p; this._applyFilters(); this._scrollToGrid();
|
||||
});
|
||||
nav.appendChild(btn);
|
||||
}
|
||||
});
|
||||
|
||||
const next = document.createElement('button');
|
||||
next.className = 'pagination-btn' + (current >= totalPages ? ' disabled' : '');
|
||||
next.disabled = current >= totalPages;
|
||||
next.setAttribute('aria-label', 'Halaman berikutnya');
|
||||
const nextIcon = document.createElement('i');
|
||||
nextIcon.className = 'fas fa-chevron-right';
|
||||
next.appendChild(nextIcon);
|
||||
next.addEventListener('click', () => {
|
||||
if (this.state.page < totalPages) { this.state.page++; this._applyFilters(); this._scrollToGrid(); }
|
||||
});
|
||||
nav.appendChild(next);
|
||||
|
||||
this.paginationContainer.appendChild(nav);
|
||||
}
|
||||
|
||||
// Returns page numbers and '...' ellipsis placeholders for the given range.
|
||||
_paginationRange(current, total) {
|
||||
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
|
||||
|
||||
const pages = [1];
|
||||
const lo = Math.max(2, current - 1);
|
||||
const hi = Math.min(total - 1, current + 1);
|
||||
|
||||
if (lo > 2) pages.push('...');
|
||||
for (let i = lo; i <= hi; i++) pages.push(i);
|
||||
if (hi < total - 1) pages.push('...');
|
||||
pages.push(total);
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
_scrollToGrid() {
|
||||
document.getElementById('repository')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
_cardMatches(view) {
|
||||
const { filter, search } = this.state;
|
||||
const matchesCategory = filter === 'all' || view.category === filter;
|
||||
const matchesTitle = view.title.toLowerCase().includes(search);
|
||||
const matchesTags = search
|
||||
? view.tags.some(t => t.toLowerCase().includes(search))
|
||||
: false;
|
||||
const matchesSearch = matchesTitle || matchesTags;
|
||||
return matchesCategory && matchesSearch && this._dateMatches(view.dateKey);
|
||||
}
|
||||
|
||||
_dateMatches(dateKey) {
|
||||
const { startDate, endDate } = this.state;
|
||||
if (!dateKey) return !startDate && !endDate;
|
||||
const cardDate = new Date(dateKey);
|
||||
if (startDate && cardDate < new Date(startDate)) return false;
|
||||
if (endDate) {
|
||||
const end = new Date(endDate);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
if (cardDate > end) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_applySort() {
|
||||
const { sort } = this.state;
|
||||
this.cards.sort((a, b) => {
|
||||
if (sort === 'newest') return b.view.dateKey.localeCompare(a.view.dateKey);
|
||||
if (sort === 'oldest') return a.view.dateKey.localeCompare(b.view.dateKey);
|
||||
if (sort === 'az') return a.view.title.toLowerCase().localeCompare(b.view.title.toLowerCase());
|
||||
return 0;
|
||||
});
|
||||
this.cards.forEach(({ element }) => this.gridContainer.appendChild(element));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export class MobileMenuController {
|
||||
constructor({
|
||||
buttonId = 'mobile-menu-button',
|
||||
menuId = 'mobile-menu',
|
||||
closeButtonId = 'close-mobile-menu',
|
||||
} = {}) {
|
||||
this.button = document.getElementById(buttonId);
|
||||
this.menu = document.getElementById(menuId);
|
||||
this.closeButton = document.getElementById(closeButtonId);
|
||||
}
|
||||
|
||||
init() {
|
||||
if (!this.button || !this.menu) return;
|
||||
|
||||
this.button.addEventListener('click', () => this.toggle());
|
||||
this.closeButton?.addEventListener('click', () => this.close());
|
||||
}
|
||||
|
||||
toggle() {
|
||||
const isOpen = !this.menu.classList.contains('-translate-x-full');
|
||||
this.menu.classList.toggle('-translate-x-full');
|
||||
this.button.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
|
||||
}
|
||||
|
||||
close() {
|
||||
this.menu.classList.add('-translate-x-full');
|
||||
this.button?.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
export class NavigationController {
|
||||
constructor({
|
||||
menuSelector = '[data-menu]',
|
||||
overlayId = 'mega-overlay',
|
||||
} = {}) {
|
||||
this.menuItems = Array.from(document.querySelectorAll(menuSelector));
|
||||
this.overlay = document.getElementById(overlayId);
|
||||
this.activeMenu = null;
|
||||
this.menuMap = {
|
||||
tentang: 'mega-tentang',
|
||||
program: 'mega-program',
|
||||
sdm: 'mega-sdm',
|
||||
fasilitas: 'mega-fasilitas',
|
||||
karya: 'mega-karya',
|
||||
kontak: 'mega-kontak',
|
||||
};
|
||||
}
|
||||
|
||||
init() {
|
||||
if (this.menuItems.length === 0 || !this.overlay) return;
|
||||
|
||||
this.menuItems.forEach(item => {
|
||||
const menuId = item.getAttribute('data-menu');
|
||||
const button = item.querySelector('.nav-menu-btn');
|
||||
if (!button) return;
|
||||
|
||||
button.setAttribute('aria-haspopup', 'true');
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
button.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (this.activeMenu === menuId) {
|
||||
this.closeMenus();
|
||||
} else {
|
||||
this.openMenu(menuId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.overlay.addEventListener('click', () => this.closeMenus());
|
||||
document.addEventListener('click', event => this._handleOutsideClick(event));
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape' && this.activeMenu) this.closeMenus();
|
||||
});
|
||||
}
|
||||
|
||||
openMenu(menuId) {
|
||||
const targetPanel = document.getElementById(this.menuMap[menuId]);
|
||||
if (!targetPanel) return;
|
||||
|
||||
Object.values(this.menuMap).forEach(panelId => {
|
||||
document.getElementById(panelId)?.classList.remove('active');
|
||||
});
|
||||
|
||||
targetPanel.classList.add('active');
|
||||
this.overlay.classList.add('active');
|
||||
this._setButtonState(menuId);
|
||||
this.activeMenu = menuId;
|
||||
}
|
||||
|
||||
closeMenus() {
|
||||
Object.values(this.menuMap).forEach(panelId => {
|
||||
document.getElementById(panelId)?.classList.remove('active');
|
||||
});
|
||||
|
||||
this.overlay.classList.remove('active');
|
||||
this._setButtonState(null);
|
||||
this.activeMenu = null;
|
||||
}
|
||||
|
||||
_setButtonState(menuId) {
|
||||
this.menuItems.forEach(item => {
|
||||
const isActive = item.getAttribute('data-menu') === menuId;
|
||||
const button = item.querySelector('.nav-menu-btn');
|
||||
const chevron = item.querySelector('.fa-chevron-down');
|
||||
|
||||
button?.classList.toggle('active', isActive);
|
||||
button?.setAttribute('aria-expanded', isActive ? 'true' : 'false');
|
||||
if (chevron) chevron.style.transform = isActive ? 'rotate(180deg)' : '';
|
||||
});
|
||||
}
|
||||
|
||||
_handleOutsideClick(event) {
|
||||
const clickedInsideNav = event.target.closest('#main-nav');
|
||||
const clickedInsidePanel = event.target.closest('.mega-panel');
|
||||
if (!clickedInsideNav && !clickedInsidePanel && this.activeMenu) this.closeMenus();
|
||||
}
|
||||
}
|
||||
+1
-1599
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
export class PaginationModel {
|
||||
static range(current, total) {
|
||||
if (total <= 7) return Array.from({ length: total }, (_, index) => index + 1);
|
||||
|
||||
const pages = [1];
|
||||
const low = Math.max(2, current - 1);
|
||||
const high = Math.min(total - 1, current + 1);
|
||||
|
||||
if (low > 2) pages.push('...');
|
||||
for (let page = low; page <= high; page += 1) pages.push(page);
|
||||
if (high < total - 1) pages.push('...');
|
||||
pages.push(total);
|
||||
|
||||
return pages;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { DocumentFormatter } from '../utils/document-formatter.js';
|
||||
|
||||
export class DirectusClient {
|
||||
static API_BASE = 'https://api.ifuntanhub.dev';
|
||||
|
||||
static itemsUrl(collection) {
|
||||
return `${this.API_BASE}/items/${collection}?limit=-1`;
|
||||
}
|
||||
|
||||
static assetUrl(uuid) {
|
||||
return `${this.API_BASE}/assets/${uuid}`;
|
||||
}
|
||||
|
||||
static viewerUrl(item) {
|
||||
const type = DocumentFormatter.fileTypeFromName(item.nama_berkas);
|
||||
const params = new URLSearchParams({
|
||||
id: item.file,
|
||||
name: item.nama_berkas,
|
||||
type,
|
||||
});
|
||||
return `viewer.html?${params.toString()}`;
|
||||
}
|
||||
|
||||
static async fetchPublished(collection) {
|
||||
const response = await fetch(this.itemsUrl(collection));
|
||||
if (!response.ok) throw new Error(`Gagal memuat koleksi ${collection}.`);
|
||||
const body = await response.json();
|
||||
const items = body.data || [];
|
||||
return items.filter(item => item.status === 'published');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export class FileDownloader {
|
||||
static async download(url, filename) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Gagal mengunduh berkas.');
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
this._click(blobUrl, filename);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
} catch (err) {
|
||||
console.warn('Unduh via Blob gagal, beralih ke parameter Directus:', err);
|
||||
const fallbackUrl = url.includes('?') ? `${url}&download` : `${url}?download`;
|
||||
this._click(fallbackUrl, filename);
|
||||
}
|
||||
}
|
||||
|
||||
static _click(href, filename) {
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = href;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
export function footerTemplate() {
|
||||
return ` <footer class="bg-untan-navy text-white pt-16 relative overflow-hidden mt-0">
|
||||
<div class="container mx-auto px-4 lg:px-8 relative z-10 flex flex-col items-center justify-center text-center">
|
||||
|
||||
<h3 class="text-xs font-bold text-slate-300 uppercase tracking-widest mb-4" data-i18n="footer_follow_us">
|
||||
Ikuti Kami</h3>
|
||||
<div class="flex items-center justify-center gap-5 sm:gap-6 mb-10">
|
||||
<a href="https://instagram.com/informatika.untan" target="_blank" rel="noopener noreferrer"
|
||||
class="text-white hover:text-untan-yellow transition-colors"><i
|
||||
class="fab fa-instagram text-xl sm:text-2xl"></i></a>
|
||||
<a href="https://instagram.com/informatika.untan" target="_blank" rel="noopener noreferrer"
|
||||
class="text-white hover:text-untan-yellow transition-colors"><i
|
||||
class="fab fa-youtube text-xl sm:text-2xl"></i></a>
|
||||
<a href="https://instagram.com/informatika.untan" target="_blank" rel="noopener noreferrer"
|
||||
class="text-white hover:text-untan-yellow transition-colors"><i
|
||||
class="fab fa-facebook-f text-xl sm:text-2xl"></i></a>
|
||||
<a href="https://instagram.com/informatika.untan" target="_blank" rel="noopener noreferrer"
|
||||
class="text-white hover:text-untan-yellow transition-colors"><i
|
||||
class="fab fa-twitter text-xl sm:text-2xl"></i></a>
|
||||
<a href="https://instagram.com/informatika.untan" target="_blank" rel="noopener noreferrer"
|
||||
class="text-white hover:text-untan-yellow transition-colors"><i
|
||||
class="fab fa-linkedin-in text-xl sm:text-2xl"></i></a>
|
||||
<a href="https://instagram.com/informatika.untan" target="_blank" rel="noopener noreferrer"
|
||||
class="text-white hover:text-untan-yellow transition-colors"><i
|
||||
class="fab fa-tiktok text-xl sm:text-2xl"></i></a>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-4 sm:gap-6 mb-8 text-left">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/id/0/03/Lambang_Universitas_Tanjungpura.png"
|
||||
class="h-16 sm:h-20 shrink-0" alt="Logo UNTAN">
|
||||
<div
|
||||
class="border-t sm:border-t-0 sm:border-l-2 border-white/30 pt-4 sm:pt-0 sm:pl-5 py-1 flex flex-col justify-center leading-tight">
|
||||
<h2 class="text-2xl sm:text-3xl font-bold font-sans tracking-widest uppercase mb-1">INFORMATIKA</h2>
|
||||
<h2 class="text-xs sm:text-sm font-normal text-slate-200 tracking-widest uppercase mt-0.5">
|
||||
UNIVERSITAS TANJUNGPURA</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-5 text-sm sm:text-base text-slate-100 max-w-2xl font-light">
|
||||
<p class="flex flex-col sm:flex-row items-center justify-center gap-2 mb-1">
|
||||
<i class="fas fa-map-marker-alt text-untan-yellow"></i>
|
||||
Jl. Prof. Dr. H. Hadari Nawawi, Bansir Laut, Kec. Pontianak Tenggara,
|
||||
</p>
|
||||
<p>Kota Pontianak, Kalimantan Barat 78124</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="text-xs sm:text-sm text-slate-200 flex flex-wrap justify-center items-center gap-x-3 gap-y-2 mb-12 font-light tracking-wide">
|
||||
<span>E: info@informatika.untan.ac.id</span>
|
||||
<span class="hidden sm:inline opacity-50">|</span>
|
||||
<span>P: +62 851-7165-5161</span>
|
||||
<span class="hidden sm:inline opacity-50">|</span>
|
||||
<span>F: +62(561) 739637</span>
|
||||
<span class="hidden sm:inline opacity-50">|</span>
|
||||
<span>WA: +62 851-7165-5161</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="w-full relative z-0 mt-4 pointer-events-none flex justify-center">
|
||||
<img src="../../assets/global/footer.png" alt="Footer Illustration" class="w-full h-auto object-cover object-bottom"
|
||||
style="object-position: bottom;">
|
||||
</div>
|
||||
</footer>`;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
export function mainContentTemplate() {
|
||||
return `<main id="main-content" class="tapops-doc-root pt-[72px] lg:pt-[160px]">
|
||||
|
||||
<!-- ═══════════════════════════════════════
|
||||
HERO
|
||||
═══════════════════════════════════════ -->
|
||||
<section class="research-hero immersive-hero">
|
||||
<div class="hero-container-centered">
|
||||
<span class="hero-kicker" data-tp-i18n="hero_kicker">Digital Administrative Hub</span>
|
||||
<h2 class="hero-title" data-tp-i18n="hero_title">Administrasi Akademik Digital</h2>
|
||||
<p class="hero-tagline" data-tp-i18n="hero_tagline">
|
||||
Satu pintu menuju semua dokumen penting. Akses terpusat menuju repositori dokumen akademik Program Studi
|
||||
Informatika Universitas Tanjungpura untuk mahasiswa dan staf.
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a href="#repository" class="btn-join"><span data-tp-i18n="hero_btn_search">Cari Dokumen</span> <i class="fas fa-search"></i></a>
|
||||
<a href="#procedures" class="btn-outline"><span data-tp-i18n="hero_btn_flow">ALUR PENGAJUAN</span> <i class="fas fa-chevron-right"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-floating-stat-bar">
|
||||
<div class="stat-bar-item">
|
||||
<span class="stat-bar-value" id="stat-active-value">—</span>
|
||||
<span class="stat-bar-label" data-tp-i18n="stat_active">Dokumen Aktif</span>
|
||||
</div>
|
||||
<div class="stat-bar-divider"></div>
|
||||
<div class="stat-bar-item">
|
||||
<span class="stat-bar-value" id="stat-updates-value">—</span>
|
||||
<span class="stat-bar-label" data-tp-i18n="stat_updates">Pembaruan Terbaru</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════════════════════════════════
|
||||
DOCUMENT REPOSITORY
|
||||
═══════════════════════════════════════ -->
|
||||
<section id="repository" class="documents-section">
|
||||
<div class="repo-dashboard-container">
|
||||
<!-- Sidebar Filter -->
|
||||
<aside class="repo-sidebar">
|
||||
<div class="sidebar-sticky">
|
||||
<h3 class="sidebar-title" data-tp-i18n="sidebar_category">KATEGORI BERKAS</h3>
|
||||
<div class="document-filter-wrapper" id="tag-filter-wrapper">
|
||||
<!-- Filter buttons are built dynamically from API tags by main.js -->
|
||||
<div class="filter-skeleton">
|
||||
<div class="filter-skeleton-item"></div>
|
||||
<div class="filter-skeleton-item short"></div>
|
||||
<div class="filter-skeleton-item"></div>
|
||||
<div class="filter-skeleton-item short"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-info-box">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<p data-tp-i18n="sidebar_info">Pastikan mengunduh versi terbaru untuk menjamin validitas berkas pengajuan.</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Document Area (div, not main — only one <main> per page) -->
|
||||
<div class="repo-main-content">
|
||||
<div class="repo-content-header">
|
||||
<div class="header-title-area">
|
||||
<h2 class="section-title" data-tp-i18n="repo_title">Eksplorasi Dokumen Prodi</h2>
|
||||
<p class="section-subtitle" data-tp-i18n="repo_subtitle">Temukan berkas administrasi dan akademik terbaru Program Studi
|
||||
Informatika.</p>
|
||||
</div>
|
||||
|
||||
<div class="repo-controls">
|
||||
<div class="search-container">
|
||||
<div class="search-input-group">
|
||||
<i class="fas fa-search search-icon"></i>
|
||||
<input type="text" id="doc-search" data-tp-i18n-placeholder="search_ph" placeholder="Cari nama berkas atau kata kunci...">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-controls">
|
||||
<div class="control-group date-range-group" id="date-range-control-group">
|
||||
<span class="control-label" data-tp-i18n="label_period">Periode Unggahan</span>
|
||||
<button type="button" id="date-picker-trigger" class="date-picker-trigger">
|
||||
<div class="trigger-left-content">
|
||||
<i class="far fa-calendar-alt"></i>
|
||||
<span id="date-picker-label" data-tp-i18n="filter_all_time">Semua Waktu</span>
|
||||
</div>
|
||||
<i class="fas fa-chevron-down trigger-arrow"></i>
|
||||
</button>
|
||||
|
||||
<div id="date-picker-backdrop" class="date-picker-backdrop"></div>
|
||||
|
||||
<div id="date-picker-popover" class="date-picker-popover">
|
||||
<div class="date-picker-top-bar">
|
||||
<div class="date-display-inputs">
|
||||
<div class="date-display-field">
|
||||
<input type="text" id="picker-start-display" placeholder="DD/MM/YYYY">
|
||||
</div>
|
||||
<span class="date-picker-separator">-</span>
|
||||
<div class="date-display-field">
|
||||
<input type="text" id="picker-end-display" placeholder="DD/MM/YYYY">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="date-picker-body">
|
||||
<div class="calendar-container">
|
||||
<div class="calendar-nav">
|
||||
<button type="button" id="prev-month-btn" class="nav-btn"><i class="fas fa-chevron-left"></i></button>
|
||||
<div class="month-labels">
|
||||
<div id="left-month-label" class="month-label">
|
||||
<span id="left-month-name">Mei</span>
|
||||
<div class="year-select-wrapper">
|
||||
<select id="left-year-select" class="calendar-year-select"></select>
|
||||
<i class="fas fa-chevron-down select-arrow"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div id="right-month-label" class="month-label">
|
||||
<span id="right-month-name">Juni</span>
|
||||
<div class="year-select-wrapper">
|
||||
<select id="right-year-select" class="calendar-year-select"></select>
|
||||
<i class="fas fa-chevron-down select-arrow"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" id="next-month-btn" class="nav-btn"><i class="fas fa-chevron-right"></i></button>
|
||||
</div>
|
||||
|
||||
<div class="calendar-grids">
|
||||
<div class="calendar-grid-pane" id="left-calendar-pane">
|
||||
<div class="day-headers"></div>
|
||||
<div class="days-container" id="left-days-container"></div>
|
||||
</div>
|
||||
<div class="calendar-grid-pane" id="right-calendar-pane">
|
||||
<div class="day-headers"></div>
|
||||
<div class="days-container" id="right-days-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="presets-container">
|
||||
<span class="presets-title" data-tp-i18n="presets_title">RENTANG WAKTU</span>
|
||||
<div class="presets-list">
|
||||
<button type="button" class="preset-btn" data-preset="all" data-tp-i18n="filter_all_time">Semua Waktu</button>
|
||||
<button type="button" class="preset-btn" data-preset="today" data-tp-i18n="preset_today">Hari Ini</button>
|
||||
<button type="button" class="preset-btn" data-preset="yesterday" data-tp-i18n="preset_yesterday">Kemarin</button>
|
||||
<button type="button" class="preset-btn" data-preset="last7" data-tp-i18n="preset_last_7_days">7 Hari Terakhir</button>
|
||||
<button type="button" class="preset-btn" data-preset="last14" data-tp-i18n="preset_last_14_days">14 Hari Terakhir</button>
|
||||
<button type="button" class="preset-btn" data-preset="last30" data-tp-i18n="preset_last_30_days">30 Hari Terakhir</button>
|
||||
<button type="button" class="preset-btn" data-preset="thisWeek" data-tp-i18n="preset_this_week">Minggu Ini</button>
|
||||
<button type="button" class="preset-btn" data-preset="lastWeek" data-tp-i18n="preset_last_week">Minggu Lalu</button>
|
||||
<button type="button" class="preset-btn" data-preset="thisMonth" data-tp-i18n="preset_this_month">Bulan Ini</button>
|
||||
<button type="button" class="preset-btn" data-preset="lastMonth" data-tp-i18n="preset_last_month">Bulan Lalu</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="date-picker-footer">
|
||||
<button type="button" id="picker-cancel-btn" class="action-btn cancel-btn" data-tp-i18n="btn_cancel">Batal</button>
|
||||
<button type="button" id="picker-apply-btn" class="action-btn apply-btn" data-tp-i18n="btn_update">Perbarui</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<span class="control-label" data-tp-i18n="label_sort">Urutkan</span>
|
||||
<div id="sort-filter-wrapper"></div>
|
||||
</div>
|
||||
|
||||
<div class="control-group mobile-category-control-group">
|
||||
<span class="control-label" data-tp-i18n="sidebar_category">Kategori Berkas</span>
|
||||
<div id="mobile-tag-filter-wrapper"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="document-grid" id="document-grid">
|
||||
<div class="repo-loading" style="grid-column: 1 / -1; text-align: center; padding: 60px 20px; color: var(--primary-blue); font-weight: 700; font-size: 1.1rem; display: flex; flex-direction: column; align-items: center; gap: 15px;">
|
||||
<i class="fas fa-circle-notch fa-spin" style="font-size: 2.5rem; color: var(--secondary-gold);"></i>
|
||||
<span data-tp-i18n="loading">Menghubungkan ke repositori berkas...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════════════════════════════════
|
||||
PROCEDURES
|
||||
═══════════════════════════════════════ -->
|
||||
<section id="procedures" class="journey-section">
|
||||
<div class="journey-container">
|
||||
<div class="section-header centered">
|
||||
<h2 class="section-title" data-tp-i18n="proc_title">Tata Cara Pengajuan Berkas</h2>
|
||||
<span class="section-tag" data-tp-i18n="proc_tag">Alur Pengajuan</span>
|
||||
</div>
|
||||
|
||||
<div class="process-flow-wrapper">
|
||||
<!-- Step 01 -->
|
||||
<div class="process-step">
|
||||
<div class="step-visual">
|
||||
<div class="step-icon-box"><i class="fas fa-file-download"></i></div>
|
||||
<div class="step-number-badge">01</div>
|
||||
<div class="step-connector"></div>
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<h3 data-tp-i18n="proc_s1_h">Unduh Formulir</h3>
|
||||
<p data-tp-i18n="proc_s1_p">Pilih kategori pengajuan Anda, lalu unduh formulir versi terbaru. Pastikan kode revisi pada formulir sesuai dengan yang tertera di halaman ini.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 02 -->
|
||||
<div class="process-step">
|
||||
<div class="step-visual">
|
||||
<div class="step-icon-box"><i class="fas fa-edit"></i></div>
|
||||
<div class="step-number-badge">02</div>
|
||||
<div class="step-connector"></div>
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<h3 data-tp-i18n="proc_s2_h">Lengkapi Formulir</h3>
|
||||
<p data-tp-i18n="proc_s2_p">Isi setiap kolom dengan lengkap dan teliti. Sertakan seluruh dokumen pendukung yang diminta sebelum pengiriman.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 03 -->
|
||||
<div class="process-step">
|
||||
<div class="step-visual">
|
||||
<div class="step-icon-box"><i class="fas fa-paper-plane"></i></div>
|
||||
<div class="step-number-badge">03</div>
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<h3 data-tp-i18n="proc_s3_h">Kirim Berkas</h3>
|
||||
<p data-tp-i18n="proc_s3_p">Kirimkan formulir beserta dokumen pendukung melalui email resmi atau WhatsApp admin prodi. Berkas yang lengkap akan langsung diproses oleh admin prodi.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════════════════════════════════
|
||||
HELP CENTER
|
||||
═══════════════════════════════════════ -->
|
||||
<section id="help" class="help-center-section">
|
||||
<div class="help-container">
|
||||
<div class="help-header-centered">
|
||||
<h2 class="section-title" data-tp-i18n="help_title">Butuh Bantuan Lebih Lanjut?</h2>
|
||||
<p data-tp-i18n="help_desc">Tim admin kami siap membantu Anda melalui kanal komunikasi resmi di bawah ini.</p>
|
||||
</div>
|
||||
|
||||
<div class="help-cards-grid">
|
||||
<div class="interaction-card help-official">
|
||||
<div class="card-inner">
|
||||
<div class="card-icon"><i class="fas fa-envelope-open-text"></i></div>
|
||||
<h3 data-tp-i18n="help_email_title">Korespondensi Resmi (Email)</h3>
|
||||
<p data-tp-i18n="help_email_desc">Gunakan untuk permohonan form khusus non-publik atau administrasi formal lainnya.</p>
|
||||
<ul class="card-checklist">
|
||||
<li><i class="fas fa-check"></i> <span data-tp-i18n="help_email_c1">Respon Maks. 24 Jam</span></li>
|
||||
<li><i class="fas fa-check"></i> <span data-tp-i18n="help_email_c2">Pelayanan Formal</span></li>
|
||||
</ul>
|
||||
<a href="https://mail.google.com/mail/?view=cm&fs=1&to=admin@informatika.untan.ac.id&su=Layanan%20Administrasi%20%26%20CS%20Informatika%20UNTAN&body=Halo%20Admin%20Prodi%20Informatika%20UNTAN%2C%0A%0ASaya%20ingin%20mengajukan%20permohonan%20%2F%20melaporkan%20kendala%20terkait%20administrasi%20akademik.%20Berikut%20detail%20informasi%20saya%3A%0A%0ANama%3A%20%5BNama%20Lengkap%20Anda%5D%0ANIM%3A%20%5BNIM%20Anda%5D%0AKategori%20Layanan%3A%20%5BTata%20Cara%20Pengajuan%20%2F%20Kendala%20Teknis%20%26%20CS%5D%0A%0ADetail%20Masalah%20%2F%20Pengajuan%3A%0A%5BTuliskan%20pertanyaan%2C%20detail%20pengajuan%2C%20atau%20kendala%20Anda%20di%20sini...%5D%0A%0ATerima%20kasih." target="_blank" rel="noopener noreferrer" class="btn-interaction"><span data-tp-i18n="help_email_btn">HUBUNGI ADMIN</span> <i class="fas fa-paper-plane"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="interaction-card help-urgent">
|
||||
<div class="card-inner">
|
||||
<div class="card-icon"><i class="fab fa-whatsapp"></i></div>
|
||||
<h3 data-tp-i18n="help_wa_title">Quick Support (WhatsApp)</h3>
|
||||
<p data-tp-i18n="help_wa_desc">Konsultasi cepat alur birokrasi atau pelaporan kendala akses pada berkas repositori.</p>
|
||||
<ul class="card-checklist">
|
||||
<li><i class="fas fa-check"></i> <span data-tp-i18n="help_wa_c1">Respon Instan Jam Kerja</span></li>
|
||||
<li><i class="fas fa-check"></i> <span data-tp-i18n="help_wa_c2">Konsultasi Teknis</span></li>
|
||||
</ul>
|
||||
<a href="https://wa.me/6285171655161?text=Halo%20Admin%20Prodi%20Informatika%20UNTAN%2C%0A%0ASaya%20ingin%20mengajukan%20permohonan%20%2F%20melaporkan%20kendala%20terkait%20administrasi%20akademik.%20Berikut%20detail%20informasi%20saya%3A%0A%0ANama%3A%20%5BNama%20Lengkap%20Anda%5D%0ANIM%3A%20%5BNIM%20Anda%5D%0AKategori%20Layanan%3A%20%5BTata%20Cara%20Pengajuan%20%2F%20Kendala%20Teknis%20%26%20CS%5D%0A%0ADetail%20Masalah%20%2F%20Pengajuan%3A%0A%5BTuliskan%20pertanyaan%2C%20detail%20pengajuan%2C%20atau%20kendala%20Anda%20di%20sini...%5D%0A%0ATerima%20kasih." target="_blank" rel="noopener noreferrer" class="btn-interaction-alt"><span data-tp-i18n="help_wa_btn">CHAT WHATSAPP</span> <i class="fab fa-whatsapp"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>`;
|
||||
}
|
||||
@@ -0,0 +1,843 @@
|
||||
export function navigationTemplate() {
|
||||
return ` <a href="#main-content" class="skip-link" data-i18n="skip_to_content">Langsung ke Konten Utama</a>
|
||||
|
||||
<div id="nav-wrapper"
|
||||
class="fixed top-0 left-0 right-0 z-50 transition-shadow duration-300 shadow-sm border-b border-slate-200 will-change-auto">
|
||||
<div class="bg-untan-navy text-white py-3 lg:py-4 relative overflow-hidden" style="min-height:0;">
|
||||
<div class="absolute inset-0 w-full overflow-hidden pointer-events-none" aria-hidden="true">
|
||||
<div class="absolute inset-0 opacity-[0.02] font-mono text-[10px] md:text-[12px] text-white select-none leading-snug whitespace-pre-wrap break-all text-right"
|
||||
style="overflow:hidden;max-height:100%;">
|
||||
01101001 01101110 01100110 01101111 01110010 01101101 01100001 01110100 01101001
|
||||
01101011 01100001 00100000 01110101 01101110 01110100 01100001 01101110 00100000
|
||||
01100010 01101001 01110011 01100001 01101001 01101110 01100110 01101111 01110010
|
||||
01101101 01100001 01110100 01101001 01101011 01100001 00100000 01110101 01101110
|
||||
01110100 01100001 01101110 00100000 01100010 01101001 01110011 01100001 01010101
|
||||
01001110 01010100 01000001 01001110 00100000 00110010 00110000 00110010 00110101
|
||||
00100000 01100110 01110101 01110100 01110101 01110010 01100101 00100000 01110010
|
||||
01100101 01100001 01100100 01111001 01101001 01101110 01100110 01101111 01110010
|
||||
01101101 01100001 01110100 01101001 01101011 01100001 00100000 01110101 01101110
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-untan-navy via-untan-navy/80 to-transparent"></div>
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto px-4 lg:px-6 flex justify-between items-center relative z-10">
|
||||
<a href="../../index.html" class="flex items-center gap-4 group">
|
||||
<div class="relative w-12 h-12 lg:w-14 lg:h-14 flex items-center justify-center shrink-0">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/id/0/03/Lambang_Universitas_Tanjungpura.png"
|
||||
alt="Logo Universitas Tanjungpura - Lambang resmi UNTAN"
|
||||
class="w-full h-full object-contain transition-transform duration-500 group-hover:scale-105 group-hover:rotate-3 relative z-10">
|
||||
</div>
|
||||
<div class="border-l-2 border-white/30 pl-4 lg:pl-5 py-1 flex flex-col justify-center">
|
||||
<span
|
||||
class="text-lg lg:text-2xl font-bold tracking-widest text-white leading-none font-sans drop-shadow-sm mb-1 uppercase">INFORMATIKA</span>
|
||||
<span
|
||||
class="text-[10px] lg:text-sm font-normal tracking-[0.15em] lg:tracking-[0.3em] text-slate-200 mt-0.5 opacity-90 drop-shadow-sm uppercase font-sans">UNIVERSITAS
|
||||
TANJUNGPURA</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="hidden lg:flex items-center gap-5">
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<a href="https://satu.untan.ac.id" target="_blank"
|
||||
class="hover:text-untan-yellow transition-colors font-medium">SATU UNTAN</a>
|
||||
</div>
|
||||
<div class="pl-5 border-l border-white/20 flex items-center gap-3">
|
||||
<button
|
||||
class="language-toggle-btn text-xs hover:text-untan-yellow transition-colors flex items-center gap-1">
|
||||
<span class="font-bold">ID</span> <span class="text-white/40">|</span> EN
|
||||
</button>
|
||||
<button
|
||||
class="w-8 h-8 flex items-center justify-center rounded-sm hover:bg-white/10 transition-colors"
|
||||
aria-label="Pengaturan aksesibilitas" data-accessibility-toggle>
|
||||
<i class="fas fa-universal-access" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button
|
||||
class="w-8 h-8 flex items-center justify-center rounded-sm hover:bg-white/10 transition-colors"
|
||||
aria-label="Cari konten">
|
||||
<i class="fas fa-search" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="mobile-menu-button"
|
||||
class="lg:hidden text-xl text-white w-9 h-9 flex items-center justify-center rounded-sm hover:bg-white/10 transition-colors"
|
||||
aria-label="Buka menu navigasi" aria-expanded="false" aria-controls="mobile-menu">
|
||||
<i class="fas fa-bars" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="bg-[#feb401] shadow-md hidden lg:block border-t border-white/20 relative" id="main-nav"
|
||||
aria-label="Menu navigasi utama">
|
||||
<div class="container mx-auto px-6">
|
||||
<div class="flex items-center justify-center w-full gap-1 py-2" role="menubar">
|
||||
|
||||
<!-- Menu: Tentang Kami -->
|
||||
<div class="nav-menu-item relative" data-menu="tentang">
|
||||
<button
|
||||
class="nav-menu-btn px-4 py-2.5 text-sm font-bold text-slate-800 hover:text-untan-blue rounded-sm transition-all flex items-center gap-1.5 uppercase tracking-wide">
|
||||
<span data-i18n="nav_about">Tentang Kami</span>
|
||||
<i class="fas fa-chevron-down text-[10px] opacity-70 transition-transform duration-300"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Menu: Program -->
|
||||
<div class="nav-menu-item relative" data-menu="program">
|
||||
<button
|
||||
class="nav-menu-btn px-4 py-2.5 text-sm font-bold text-slate-800 hover:text-untan-blue rounded-sm transition-all flex items-center gap-1.5 uppercase tracking-wide">
|
||||
<span data-i18n="nav_program">Program</span>
|
||||
<i class="fas fa-chevron-down text-[10px] opacity-70 transition-transform duration-300"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Menu: SDM -->
|
||||
<div class="nav-menu-item relative" data-menu="sdm">
|
||||
<button
|
||||
class="nav-menu-btn px-4 py-2.5 text-sm font-bold text-slate-800 hover:text-untan-blue rounded-sm transition-all flex items-center gap-1.5 uppercase tracking-wide">
|
||||
<span data-i18n="nav_sdm">SDM</span>
|
||||
<i class="fas fa-chevron-down text-[10px] opacity-70 transition-transform duration-300"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Menu: Fasilitas -->
|
||||
<div class="nav-menu-item relative" data-menu="fasilitas">
|
||||
<button
|
||||
class="nav-menu-btn px-4 py-2.5 text-sm font-bold text-slate-800 hover:text-untan-blue rounded-sm transition-all flex items-center gap-1.5 uppercase tracking-wide">
|
||||
<span data-i18n="nav_facilities">Fasilitas</span>
|
||||
<i class="fas fa-chevron-down text-[10px] opacity-70 transition-transform duration-300"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Menu: Karya & Agenda -->
|
||||
<div class="nav-menu-item relative" data-menu="karya">
|
||||
<button
|
||||
class="nav-menu-btn px-4 py-2.5 text-sm font-bold text-slate-800 hover:text-untan-blue rounded-sm transition-all flex items-center gap-1.5 uppercase tracking-wide">
|
||||
<span data-i18n="nav_works">Karya & Agenda</span>
|
||||
<i class="fas fa-chevron-down text-[10px] opacity-70 transition-transform duration-300"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Menu: Kontak -->
|
||||
<div class="nav-menu-item relative" data-menu="kontak">
|
||||
<button
|
||||
class="nav-menu-btn px-4 py-2.5 text-sm font-bold text-slate-800 hover:text-untan-blue rounded-sm transition-all flex items-center gap-1.5 uppercase tracking-wide">
|
||||
<span data-i18n="nav_contact">Kontak</span>
|
||||
<i class="fas fa-chevron-down text-[10px] opacity-70 transition-transform duration-300"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MEGA MENU PANELS (Inside nav for proper positioning) -->
|
||||
<div id="mega-menu-container" class="absolute left-0 right-0 top-full z-50">
|
||||
|
||||
<!-- Mega Menu: Tentang Kami -->
|
||||
<div id="mega-tentang" class="mega-panel bg-white border-b-4 border-untan-navy shadow-2xl">
|
||||
<div class="container mx-auto px-6 py-8">
|
||||
<div class="grid grid-cols-12 gap-8">
|
||||
<!-- Left: Featured -->
|
||||
<div
|
||||
class="col-span-4 bg-gradient-to-br from-untan-navy to-slate-800 rounded-2xl p-6 text-white relative overflow-hidden">
|
||||
<div
|
||||
class="absolute top-0 right-0 w-32 h-32 bg-untan-yellow/10 rounded-full -mr-16 -mt-16">
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 w-24 h-24 bg-white/5 rounded-full -ml-12 -mb-12">
|
||||
</div>
|
||||
<div class="relative z-10">
|
||||
<span
|
||||
class="inline-block px-3 py-1 bg-untan-yellow text-slate-900 text-[10px] font-bold uppercase tracking-wider rounded-full mb-4"
|
||||
data-i18n="mega_about_title">Tentang Kami</span>
|
||||
<h3 class="text-2xl font-bold mb-3">Mengenal Informatika UNTAN</h3>
|
||||
<p class="text-slate-300 text-sm mb-6 leading-relaxed" data-i18n="mega_about_desc">
|
||||
Terakreditasi Unggul, mencetak lulusan kompeten di bidang teknologi informasi
|
||||
sejak 1993.</p>
|
||||
<a href="content.html?type=berita"
|
||||
class="inline-flex items-center gap-2 text-untan-yellow font-semibold text-sm hover:gap-3 transition-all">
|
||||
Selengkapnya <i class="fas fa-arrow-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right: Links -->
|
||||
<div class="col-span-8 grid grid-cols-2 gap-6">
|
||||
<a href="content.html?type=berita" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-amber-50 text-amber-600 group-hover:bg-amber-600 group-hover:text-white">
|
||||
<i class="fas fa-landmark"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue transition-colors"
|
||||
data-i18n="link_history">Sejarah & Tonggak</h4>
|
||||
<p class="text-xs text-slate-500 mt-1" data-i18n="link_history_desc">Perjalanan
|
||||
panjang membangun generasi digital</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="content.html?type=berita" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-blue-50 text-blue-600 group-hover:bg-blue-600 group-hover:text-white">
|
||||
<i class="fas fa-users"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue transition-colors"
|
||||
data-i18n="link_org">Struktur Organisasi</h4>
|
||||
<p class="text-xs text-slate-500 mt-1" data-i18n="link_org_desc">Pimpinan dan
|
||||
jajaran pengelola</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="profil.html" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-emerald-50 text-emerald-600 group-hover:bg-emerald-600 group-hover:text-white">
|
||||
<i class="fas fa-bullseye"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue transition-colors"
|
||||
data-i18n="link_vision">Visi & Misi</h4>
|
||||
<p class="text-xs text-slate-500 mt-1" data-i18n="link_vision_desc">Arah dan
|
||||
tujuan pengembangan prodi</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="content.html?type=berita" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-purple-50 text-purple-600 group-hover:bg-purple-600 group-hover:text-white">
|
||||
<i class="fas fa-handshake"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue transition-colors"
|
||||
data-i18n="link_partnership">Kerjasama & MoU</h4>
|
||||
<p class="text-xs text-slate-500 mt-1" data-i18n="link_partnership_desc">Mitra
|
||||
industri dan akademik</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mega Menu: Program -->
|
||||
<div id="mega-program" class="mega-panel bg-white border-b-4 border-untan-navy shadow-2xl">
|
||||
<div class="container mx-auto px-6 py-8">
|
||||
<div class="grid grid-cols-12 gap-8">
|
||||
<!-- Left: Featured -->
|
||||
<div
|
||||
class="col-span-4 bg-gradient-to-br from-blue-600 to-indigo-700 rounded-2xl p-6 text-white relative overflow-hidden">
|
||||
<div class="absolute top-0 right-0 w-40 h-40 bg-white/10 rounded-full -mr-20 -mt-20">
|
||||
</div>
|
||||
<div class="relative z-10">
|
||||
<span
|
||||
class="inline-block px-3 py-1 bg-white/20 text-white text-[10px] font-bold uppercase tracking-wider rounded-full mb-4">Program
|
||||
Akademik</span>
|
||||
<h3 class="text-2xl font-bold mb-3" data-i18n="mega_program_obe">Kurikulum Berbasis
|
||||
OBE</h3>
|
||||
<p class="text-blue-100 text-sm mb-6 leading-relaxed">Outcome-Based Education dengan
|
||||
4 jalur konsentrasi: AI, Software Engineering, Network, dan Information Systems.
|
||||
</p>
|
||||
<a href="subject-and-courses.html"
|
||||
class="inline-flex items-center gap-2 bg-white text-blue-600 px-4 py-2 rounded-lg font-semibold text-sm hover:bg-blue-50 transition-all"
|
||||
data-i18n="btn_krs_sim">
|
||||
<i class="fas fa-play-circle"></i> Simulasi KRS
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right: Links Grid -->
|
||||
<div class="col-span-8">
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<a href="subject-and-courses.html" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-indigo-50 text-indigo-600 group-hover:bg-indigo-600 group-hover:text-white">
|
||||
<i class="fas fa-graduation-cap"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue"
|
||||
data-i18n="link_academic">Akademik</h4>
|
||||
<p class="text-xs text-slate-500 mt-1" data-i18n="link_academic_desc">
|
||||
Jadwal, KRS, dan kalender</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="content.html?type=berita" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-yellow-50 text-yellow-600 group-hover:bg-yellow-600 group-hover:text-white">
|
||||
<i class="fas fa-award"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue"
|
||||
data-i18n="link_accreditation">Akreditasi</h4>
|
||||
<p class="text-xs text-slate-500 mt-1" data-i18n="link_accred_desc">Status
|
||||
Unggul BAN-PT</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="graduate-profile.html" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-green-50 text-green-600 group-hover:bg-green-600 group-hover:text-white">
|
||||
<i class="fas fa-user-graduate"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue"
|
||||
data-i18n="link_graduate">Profil Lulusan</h4>
|
||||
<p class="text-xs text-slate-500 mt-1" data-i18n="link_graduate_desc">
|
||||
Kompetensi & karir</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="subject-and-courses.html" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-rose-50 text-rose-600 group-hover:bg-rose-600 group-hover:text-white">
|
||||
<i class="fas fa-book-open"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue"
|
||||
data-i18n="link_courses">Mata Kuliah</h4>
|
||||
<p class="text-xs text-slate-500 mt-1">144 SKS kurikulum</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="academic-guidelines.html" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-cyan-50 text-cyan-600 group-hover:bg-cyan-600 group-hover:text-white">
|
||||
<i class="fas fa-file-alt"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue"
|
||||
data-i18n="link_guideline">Panduan</h4>
|
||||
<p class="text-xs text-slate-500 mt-1" data-i18n="link_guideline_desc">Buku
|
||||
panduan akademik</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="content.html?type=berita" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-orange-50 text-orange-600 group-hover:bg-orange-600 group-hover:text-white">
|
||||
<i class="fas fa-chart-bar"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue"
|
||||
data-i18n="link_statistics">Informatika Angka</h4>
|
||||
<p class="text-xs text-slate-500 mt-1" data-i18n="link_statistics_desc">
|
||||
Statistik & data</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mega Menu: SDM -->
|
||||
<div id="mega-sdm" class="mega-panel bg-white border-b-4 border-untan-navy shadow-2xl">
|
||||
<div class="container mx-auto px-6 py-8">
|
||||
<div class="grid grid-cols-12 gap-8">
|
||||
<!-- Left: Featured Dosen -->
|
||||
<div
|
||||
class="col-span-5 bg-gradient-to-br from-slate-800 to-slate-900 rounded-2xl p-6 text-white relative overflow-hidden">
|
||||
<div class="absolute inset-0 opacity-10">
|
||||
<div class="absolute top-4 right-4 w-20 h-20 border-2 border-white rounded-full">
|
||||
</div>
|
||||
<div class="absolute bottom-4 left-4 w-16 h-16 border-2 border-white rounded-full">
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative z-10">
|
||||
<span
|
||||
class="inline-block px-3 py-1 bg-untan-yellow text-slate-900 text-[10px] font-bold uppercase tracking-wider rounded-full mb-4"
|
||||
data-i18n="mega_sdm_title">Sumber Daya Manusia</span>
|
||||
<h3 class="text-2xl font-bold mb-3" data-i18n="mega_sdm_desc">Tim Pengajar
|
||||
Profesional</h3>
|
||||
<p class="text-slate-300 text-sm mb-4 leading-relaxed">30+ dosen berkualifikasi
|
||||
S2/S3 dari universitas terkemuka dalam dan luar negeri.</p>
|
||||
<div class="flex gap-4 mb-6">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-untan-yellow">15+</div>
|
||||
<div class="text-[10px] text-slate-400 uppercase">Doktor</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-untan-yellow">20+</div>
|
||||
<div class="text-[10px] text-slate-400 uppercase">Magister</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-untan-yellow">5</div>
|
||||
<div class="text-[10px] text-slate-400 uppercase">Guru Besar</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="people-based-on-kk.html"
|
||||
class="inline-flex items-center gap-2 text-untan-yellow font-semibold text-sm hover:gap-3 transition-all"
|
||||
data-i18n="btn_all_lecturers">
|
||||
Lihat Semua Dosen <i class="fas fa-arrow-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right: KK Groups -->
|
||||
<div class="col-span-7">
|
||||
<h4 class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4"
|
||||
data-i18n="label_expertise">Kelompok Keahlian</h4>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<a href="people-based-on-kk.html"
|
||||
class="group bg-gradient-to-r from-purple-50 to-white border border-purple-100 rounded-xl p-5 hover:shadow-lg hover:border-purple-300 transition-all">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="w-12 h-12 bg-purple-600 rounded-xl flex items-center justify-center text-white text-xl group-hover:scale-110 transition-transform">
|
||||
<i class="fas fa-brain"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="font-bold text-slate-800 mb-1">KK Kecerdasan Buatan</h5>
|
||||
<p class="text-xs text-slate-500">Machine Learning, Computer Vision, NLP
|
||||
</p>
|
||||
<span class="inline-block mt-2 text-xs text-purple-600 font-semibold">8
|
||||
Dosen <i class="fas fa-chevron-right ml-1"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="people-based-on-kk.html"
|
||||
class="group bg-gradient-to-r from-blue-50 to-white border border-blue-100 rounded-xl p-5 hover:shadow-lg hover:border-blue-300 transition-all">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="w-12 h-12 bg-blue-600 rounded-xl flex items-center justify-center text-white text-xl group-hover:scale-110 transition-transform">
|
||||
<i class="fas fa-code"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="font-bold text-slate-800 mb-1">KK Rekayasa Perangkat Lunak
|
||||
</h5>
|
||||
<p class="text-xs text-slate-500">Software Engineering, DevOps, Testing
|
||||
</p>
|
||||
<span class="inline-block mt-2 text-xs text-blue-600 font-semibold">10
|
||||
Dosen <i class="fas fa-chevron-right ml-1"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="people-based-on-kk.html"
|
||||
class="group bg-gradient-to-r from-emerald-50 to-white border border-emerald-100 rounded-xl p-5 hover:shadow-lg hover:border-emerald-300 transition-all">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="w-12 h-12 bg-emerald-600 rounded-xl flex items-center justify-center text-white text-xl group-hover:scale-110 transition-transform">
|
||||
<i class="fas fa-network-wired"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="font-bold text-slate-800 mb-1">KK Jaringan & Keamanan</h5>
|
||||
<p class="text-xs text-slate-500">Network, Cybersecurity, Cloud</p>
|
||||
<span class="inline-block mt-2 text-xs text-emerald-600 font-semibold">7
|
||||
Dosen <i class="fas fa-chevron-right ml-1"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="people-based-on-kk.html"
|
||||
class="group bg-gradient-to-r from-orange-50 to-white border border-orange-100 rounded-xl p-5 hover:shadow-lg hover:border-orange-300 transition-all">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="w-12 h-12 bg-orange-600 rounded-xl flex items-center justify-center text-white text-xl group-hover:scale-110 transition-transform">
|
||||
<i class="fas fa-database"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="font-bold text-slate-800 mb-1">KK Sistem Informasi</h5>
|
||||
<p class="text-xs text-slate-500">Data Science, GIS, Business Intel</p>
|
||||
<span class="inline-block mt-2 text-xs text-orange-600 font-semibold">6
|
||||
Dosen <i class="fas fa-chevron-right ml-1"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mega Menu: Fasilitas -->
|
||||
<div id="mega-fasilitas" class="mega-panel bg-white border-b-4 border-untan-navy shadow-2xl">
|
||||
<div class="container mx-auto px-6 py-8">
|
||||
<div class="grid grid-cols-4 gap-6">
|
||||
<a href="content.html?type=rubrik"
|
||||
class="group relative bg-gradient-to-br from-cyan-500 to-blue-600 rounded-2xl p-6 text-white overflow-hidden hover:shadow-xl transition-all">
|
||||
<div class="absolute top-0 right-0 w-24 h-24 bg-white/10 rounded-full -mr-12 -mt-12">
|
||||
</div>
|
||||
<i class="fas fa-flask text-4xl mb-4 opacity-80"></i>
|
||||
<h4 class="text-lg font-bold mb-2" data-i18n="mega_lab">Laboratorium</h4>
|
||||
<p class="text-sm text-cyan-100 mb-4" data-i18n="mega_lab_desc">8 lab modern dengan
|
||||
peralatan terkini</p>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-xs font-semibold opacity-80 group-hover:opacity-100 group-hover:gap-2 transition-all"
|
||||
data-i18n="btn_explore">
|
||||
Jelajahi <i class="fas fa-arrow-right"></i>
|
||||
</span>
|
||||
</a>
|
||||
<a href="content.html?type=rubrik"
|
||||
class="group relative bg-gradient-to-br from-violet-500 to-purple-600 rounded-2xl p-6 text-white overflow-hidden hover:shadow-xl transition-all">
|
||||
<div class="absolute top-0 right-0 w-24 h-24 bg-white/10 rounded-full -mr-12 -mt-12">
|
||||
</div>
|
||||
<i class="fas fa-server text-4xl mb-4 opacity-80"></i>
|
||||
<h4 class="text-lg font-bold mb-2" data-i18n="mega_dc">Data Center</h4>
|
||||
<p class="text-sm text-violet-100 mb-4" data-i18n="mega_dc_desc">Server & infrastruktur
|
||||
cloud lokal</p>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-xs font-semibold opacity-80 group-hover:opacity-100 group-hover:gap-2 transition-all"
|
||||
data-i18n="btn_explore">
|
||||
Jelajahi <i class="fas fa-arrow-right"></i>
|
||||
</span>
|
||||
</a>
|
||||
<a href="content.html?type=rubrik"
|
||||
class="group relative bg-gradient-to-br from-emerald-500 to-teal-600 rounded-2xl p-6 text-white overflow-hidden hover:shadow-xl transition-all">
|
||||
<div class="absolute top-0 right-0 w-24 h-24 bg-white/10 rounded-full -mr-12 -mt-12">
|
||||
</div>
|
||||
<i class="fas fa-book-reader text-4xl mb-4 opacity-80"></i>
|
||||
<h4 class="text-lg font-bold mb-2" data-i18n="mega_eresources">E-Resources</h4>
|
||||
<p class="text-sm text-emerald-100 mb-4" data-i18n="mega_eresources_desc">Akses jurnal &
|
||||
database ilmiah</p>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-xs font-semibold opacity-80 group-hover:opacity-100 group-hover:gap-2 transition-all"
|
||||
data-i18n="btn_explore">
|
||||
Jelajahi <i class="fas fa-arrow-right"></i>
|
||||
</span>
|
||||
</a>
|
||||
<a href="ifuntanhub.html"
|
||||
class="group relative bg-gradient-to-br from-slate-700 to-slate-900 rounded-2xl p-6 text-white overflow-hidden hover:shadow-xl transition-all">
|
||||
<div
|
||||
class="absolute top-0 right-0 w-24 h-24 bg-untan-yellow/20 rounded-full -mr-12 -mt-12">
|
||||
</div>
|
||||
<i class="fas fa-cloud text-4xl mb-4 opacity-80"></i>
|
||||
<h4 class="text-lg font-bold mb-2">IFUNTANHUB</h4>
|
||||
<p class="text-sm text-slate-300 mb-4" data-i18n="mega_hub_desc">Platform PaaS untuk
|
||||
mahasiswa</p>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-xs font-semibold text-untan-yellow group-hover:gap-2 transition-all">
|
||||
Launch <i class="fas fa-external-link-alt"></i>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mega Menu: Karya & Agenda -->
|
||||
<div id="mega-karya" class="mega-panel bg-white border-b-4 border-untan-navy shadow-2xl">
|
||||
<div class="container mx-auto px-6 py-8">
|
||||
<div class="grid grid-cols-12 gap-8">
|
||||
<!-- Left: Research Highlight -->
|
||||
<div class="col-span-5">
|
||||
<div
|
||||
class="bg-gradient-to-br from-amber-400 to-orange-500 rounded-2xl p-6 text-white mb-4">
|
||||
<span
|
||||
class="inline-block px-3 py-1 bg-white/20 text-white text-[10px] font-bold uppercase tracking-wider rounded-full mb-3"
|
||||
data-i18n="mega_research_title">Riset Unggulan</span>
|
||||
<h3 class="text-xl font-bold mb-2" data-i18n="mega_sinta_title">Indeks SINTA Terbaik
|
||||
</h3>
|
||||
<p class="text-amber-100 text-sm mb-4" data-i18n="mega_sinta_desc">Publikasi dosen
|
||||
terindeks Scopus, WoS, dan jurnal nasional terakreditasi.</p>
|
||||
<a href="content.html?type=berita"
|
||||
class="inline-flex items-center gap-2 bg-white text-orange-600 px-4 py-2 rounded-lg font-semibold text-sm hover:bg-orange-50 transition-all"
|
||||
data-i18n="btn_publications">
|
||||
Lihat Publikasi
|
||||
</a>
|
||||
</div>
|
||||
<a href="content.html?type=berita"
|
||||
class="block bg-slate-50 border border-slate-200 rounded-xl p-4 hover:bg-slate-100 hover:border-slate-300 transition-all">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center text-red-600">
|
||||
<i class="fas fa-calendar-alt"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="font-bold text-slate-800 text-sm" data-i18n="link_info_agenda">
|
||||
Info & Agenda</h5>
|
||||
<p class="text-xs text-slate-500" data-i18n="link_info_agenda_desc">
|
||||
Pengumuman dan jadwal kegiatan</p>
|
||||
</div>
|
||||
<i class="fas fa-chevron-right text-slate-400 ml-auto"></i>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<!-- Right: Grid -->
|
||||
<div class="col-span-7 grid grid-cols-2 gap-4">
|
||||
<a href="karya.html" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-indigo-50 text-indigo-600 group-hover:bg-indigo-600 group-hover:text-white">
|
||||
<i class="fas fa-layer-group"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue">Semua Karya
|
||||
</h4>
|
||||
<p class="text-xs text-slate-500 mt-1">Lihat seluruh karya dalam satu halaman
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="karya.html?filter=penelitian" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-blue-50 text-blue-600 group-hover:bg-blue-600 group-hover:text-white">
|
||||
<i class="fas fa-microscope"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue"
|
||||
data-i18n="link_research">Riset</h4>
|
||||
<p class="text-xs text-slate-500 mt-1" data-i18n="link_research_desc">Penelitian
|
||||
dosen dan mahasiswa</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="karya.html?filter=pkm" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-green-50 text-green-600 group-hover:bg-green-600 group-hover:text-white">
|
||||
<i class="fas fa-hands-helping"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue"
|
||||
data-i18n="link_community">Pengabdian</h4>
|
||||
<p class="text-xs text-slate-500 mt-1" data-i18n="link_community_desc">Program
|
||||
pengabdian masyarakat</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="content.html?type=rubrik" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-amber-50 text-amber-600 group-hover:bg-amber-600 group-hover:text-white">
|
||||
<i class="fas fa-trophy"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue"
|
||||
data-i18n="link_achievements">Prestasi</h4>
|
||||
<p class="text-xs text-slate-500 mt-1" data-i18n="link_achievements_desc">
|
||||
Pencapaian dosen & mahasiswa</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mega Menu: Kontak -->
|
||||
<div id="mega-kontak" class="mega-panel bg-white border-b-4 border-untan-navy shadow-2xl">
|
||||
<div class="container mx-auto px-6 py-8">
|
||||
<div class="grid grid-cols-12 gap-8">
|
||||
<!-- Left: Contact Info -->
|
||||
<div class="col-span-5 bg-untan-navy rounded-2xl p-6 text-white">
|
||||
<h3 class="text-xl font-bold mb-4" data-i18n="mega_contact_title">Hubungi Kami</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="w-10 h-10 bg-white/10 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-map-marker-alt text-untan-yellow"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-slate-300">Jl. Prof. Dr. H. Hadari Nawawi, Bansir
|
||||
Laut, Pontianak Tenggara</p>
|
||||
<p class="text-xs text-slate-400 mt-1">Kalimantan Barat 78124</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 bg-white/10 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fab fa-whatsapp text-untan-yellow"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-white font-medium">+62 851-7165-5161</p>
|
||||
<p class="text-xs text-slate-400">Senin - Jumat, 08:00 - 16:00</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 bg-white/10 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-envelope text-untan-yellow"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-white font-medium">info@informatika.untan.ac.id</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right: Quick Links & Social -->
|
||||
<div class="col-span-7">
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<a href="content.html?type=berita" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-blue-50 text-blue-600 group-hover:bg-blue-600 group-hover:text-white">
|
||||
<i class="fas fa-id-card"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue"
|
||||
data-i18n="link_official_contact">Kontak Resmi</h4>
|
||||
<p class="text-xs text-slate-500 mt-1" data-i18n="link_contact_desc">
|
||||
Direktori kontak staf</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="https://mail.google.com/mail/?view=cm&fs=1&to=info@informatika.untan.ac.id" target="_blank" rel="noopener noreferrer" class="mega-link-card group">
|
||||
<div
|
||||
class="mega-link-icon bg-green-50 text-green-600 group-hover:bg-green-600 group-hover:text-white">
|
||||
<i class="fas fa-comment-dots"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-slate-800 group-hover:text-untan-blue"
|
||||
data-i18n="link_feedback">Saran & Masukan</h4>
|
||||
<p class="text-xs text-slate-500 mt-1" data-i18n="link_feedback_desc">Kirim
|
||||
feedback Anda</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="bg-slate-50 rounded-xl p-5">
|
||||
<h4 class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4"
|
||||
data-i18n="follow_us">Ikuti Kami</h4>
|
||||
<div class="flex gap-3">
|
||||
<a href="https://instagram.com/informatika.untan" target="_blank"
|
||||
class="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-xl flex items-center justify-center text-white hover:scale-110 transition-transform">
|
||||
<i class="fab fa-instagram text-xl"></i>
|
||||
</a>
|
||||
<a href="#"
|
||||
class="w-12 h-12 bg-gradient-to-br from-red-500 to-red-600 rounded-xl flex items-center justify-center text-white hover:scale-110 transition-transform">
|
||||
<i class="fab fa-youtube text-xl"></i>
|
||||
</a>
|
||||
<a href="#"
|
||||
class="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center text-white hover:scale-110 transition-transform">
|
||||
<i class="fab fa-facebook-f text-xl"></i>
|
||||
</a>
|
||||
<a href="#"
|
||||
class="w-12 h-12 bg-gradient-to-br from-sky-400 to-sky-500 rounded-xl flex items-center justify-center text-white hover:scale-110 transition-transform">
|
||||
<i class="fab fa-twitter text-xl"></i>
|
||||
</a>
|
||||
<a href="#"
|
||||
class="w-12 h-12 bg-gradient-to-br from-blue-600 to-blue-700 rounded-xl flex items-center justify-center text-white hover:scale-110 transition-transform">
|
||||
<i class="fab fa-linkedin-in text-xl"></i>
|
||||
</a>
|
||||
<a href="#"
|
||||
class="w-12 h-12 bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl flex items-center justify-center text-white hover:scale-110 transition-transform">
|
||||
<i class="fab fa-tiktok text-xl"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- End mega-menu-container -->
|
||||
</nav>
|
||||
|
||||
<!-- Overlay for mega menu -->
|
||||
<div id="mega-overlay" class="fixed inset-0 bg-black/30 backdrop-blur-sm z-30"></div>
|
||||
</div>
|
||||
|
||||
<div id="mobile-menu"
|
||||
class="fixed inset-0 bg-slate-900 z-[60] transform -translate-x-full lg:hidden flex flex-col transition-transform duration-300"
|
||||
role="dialog" aria-modal="true" aria-label="Menu navigasi mobile">
|
||||
<div class="p-4 flex justify-between items-center border-b border-white/10 bg-untan-navy">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="font-bold text-lg text-white font-serif">Menu Utama</span>
|
||||
<button
|
||||
class="language-toggle-btn px-2 py-1 rounded-sm border border-white/20 text-xs text-white hover:bg-white/10 transition-colors flex items-center gap-2"
|
||||
aria-label="Ganti bahasa">
|
||||
<span class="font-bold">ID</span> <span class="text-white/40" aria-hidden="true">|</span> EN
|
||||
</button>
|
||||
<button
|
||||
class="w-8 h-8 rounded-sm bg-white/10 hover:bg-white/20 flex items-center justify-center text-white transition-colors"
|
||||
aria-label="Pengaturan aksesibilitas" data-accessibility-toggle>
|
||||
<i class="fas fa-universal-access" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button id="close-mobile-menu"
|
||||
class="w-8 h-8 rounded-sm bg-white/10 flex items-center justify-center text-white"
|
||||
aria-label="Tutup menu navigasi">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-5 space-y-4 bg-slate-50">
|
||||
<a href="../../index.html" class="block text-base font-bold text-slate-800 py-1 border-b border-slate-200"
|
||||
data-i18n="nav_home">Beranda</a>
|
||||
|
||||
<!-- Tentang Kami -->
|
||||
<div class="pt-2">
|
||||
<span class="text-xs font-bold text-slate-400 uppercase font-serif" data-i18n="nav_about">Tentang
|
||||
Kami</span>
|
||||
<div class="pl-2 border-l-2 border-slate-100 mt-1 space-y-1">
|
||||
<a href="content.html?type=berita" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_history">Sejarah & Tonggak</a>
|
||||
<a href="content.html?type=berita" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_org">Struktur Organisasi</a>
|
||||
<a href="content.html?type=berita" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_vision">Visi & Misi</a>
|
||||
<a href="content.html?type=berita" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_partnership">Kerjasama & MoU</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Program -->
|
||||
<div class="pt-2 border-t border-slate-200">
|
||||
<span class="text-xs font-bold text-slate-400 uppercase font-serif"
|
||||
data-i18n="nav_program">Program</span>
|
||||
<div class="pl-2 border-l-2 border-slate-100 mt-1 space-y-1">
|
||||
<a href="subject-and-courses.html" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_academic">Akademik</a>
|
||||
<a href="content.html?type=berita" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_accreditation">Akreditasi</a>
|
||||
<a href="graduate-profile.html" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_graduate">Profil Lulusan</a>
|
||||
<a href="subject-and-courses.html" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_courses">Mata Kuliah</a>
|
||||
<a href="academic-guidelines.html" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_guideline">Panduan Akademik</a>
|
||||
<a href="content.html?type=berita" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_statistics">Informatika Angka</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SDM -->
|
||||
<div class="pt-2 border-t border-slate-200">
|
||||
<span class="text-xs font-bold text-slate-400 uppercase font-serif" data-i18n="nav_sdm">SDM</span>
|
||||
<div class="pl-2 border-l-2 border-slate-100 mt-1 space-y-1">
|
||||
<a href="people-based-on-kk.html"
|
||||
class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue font-medium"
|
||||
data-i18n="btn_all_lecturers">Semua Dosen</a>
|
||||
<a href="people-based-on-kk.html" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="kk_ai">Kecerdasan Buatan</a>
|
||||
<a href="people-based-on-kk.html" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="kk_se">Rekayasa Perangkat Lunak</a>
|
||||
<a href="people-based-on-kk.html" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="kk_network">Jaringan & Keamanan</a>
|
||||
<a href="people-based-on-kk.html" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="kk_is">Sistem Informasi</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fasilitas -->
|
||||
<div class="pt-2 border-t border-slate-200">
|
||||
<span class="text-xs font-bold text-slate-400 uppercase font-serif"
|
||||
data-i18n="nav_facilities">Fasilitas</span>
|
||||
<div class="pl-2 border-l-2 border-slate-100 mt-1 space-y-1">
|
||||
<a href="content.html?type=rubrik" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="mega_lab">Laboratorium</a>
|
||||
<a href="content.html?type=rubrik" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="mega_dc">Data Center</a>
|
||||
<a href="content.html?type=rubrik" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="mega_eresources">E-Resources</a>
|
||||
<a href="ifuntanhub.html"
|
||||
class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue">IFUNTANHUB</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Karya & Agenda -->
|
||||
<div class="pt-2 border-t border-slate-200">
|
||||
<span class="text-xs font-bold text-slate-400 uppercase font-serif" data-i18n="nav_works">Karya &
|
||||
Agenda</span>
|
||||
<div class="pl-2 border-l-2 border-slate-100 mt-1 space-y-1">
|
||||
<a href="karya.html" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_all_karya">Semua Karya</a>
|
||||
<a href="karya.html?filter=penelitian"
|
||||
class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_research">Riset</a>
|
||||
<a href="karya.html?filter=pkm" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_community">Pengabdian Masyarakat</a>
|
||||
<a href="content.html?type=rubrik" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_achievements">Prestasi</a>
|
||||
<a href="content.html?type=berita" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_info_agenda">Info & Agenda</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kontak -->
|
||||
<div class="pt-2 border-t border-slate-200 pb-8">
|
||||
<span class="text-xs font-bold text-slate-400 uppercase font-serif"
|
||||
data-i18n="nav_contact">Kontak</span>
|
||||
<div class="pl-2 border-l-2 border-slate-100 mt-1 space-y-1">
|
||||
<a href="content.html?type=berita" class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_official_contact">Kontak Resmi</a>
|
||||
<a href="https://mail.google.com/mail/?view=cm&fs=1&to=info@informatika.untan.ac.id" target="_blank" rel="noopener noreferrer"
|
||||
class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue"
|
||||
data-i18n="link_feedback">Saran & Masukan</a>
|
||||
<a href="https://instagram.com/informatika.untan" target="_blank" rel="noopener noreferrer"
|
||||
class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue flex items-center gap-2">
|
||||
<i class="fab fa-instagram text-pink-500"></i> Instagram
|
||||
</a>
|
||||
<a href="#"
|
||||
class="block py-1.5 text-sm text-slate-700 hover:text-untan-blue flex items-center gap-2">
|
||||
<i class="fab fa-youtube text-red-500"></i> YouTube
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { navigationTemplate } from './navigation-template.js';
|
||||
import { mainContentTemplate } from './main-content-template.js';
|
||||
import { footerTemplate } from './footer-template.js';
|
||||
|
||||
export function pageTemplate() {
|
||||
return [navigationTemplate(), mainContentTemplate(), footerTemplate()].join('');
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
export class DateRangeCalculator {
|
||||
static clearTime(date) {
|
||||
if (!date) return null;
|
||||
const d = new Date(date);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
static datesEqual(left, right) {
|
||||
if (!left || !right) return false;
|
||||
return left.getFullYear() === right.getFullYear()
|
||||
&& left.getMonth() === right.getMonth()
|
||||
&& left.getDate() === right.getDate();
|
||||
}
|
||||
|
||||
static calculatePreset(presetName, todayInput = new Date()) {
|
||||
const today = this.clearTime(todayInput);
|
||||
let start = null;
|
||||
let end = null;
|
||||
|
||||
switch (presetName) {
|
||||
case 'today':
|
||||
start = new Date(today);
|
||||
end = new Date(today);
|
||||
break;
|
||||
case 'yesterday':
|
||||
start = new Date(today);
|
||||
start.setDate(today.getDate() - 1);
|
||||
end = new Date(start);
|
||||
break;
|
||||
case 'last7':
|
||||
end = new Date(today);
|
||||
start = new Date(today);
|
||||
start.setDate(today.getDate() - 6);
|
||||
break;
|
||||
case 'last14':
|
||||
end = new Date(today);
|
||||
start = new Date(today);
|
||||
start.setDate(today.getDate() - 13);
|
||||
break;
|
||||
case 'last30':
|
||||
end = new Date(today);
|
||||
start = new Date(today);
|
||||
start.setDate(today.getDate() - 29);
|
||||
break;
|
||||
case 'thisWeek': {
|
||||
end = new Date(today);
|
||||
start = new Date(today);
|
||||
const day = today.getDay();
|
||||
const diff = today.getDate() - day + (day === 0 ? -6 : 1);
|
||||
start.setDate(diff);
|
||||
break;
|
||||
}
|
||||
case 'lastWeek': {
|
||||
const temp = new Date(today);
|
||||
const day = temp.getDay();
|
||||
const diff = temp.getDate() - day + (day === 0 ? -6 : 1);
|
||||
start = new Date(temp);
|
||||
start.setDate(diff - 7);
|
||||
end = new Date(temp);
|
||||
end.setDate(diff - 1);
|
||||
break;
|
||||
}
|
||||
case 'thisMonth':
|
||||
start = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
end = new Date(today);
|
||||
break;
|
||||
case 'lastMonth':
|
||||
start = new Date(today.getFullYear(), today.getMonth() - 1, 1);
|
||||
end = new Date(today.getFullYear(), today.getMonth(), 0);
|
||||
break;
|
||||
case 'all':
|
||||
default:
|
||||
start = null;
|
||||
end = null;
|
||||
break;
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
static formatDisplay(date) {
|
||||
if (!date) return '';
|
||||
const d = new Date(date);
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
return `${day}/${month}/${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
static parseDisplayDate(value) {
|
||||
const match = String(value || '').trim().match(/^(\d{1,2})[\/\-.](\d{1,2})[\/\-.](\d{4})$/);
|
||||
if (!match) return null;
|
||||
|
||||
const day = Number.parseInt(match[1], 10);
|
||||
const month = Number.parseInt(match[2], 10) - 1;
|
||||
const year = Number.parseInt(match[3], 10);
|
||||
if (month < 0 || month > 11 || day < 1 || day > 31 || year < 1000 || year > 9999) return null;
|
||||
|
||||
const date = new Date(year, month, day);
|
||||
if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) return null;
|
||||
return date;
|
||||
}
|
||||
|
||||
static toDateKey(date) {
|
||||
if (!date) return '';
|
||||
const d = new Date(date);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
export class DocumentFormatter {
|
||||
static MONTHS_ID = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'];
|
||||
|
||||
static CATEGORY_RULES = [
|
||||
{ key: 'skripsi', match: ['skripsi', 'tugas akhir', 'ta'] },
|
||||
{ key: 'akademik', match: ['akademik', 'kalender', 'kaldik'] },
|
||||
{ key: 'praktik', match: ['praktik', 'praktek', 'kerja praktik', 'kp'] },
|
||||
];
|
||||
|
||||
static formatDate(iso) {
|
||||
if (!iso) return '';
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = this.MONTHS_ID[date.getMonth()];
|
||||
return `${day} ${month} ${date.getFullYear()}`;
|
||||
}
|
||||
|
||||
static categoryFromTags(tags) {
|
||||
if (!tags || tags.length === 0) return 'umum';
|
||||
const lower = tags.map(tag => tag.toLowerCase());
|
||||
for (const rule of this.CATEGORY_RULES) {
|
||||
if (rule.match.some(match => lower.includes(match))) return rule.key;
|
||||
}
|
||||
return 'umum';
|
||||
}
|
||||
|
||||
static fileTypeFromName(name) {
|
||||
const lower = (name || '').toLowerCase();
|
||||
if (lower.endsWith('.docx') || lower.endsWith('.doc')) return 'docx';
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
static downloadFilename(name, type) {
|
||||
const ext = `.${type}`;
|
||||
return name.toLowerCase().endsWith(ext) ? name : `${name}${ext}`;
|
||||
}
|
||||
|
||||
static escapeHtml(str) {
|
||||
if (str == null) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
static escapeAttr(str) {
|
||||
return this.escapeHtml(str);
|
||||
}
|
||||
|
||||
static parseTags(raw) {
|
||||
if (!raw) return [];
|
||||
const str = String(raw).trim();
|
||||
if (!str) return [];
|
||||
|
||||
if (str.startsWith('[')) {
|
||||
try {
|
||||
const arr = JSON.parse(str);
|
||||
if (Array.isArray(arr)) {
|
||||
return arr.map(tag => String(tag).trim()).filter(Boolean);
|
||||
}
|
||||
} catch {
|
||||
// Fall through to comma-separated parsing.
|
||||
}
|
||||
}
|
||||
|
||||
return str.split(',').map(tag => tag.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
+1
-220
@@ -1,220 +1 @@
|
||||
/**
|
||||
* Viewer page — renders a single Directus asset in-browser.
|
||||
* PDF → native <iframe>
|
||||
* DOCX → docx-preview library (renders document XML to HTML in-page)
|
||||
*
|
||||
* URL params: ?id={uuid}&name={filename}&type={pdf|docx}
|
||||
*/
|
||||
|
||||
class ViewerApi {
|
||||
static API_BASE = 'https://api.ifuntanhub.dev';
|
||||
|
||||
static assetUrl(uuid) {
|
||||
return `${this.API_BASE}/assets/${uuid}`;
|
||||
}
|
||||
|
||||
static async fetchBlob(url, onProgress) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Gagal mengambil berkas (HTTP ${response.status}).`);
|
||||
|
||||
// If the response supports streaming and we have a progress callback,
|
||||
// pipe through a progress-tracking reader.
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
if (onProgress && contentLength && response.body) {
|
||||
const total = parseInt(contentLength, 10);
|
||||
const reader = response.body.getReader();
|
||||
const chunks = [];
|
||||
let received = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
received += value.length;
|
||||
onProgress(received / total);
|
||||
}
|
||||
return new Blob(chunks);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
}
|
||||
|
||||
class FileViewer {
|
||||
constructor() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
this.id = params.get('id');
|
||||
this.name = params.get('name') || 'Dokumen';
|
||||
this.type = (params.get('type') || 'pdf').toLowerCase();
|
||||
|
||||
this.titleEl = document.getElementById('viewer-title');
|
||||
this.typeBadgeEl = document.getElementById('viewer-type');
|
||||
this.downloadEl = document.getElementById('viewer-download');
|
||||
this.contentEl = document.getElementById('viewer-content');
|
||||
|
||||
this.assetUrl = this.id ? ViewerApi.assetUrl(this.id) : null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.id) {
|
||||
this._renderError('Parameter berkas tidak ditemukan di URL.');
|
||||
return;
|
||||
}
|
||||
|
||||
document.title = `${this.name} — Pratinjau Dokumen`;
|
||||
this.titleEl.textContent = this.name;
|
||||
this.typeBadgeEl.textContent = this.type.toUpperCase();
|
||||
this._setupDownload();
|
||||
|
||||
try {
|
||||
if (this.type === 'docx') {
|
||||
await this._renderDocx();
|
||||
} else {
|
||||
this._renderPdf();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
this._renderError('Gagal memuat pratinjau berkas. Silakan unduh berkas untuk membuka secara lokal.');
|
||||
}
|
||||
}
|
||||
|
||||
_setupDownload() {
|
||||
const ext = `.${this.type}`;
|
||||
const filename = this.name.toLowerCase().endsWith(ext) ? this.name : `${this.name}${ext}`;
|
||||
|
||||
// Use blob-based download so the browser always triggers a save
|
||||
// dialog rather than navigating to the file (cross-origin `download`
|
||||
// attribute is ignored by browsers).
|
||||
this.downloadEl.removeAttribute('href');
|
||||
this.downloadEl.style.cursor = 'pointer';
|
||||
this.downloadEl.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
// Visual feedback while downloading.
|
||||
const origHtml = this.downloadEl.innerHTML;
|
||||
this.downloadEl.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Mengunduh…';
|
||||
this.downloadEl.style.pointerEvents = 'none';
|
||||
try {
|
||||
const blob = await ViewerApi.fetchBlob(this.assetUrl);
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = blobUrl;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
} catch (err) {
|
||||
console.warn('Blob download failed, falling back:', err);
|
||||
// Fallback: open asset URL with ?download param
|
||||
window.open(`${this.assetUrl}?download`, '_blank');
|
||||
} finally {
|
||||
this.downloadEl.innerHTML = origHtml;
|
||||
this.downloadEl.style.pointerEvents = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_renderPdf() {
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.className = 'viewer-pdf-frame';
|
||||
iframe.title = this.name;
|
||||
iframe.src = this.assetUrl;
|
||||
this.contentEl.replaceChildren(iframe);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-load a script by URL. Returns a promise that resolves on load.
|
||||
*/
|
||||
static _loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = src;
|
||||
s.onload = resolve;
|
||||
s.onerror = () => reject(new Error(`Gagal memuat skrip: ${src}`));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
async _renderDocx() {
|
||||
// Show progress bar immediately.
|
||||
const progressBar = this._showProgress();
|
||||
|
||||
// Load DOCX libraries and fetch the blob in parallel.
|
||||
const [blob] = await Promise.all([
|
||||
ViewerApi.fetchBlob(this.assetUrl, (ratio) => {
|
||||
progressBar.update(ratio);
|
||||
}),
|
||||
this._loadDocxLibraries(),
|
||||
]);
|
||||
progressBar.finish();
|
||||
|
||||
if (typeof docx === 'undefined' || typeof docx.renderAsync !== 'function') {
|
||||
throw new Error('Library docx-preview belum dimuat.');
|
||||
}
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'viewer-docx-content';
|
||||
this.contentEl.replaceChildren(container);
|
||||
|
||||
await docx.renderAsync(blob, container, null, {
|
||||
inWrapper: true,
|
||||
ignoreWidth: false,
|
||||
ignoreHeight: false,
|
||||
});
|
||||
}
|
||||
|
||||
async _loadDocxLibraries() {
|
||||
if (typeof docx !== 'undefined') return; // already loaded
|
||||
await FileViewer._loadScript('https://unpkg.com/jszip@3.10.1/dist/jszip.min.js');
|
||||
await FileViewer._loadScript('https://unpkg.com/docx-preview@0.3.5/dist/docx-preview.js');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a thin progress bar at the top of the content area.
|
||||
* Returns { update(ratio), finish() }.
|
||||
*/
|
||||
_showProgress() {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'viewer-progress-bar';
|
||||
const fill = document.createElement('div');
|
||||
fill.className = 'viewer-progress-fill';
|
||||
bar.appendChild(fill);
|
||||
|
||||
// Insert above the loading spinner.
|
||||
this.contentEl.prepend(bar);
|
||||
|
||||
return {
|
||||
update(ratio) {
|
||||
fill.style.width = `${Math.min(Math.round(ratio * 100), 100)}%`;
|
||||
},
|
||||
finish() {
|
||||
fill.style.width = '100%';
|
||||
setTimeout(() => bar.remove(), 300);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
_renderError(message) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'viewer-error';
|
||||
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'fas fa-exclamation-triangle';
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.textContent = message;
|
||||
|
||||
const back = document.createElement('a');
|
||||
back.href = 'index.html#repository';
|
||||
back.className = 'btn-back-mini';
|
||||
back.textContent = 'Kembali ke Repositori';
|
||||
|
||||
wrapper.append(icon, span, back);
|
||||
this.contentEl.replaceChildren(wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new FileViewer().init();
|
||||
});
|
||||
import './viewer/viewer-app.js';
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { ScriptLoader } from './script-loader.js';
|
||||
import { ViewerApi } from './viewer-api.js';
|
||||
import { ViewerProgressBar } from './viewer-progress-bar.js';
|
||||
|
||||
export class FileViewer {
|
||||
constructor() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
this.id = params.get('id');
|
||||
this.name = params.get('name') || 'Dokumen';
|
||||
this.type = (params.get('type') || 'pdf').toLowerCase();
|
||||
|
||||
this.titleEl = document.getElementById('viewer-title');
|
||||
this.typeBadgeEl = document.getElementById('viewer-type');
|
||||
this.downloadEl = document.getElementById('viewer-download');
|
||||
this.contentEl = document.getElementById('viewer-content');
|
||||
|
||||
this.assetUrl = this.id ? ViewerApi.assetUrl(this.id) : null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.id) {
|
||||
this._renderError('Parameter berkas tidak ditemukan di URL.');
|
||||
return;
|
||||
}
|
||||
|
||||
document.title = `${this.name} — Pratinjau Dokumen`;
|
||||
this.titleEl.textContent = this.name;
|
||||
this.typeBadgeEl.textContent = this.type.toUpperCase();
|
||||
this._setupDownload();
|
||||
|
||||
try {
|
||||
if (this.type === 'docx') {
|
||||
await this._renderDocx();
|
||||
} else {
|
||||
this._renderPdf();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
this._renderError('Gagal memuat pratinjau berkas. Silakan unduh berkas untuk membuka secara lokal.');
|
||||
}
|
||||
}
|
||||
|
||||
_setupDownload() {
|
||||
const ext = `.${this.type}`;
|
||||
const filename = this.name.toLowerCase().endsWith(ext) ? this.name : `${this.name}${ext}`;
|
||||
|
||||
this.downloadEl.removeAttribute('href');
|
||||
this.downloadEl.style.cursor = 'pointer';
|
||||
this.downloadEl.addEventListener('click', async event => {
|
||||
event.preventDefault();
|
||||
const originalHtml = this.downloadEl.innerHTML;
|
||||
this.downloadEl.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Mengunduh…';
|
||||
this.downloadEl.style.pointerEvents = 'none';
|
||||
try {
|
||||
const blob = await ViewerApi.fetchBlob(this.assetUrl);
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.style.display = 'none';
|
||||
link.href = blobUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
} catch (err) {
|
||||
console.warn('Blob download failed, falling back:', err);
|
||||
window.open(`${this.assetUrl}?download`, '_blank');
|
||||
} finally {
|
||||
this.downloadEl.innerHTML = originalHtml;
|
||||
this.downloadEl.style.pointerEvents = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_renderPdf() {
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.className = 'viewer-pdf-frame';
|
||||
iframe.title = this.name;
|
||||
iframe.src = this.assetUrl;
|
||||
this.contentEl.replaceChildren(iframe);
|
||||
}
|
||||
|
||||
async _renderDocx() {
|
||||
const progressBar = new ViewerProgressBar(this.contentEl);
|
||||
const [blob] = await Promise.all([
|
||||
ViewerApi.fetchBlob(this.assetUrl, ratio => progressBar.update(ratio)),
|
||||
this._loadDocxLibraries(),
|
||||
]);
|
||||
progressBar.finish();
|
||||
|
||||
if (!window.docx || typeof window.docx.renderAsync !== 'function') {
|
||||
throw new Error('Library docx-preview belum dimuat.');
|
||||
}
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'viewer-docx-content';
|
||||
this.contentEl.replaceChildren(container);
|
||||
|
||||
await window.docx.renderAsync(blob, container, null, {
|
||||
inWrapper: true,
|
||||
ignoreWidth: false,
|
||||
ignoreHeight: false,
|
||||
});
|
||||
}
|
||||
|
||||
async _loadDocxLibraries() {
|
||||
if (window.docx) return;
|
||||
await ScriptLoader.load('https://unpkg.com/jszip@3.10.1/dist/jszip.min.js');
|
||||
await ScriptLoader.load('https://unpkg.com/docx-preview@0.3.5/dist/docx-preview.js');
|
||||
}
|
||||
|
||||
_renderError(message) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'viewer-error';
|
||||
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'fas fa-exclamation-triangle';
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.textContent = message;
|
||||
|
||||
const back = document.createElement('a');
|
||||
back.href = 'index.html#repository';
|
||||
back.className = 'btn-back-mini';
|
||||
back.textContent = 'Kembali ke Repositori';
|
||||
|
||||
wrapper.append(icon, span, back);
|
||||
this.contentEl.replaceChildren(wrapper);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export class ScriptLoader {
|
||||
static load(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.onload = resolve;
|
||||
script.onerror = () => reject(new Error(`Gagal memuat skrip: ${src}`));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export class ViewerApi {
|
||||
static API_BASE = 'https://api.ifuntanhub.dev';
|
||||
|
||||
static assetUrl(uuid) {
|
||||
return `${this.API_BASE}/assets/${uuid}`;
|
||||
}
|
||||
|
||||
static async fetchBlob(url, onProgress) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Gagal mengambil berkas (HTTP ${response.status}).`);
|
||||
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
if (onProgress && contentLength && response.body) {
|
||||
const total = Number.parseInt(contentLength, 10);
|
||||
const reader = response.body.getReader();
|
||||
const chunks = [];
|
||||
let received = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
received += value.length;
|
||||
onProgress(received / total);
|
||||
}
|
||||
return new Blob(chunks);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { FileViewer } from './file-viewer.js';
|
||||
import { ViewerPrefetch } from './viewer-prefetch.js';
|
||||
|
||||
ViewerPrefetch.fromLocation();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new FileViewer().init();
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ViewerApi } from './viewer-api.js';
|
||||
|
||||
export class ViewerPrefetch {
|
||||
static fromLocation(location = window.location) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const id = params.get('id');
|
||||
if (!id) return;
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'prefetch';
|
||||
link.as = 'fetch';
|
||||
link.crossOrigin = 'anonymous';
|
||||
link.href = ViewerApi.assetUrl(id);
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export class ViewerProgressBar {
|
||||
constructor(contentEl) {
|
||||
this.bar = document.createElement('div');
|
||||
this.bar.className = 'viewer-progress-bar';
|
||||
this.fill = document.createElement('div');
|
||||
this.fill.className = 'viewer-progress-fill';
|
||||
this.bar.appendChild(this.fill);
|
||||
contentEl.prepend(this.bar);
|
||||
}
|
||||
|
||||
update(ratio) {
|
||||
this.fill.style.width = `${Math.min(Math.round(ratio * 100), 100)}%`;
|
||||
}
|
||||
|
||||
finish() {
|
||||
this.fill.style.width = '100%';
|
||||
setTimeout(() => this.bar.remove(), 300);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { DateRangeCalculator } from '../js/utils/date-range-calculator.js';
|
||||
import { PaginationModel } from '../js/models/pagination-model.js';
|
||||
|
||||
test('calculates date presets using an injected current day', () => {
|
||||
const today = new Date('2026-06-04T12:00:00+07:00');
|
||||
|
||||
const last7 = DateRangeCalculator.calculatePreset('last7', today);
|
||||
assert.equal(DateRangeCalculator.toDateKey(last7.start), '2026-05-29');
|
||||
assert.equal(DateRangeCalculator.toDateKey(last7.end), '2026-06-04');
|
||||
|
||||
const thisMonth = DateRangeCalculator.calculatePreset('thisMonth', today);
|
||||
assert.equal(DateRangeCalculator.toDateKey(thisMonth.start), '2026-06-01');
|
||||
assert.equal(DateRangeCalculator.toDateKey(thisMonth.end), '2026-06-04');
|
||||
|
||||
const all = DateRangeCalculator.calculatePreset('all', today);
|
||||
assert.equal(all.start, null);
|
||||
assert.equal(all.end, null);
|
||||
});
|
||||
|
||||
test('formats and parses dd/mm/yyyy date input safely', () => {
|
||||
const date = new Date(2026, 5, 4);
|
||||
assert.equal(DateRangeCalculator.formatDisplay(date), '04/06/2026');
|
||||
assert.equal(DateRangeCalculator.toDateKey(date), '2026-06-04');
|
||||
assert.equal(DateRangeCalculator.parseDisplayDate('04/06/2026')?.getFullYear(), 2026);
|
||||
assert.equal(DateRangeCalculator.parseDisplayDate('31/02/2026'), null);
|
||||
assert.equal(DateRangeCalculator.parseDisplayDate('04/06/10000'), null);
|
||||
});
|
||||
|
||||
test('creates stable compact pagination ranges', () => {
|
||||
assert.deepEqual(PaginationModel.range(1, 4), [1, 2, 3, 4]);
|
||||
assert.deepEqual(PaginationModel.range(1, 10), [1, 2, '...', 10]);
|
||||
assert.deepEqual(PaginationModel.range(5, 10), [1, '...', 4, 5, 6, '...', 10]);
|
||||
assert.deepEqual(PaginationModel.range(10, 10), [1, '...', 9, 10]);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { DocumentFormatter } from '../js/utils/document-formatter.js';
|
||||
|
||||
test('parses comma-separated and JSON string tags', () => {
|
||||
assert.deepEqual(DocumentFormatter.parseTags('akademik, panduan, 2026'), [
|
||||
'akademik',
|
||||
'panduan',
|
||||
'2026',
|
||||
]);
|
||||
assert.deepEqual(DocumentFormatter.parseTags('["kerja praktik","kp"]'), [
|
||||
'kerja praktik',
|
||||
'kp',
|
||||
]);
|
||||
});
|
||||
|
||||
test('maps parsed tags to fixed repository categories', () => {
|
||||
assert.equal(DocumentFormatter.categoryFromTags(['TA']), 'skripsi');
|
||||
assert.equal(DocumentFormatter.categoryFromTags(['Kalender']), 'akademik');
|
||||
assert.equal(DocumentFormatter.categoryFromTags(['kp']), 'praktik');
|
||||
assert.equal(DocumentFormatter.categoryFromTags(['surat']), 'umum');
|
||||
assert.equal(DocumentFormatter.categoryFromTags([]), 'umum');
|
||||
});
|
||||
|
||||
test('derives file type and safe download filename from document name', () => {
|
||||
assert.equal(DocumentFormatter.fileTypeFromName('Panduan Akademik.pdf'), 'pdf');
|
||||
assert.equal(DocumentFormatter.fileTypeFromName('Form KP.DOCX'), 'docx');
|
||||
assert.equal(DocumentFormatter.downloadFilename('Form KP', 'docx'), 'Form KP.docx');
|
||||
assert.equal(DocumentFormatter.downloadFilename('Kalender.pdf', 'pdf'), 'Kalender.pdf');
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const indexHtml = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
|
||||
const viewerHtml = readFileSync(new URL('../viewer.html', import.meta.url), 'utf8');
|
||||
|
||||
test('Tapops index uses module scripts and no inline behavior', () => {
|
||||
assert.match(indexHtml, /<script type="module" src="js\/app\.js"><\/script>/);
|
||||
assert.doesNotMatch(indexHtml, /<script>\s*\(/);
|
||||
assert.doesNotMatch(indexHtml, /\sonclick=/);
|
||||
});
|
||||
|
||||
test('Tapops viewer uses module scripts and no inline behavior', () => {
|
||||
assert.match(viewerHtml, /<script type="module" src="js\/viewer\/viewer-app\.js"><\/script>/);
|
||||
assert.doesNotMatch(viewerHtml, /<script>\s*\(/);
|
||||
});
|
||||
@@ -16,26 +16,6 @@
|
||||
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/viewer.css">
|
||||
|
||||
<!--
|
||||
Early asset prefetch: start downloading the file immediately while the
|
||||
rest of the page (CSS, fonts, scripts) loads in parallel. This shaves
|
||||
off the entire page-load time from the perceived wait.
|
||||
-->
|
||||
<script>
|
||||
(function() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var id = params.get('id');
|
||||
if (id) {
|
||||
var link = document.createElement('link');
|
||||
link.rel = 'prefetch';
|
||||
link.as = 'fetch';
|
||||
link.crossOrigin = 'anonymous';
|
||||
link.href = 'https://api.ifuntanhub.dev/assets/' + id;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body class="viewer-body">
|
||||
@@ -63,11 +43,7 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!--
|
||||
DOCX libraries are now lazy-loaded by viewer.js ONLY when type=docx.
|
||||
PDFs no longer wait for ~350KB of unused scripts to download.
|
||||
-->
|
||||
<script src="js/viewer.js"></script>
|
||||
<script type="module" src="js/viewer/viewer-app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
"name": "web-student-safe",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Student-safe static page assignment kit for Informatika UNTAN",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"test:tapops": "node --test groups/Tapops/tests/*.test.mjs",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Reference in New Issue
Block a user