1
0
mirror of https://github.com/muety/wakapi.git synced 2023-08-10 21:12:56 +03:00

refactor: redesign login page

refactor: redesign signup page
refactor: redesign summary page
This commit is contained in:
Ferdinand Mütsch 2021-12-15 22:46:02 +01:00
parent ee501ca3c5
commit 44a2e609fb
19 changed files with 562 additions and 458 deletions

View File

@ -243,83 +243,5 @@
"Zephir": "#118f9e",
"Zig": "#ec915c",
"ZIL": "#dc75e5"
},
"editors": {
"Android Studio": "#99cd00",
"AppCode": "#04dbde",
"Aptana": "#ec8623",
"Atom": "#49b77e",
"Azure Data Studio": "#0271c6",
"Blender": "#fb8007",
"Brackets": "#067dc3",
"Chrome": "#fdd308",
"CLion": "#14c9a5",
"Cloud9": "#25a6d9",
"Coda": "#3e8e1c",
"CodeTasty": "#7368a8",
"DataGrip": "#907cf2",
"DBeaver": "#897363",
"Eclipse": "#443582",
"Emacs": "#8c76c3",
"Eric": "#423f13",
"Excel": "#0f753c",
"Flash Builder": "#aca3a4",
"Gedit": "#872114",
"GoLand": "#bd4ffc",
"HBuilder X": "#1ba334",
"IntelliJ IDEA": "#237ce2",
"IntelliJ": "#237ce2",
"Kakoune": "#dd5f4a",
"Kate": "#3f4040",
"Komodo": "#fcb414",
"Micro": "#2c3494",
"MonoDevelop": "#6185b3",
"NetBeans": "#f1f6e2",
"Notepad++": "#9ecf54",
"Nova": "#ff054a",
"Onivim": "#ee848e",
"PhpStorm": "#d93ac1",
"PowerPoint": "#c6421f",
"Processing": "#6a7152",
"PyCharm": "#d2ee5c",
"Pymakr": "#323d4f",
"Rider": "#f7a415",
"RubyMine": "#ff6336",
"Sketch": "#fdad00",
"SlickEdit": "#57ca57",
"SQL Server Management Studio": "#ffb901",
"Sublime Text": "#ff9800",
"Terminal": "#133f1c",
"TeXstudio": "#652d96",
"TextMate": "#822b7a",
"Unity": "#222d36",
"Vim": "#068304",
"Visual Studio": "#9460cd",
"VS Code": "#027acd",
"VSCode": "#027acd",
"WebStorm": "#00c6d7",
"Word": "#0f4091",
"WPS Office": "#fc6143",
"Xamarin": "#3598db",
"Xcode": "#3fa7e4",
"Adobe XD": "#fd27bc",
"Code::Blocks": "#d0ce71",
"Embarcadero Delphi": "#d9242a",
"EmEditor": "#ed3103",
"Figma": "#c7b9ff",
"Firefox": "#d96527",
"Geany": "#fbec75",
"Light Table": "#007ac1",
"MacRabbit Espresso": "#e6593f",
"MySQL Workbench": "#245279",
"Photoshop": "#0a0054",
"QtCreator": "#7fc342",
"RStudio": "#2369c7",
"WebMatrix": "#aeaeae"
},
"operating_systems": {
"Linux": "#f0b912",
"Windows": "#00b7ee",
"Mac": "#4d66cb"
}
}

View File

@ -55,8 +55,6 @@ type SummaryViewModel struct {
User *User
AvatarURL string
LanguageColors map[string]string
EditorColors map[string]string
OSColors map[string]string
Error string
Success string
ApiKey string
@ -225,6 +223,27 @@ func (s *Summary) TotalTimeByFilters(filters *Filters) time.Duration {
return 0
}
func (s *Summary) MaxBy(entityType uint8) *SummaryItem {
var max *SummaryItem
mappedItems := s.MappedItems()
if items := mappedItems[entityType]; len(*items) > 0 {
for _, item := range *items {
if max == nil || item.Total > max.Total {
max = item
}
}
}
return max
}
func (s *Summary) MaxByToString(entityType uint8) string {
max := s.MaxBy(entityType)
if max == nil {
return "-"
}
return max.Key
}
func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
processAliases := func(origin []*SummaryItem) []*SummaryItem {
target := make([]*SummaryItem, 0)

View File

@ -66,8 +66,6 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
SummaryParams: summaryParams,
User: user,
LanguageColors: utils.FilterColors(h.config.App.GetLanguageColors(), summary.Languages),
EditorColors: utils.FilterColors(h.config.App.GetEditorColors(), summary.Editors),
OSColors: utils.FilterColors(h.config.App.GetOSColors(), summary.OperatingSystems),
ApiKey: user.ApiKey,
RawQuery: rawQuery,
}

View File

@ -33,7 +33,18 @@ let icons = [
'eva:corner-right-down-fill',
'bi:heart-fill',
'fxemoji:running',
'ic:round-person'
'ic:round-person',
'bx:bxs-bar-chart-alt-2',
'bi:people-fill',
'fluent:data-bar-horizontal-24-filled',
'ic:round-dashboard',
'ci:settings-filled',
'akar-icons:chevron-down',
'ls:logout',
'fluent:key-32-filled',
'majesticons:clipboard-copy',
'fa-regular:calendar-alt',
'ph:books-bold'
]
const output = path.normalize(path.join(__dirname, '../static/assets/icons.js'))

View File

@ -6,7 +6,20 @@ body {
background-color: #242b3a;
}
.hover\:bg-gray-850:hover {
--bg-opacity: 1;
background-color: #242b3a;
}
::-webkit-calendar-picker-indicator {
filter: invert(1);
cursor: pointer;
}
.text-xxs {
font-size: 0.65rem;
}
.mt-14 {
margin-top: 3.5rem;
}

View File

@ -1,4 +1,11 @@
const CHART_TARGET_SIZE = 200
const LEGEND_MAX_ENTRIES = 9
// dirty hack to vertically align legends across multiple charts
// however, without monospace font, it's still not perfectly aligned
// waiting for https://github.com/chartjs/Chart.js/discussions/9890
const LEGEND_CHARACTERS = 20
// https://hihayk.github.io/scale/#4/6/50/80/-51/67/20/14/276749/39/103/73/white
const baseColors = [ '#112836', '#163B43', '#1C4F4D', '#215B4C', '#276749', '#437C57', '#5F9167', '#7DA67C', '#9FBA98', '#BFCEB5', '#DCE2D3' ]
const projectsCanvas = document.getElementById('chart-projects')
const osCanvas = document.getElementById('chart-os')
@ -30,9 +37,8 @@ let charts = []
let showTopN = []
let resizeCount = 0
charts.color = "#E2E8F0"
charts.borderColor = "#E2E8F0"
charts.backgroundColor = "#E2E8F0"
Chart.defaults.color = "#E2E8F0"
Chart.defaults.borderColor = "#242b3a"
String.prototype.toHHMMSS = function () {
const sec_num = parseInt(this, 10)
@ -52,15 +58,6 @@ String.prototype.toHHMMSS = function () {
return `${hours}:${minutes}:${seconds}`
}
String.prototype.toHHMM = function () {
const sec_num = parseInt(this, 10)
const hours = Math.floor(sec_num / 3600)
const minutes = Math.floor((sec_num - (hours * 3600)) / 60)
return `${hours}:${minutes}`
}
function draw(subselection) {
function getTooltipOptions(key) {
return {
@ -74,6 +71,12 @@ function draw(subselection) {
}
}
function filterLegendItem(item) {
item.text = item.text.length > LEGEND_CHARACTERS ? item.text.slice(0, LEGEND_CHARACTERS - 3).padEnd(LEGEND_CHARACTERS, '.') : item.text
item.text = item.text.padEnd(LEGEND_CHARACTERS + 3)
return item.index < LEGEND_MAX_ENTRIES
}
function shouldUpdate(index) {
return !subselection || (subselection.includes(index) && data[index].length >= showTopN[index])
}
@ -91,19 +94,14 @@ function draw(subselection) {
data: wakapiData.projects
.slice(0, Math.min(showTopN[0], wakapiData.projects.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.projects.map(p => {
const c = hexToRgb(getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.6)`
}),
hoverBackgroundColor: wakapiData.projects.map(p => {
const c = hexToRgb(getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
}),
borderColor: wakapiData.projects.map(p => {
const c = hexToRgb(getRandomColor(p.key))
backgroundColor: wakapiData.projects.map((p, i) => {
const c = hexToRgb(getColor(p.key, i % baseColors.length))
return `rgba(${c.r}, ${c.g}, ${c.b}, 1)`
}),
borderWidth: 2
hoverBackgroundColor: wakapiData.projects.map((p, i) => {
const c = hexToRgb(getColor(p.key, i % baseColors.length))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
}),
}],
labels: wakapiData.projects
.slice(0, Math.min(showTopN[0], wakapiData.projects.length))
@ -115,7 +113,7 @@ function draw(subselection) {
xAxes: {
title: {
display: true,
text: 'Duration (hh:mm:ss)'
text: 'Duration (hh:mm:ss)',
},
ticks: {
callback: (label) => label.toString().toHHMMSS(),
@ -124,12 +122,11 @@ function draw(subselection) {
},
plugins: {
legend: {
display: false
display: false,
},
tooltip: getTooltipOptions('projects'),
},
maintainAspectRatio: false,
onResize: onChartResize
}
})
: null
@ -142,18 +139,15 @@ function draw(subselection) {
data: wakapiData.operatingSystems
.slice(0, Math.min(showTopN[1], wakapiData.operatingSystems.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.operatingSystems.map(p => {
const c = hexToRgb(osColors[p.key.toLowerCase()] || getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.6)`
}),
hoverBackgroundColor: wakapiData.operatingSystems.map(p => {
const c = hexToRgb(osColors[p.key.toLowerCase()] || getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
}),
borderColor: wakapiData.operatingSystems.map(p => {
const c = hexToRgb(osColors[p.key.toLowerCase()] || getRandomColor(p.key))
backgroundColor: wakapiData.operatingSystems.map((p, i) => {
const c = hexToRgb(getColor(p.key, i))
return `rgba(${c.r}, ${c.g}, ${c.b}, 1)`
}),
hoverBackgroundColor: wakapiData.operatingSystems.map((p, i) => {
const c = hexToRgb(getColor(p.key, i))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
}),
borderWidth: 0
}],
labels: wakapiData.operatingSystems
.slice(0, Math.min(showTopN[1], wakapiData.operatingSystems.length))
@ -162,9 +156,14 @@ function draw(subselection) {
options: {
plugins: {
tooltip: getTooltipOptions('operatingSystems'),
legend: {
position: 'right',
labels: {
filter: filterLegendItem
},
},
},
maintainAspectRatio: false,
onResize: onChartResize
}
})
: null
@ -177,18 +176,15 @@ function draw(subselection) {
data: wakapiData.editors
.slice(0, Math.min(showTopN[2], wakapiData.editors.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.editors.map(p => {
const c = hexToRgb(editorColors[p.key.toLowerCase()] || getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.6)`
}),
hoverBackgroundColor: wakapiData.editors.map(p => {
const c = hexToRgb(editorColors[p.key.toLowerCase()] || getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
}),
borderColor: wakapiData.editors.map(p => {
const c = hexToRgb(editorColors[p.key.toLowerCase()] || getRandomColor(p.key))
backgroundColor: wakapiData.editors.map((p, i) => {
const c = hexToRgb(getColor(p.key, i))
return `rgba(${c.r}, ${c.g}, ${c.b}, 1)`
}),
hoverBackgroundColor: wakapiData.editors.map((p, i) => {
const c = hexToRgb(getColor(p.key, i))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
}),
borderWidth: 0
}],
labels: wakapiData.editors
.slice(0, Math.min(showTopN[2], wakapiData.editors.length))
@ -197,9 +193,14 @@ function draw(subselection) {
options: {
plugins: {
tooltip: getTooltipOptions('editors'),
legend: {
position: 'right',
labels: {
filter: filterLegendItem
},
},
},
maintainAspectRatio: false,
onResize: onChartResize
}
})
: null
@ -214,16 +215,13 @@ function draw(subselection) {
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.languages.map(p => {
const c = hexToRgb(languageColors[p.key.toLowerCase()] || getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.6)`
return `rgba(${c.r}, ${c.g}, ${c.b}, 1)`
}),
hoverBackgroundColor: wakapiData.languages.map(p => {
const c = hexToRgb(languageColors[p.key.toLowerCase()] || getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
}),
borderColor: wakapiData.languages.map(p => {
const c = hexToRgb(languageColors[p.key.toLowerCase()] || getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 1)`
}),
borderWidth: 0
}],
labels: wakapiData.languages
.slice(0, Math.min(showTopN[3], wakapiData.languages.length))
@ -232,9 +230,17 @@ function draw(subselection) {
options: {
plugins: {
tooltip: getTooltipOptions('languages'),
legend: {
position: 'right',
labels: {
filter: filterLegendItem
},
title: {
display: true,
}
},
},
maintainAspectRatio: false,
onResize: onChartResize
}
})
: null
@ -247,18 +253,15 @@ function draw(subselection) {
data: wakapiData.machines
.slice(0, Math.min(showTopN[4], wakapiData.machines.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.machines.map(p => {
const c = hexToRgb(getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.6)`
}),
hoverBackgroundColor: wakapiData.machines.map(p => {
const c = hexToRgb(getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
}),
borderColor: wakapiData.machines.map(p => {
const c = hexToRgb(getRandomColor(p.key))
backgroundColor: wakapiData.machines.map((p, i) => {
const c = hexToRgb(getColor(p.key, i))
return `rgba(${c.r}, ${c.g}, ${c.b}, 1)`
}),
hoverBackgroundColor: wakapiData.machines.map((p, i) => {
const c = hexToRgb(getColor(p.key, i))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
}),
borderWidth: 0
}],
labels: wakapiData.machines
.slice(0, Math.min(showTopN[4], wakapiData.machines.length))
@ -267,9 +270,14 @@ function draw(subselection) {
options: {
plugins: {
tooltip: getTooltipOptions('machines'),
legend: {
position: 'right',
labels: {
filter: filterLegendItem
},
},
},
maintainAspectRatio: false,
onResize: onChartResize
}
})
: null
@ -282,18 +290,15 @@ function draw(subselection) {
data: wakapiData.labels
.slice(0, Math.min(showTopN[5], wakapiData.labels.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.labels.map(p => {
const c = hexToRgb(getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.6)`
}),
hoverBackgroundColor: wakapiData.labels.map(p => {
const c = hexToRgb(getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
}),
borderColor: wakapiData.labels.map(p => {
const c = hexToRgb(getRandomColor(p.key))
backgroundColor: wakapiData.labels.map((p, i) => {
const c = hexToRgb(getColor(p.key, i))
return `rgba(${c.r}, ${c.g}, ${c.b}, 1)`
}),
hoverBackgroundColor: wakapiData.labels.map((p, i) => {
const c = hexToRgb(getColor(p.key, i))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
}),
borderWidth: 0
}],
labels: wakapiData.labels
.slice(0, Math.min(showTopN[5], wakapiData.labels.length))
@ -302,26 +307,24 @@ function draw(subselection) {
options: {
plugins: {
tooltip: getTooltipOptions('labels'),
legend: {
position: 'right',
labels: {
filter: filterLegendItem
},
},
},
maintainAspectRatio: false,
onResize: onChartResize
}
})
: null
getTotal(wakapiData.operatingSystems)
charts[0] = projectChart ? projectChart : charts[0]
charts[1] = osChart ? osChart : charts[1]
charts[2] = editorChart ? editorChart : charts[2]
charts[3] = languageChart ? languageChart : charts[3]
charts[4] = machineChart ? machineChart : charts[4]
charts[5] = labelChart ? labelChart : charts[5]
if (!subselection) {
charts.forEach(c => c.options.onResize(c))
equalizeHeights()
}
}
function parseTopN() {
@ -354,45 +357,9 @@ function getContainer(chart) {
return chart.canvas.parentNode
}
function onChartResize(chart) {
let container = getContainer(chart)
let targetHeight = Math.min(chart.width, CHART_TARGET_SIZE)
// let actualHeight = chart.height - chart.chartArea.top
let actualHeight = chart.height - chart.top
let containerTargetHeight = container.clientHeight += (targetHeight - actualHeight)
container.style.height = parseInt(containerTargetHeight) + 'px'
resizeCount++
watchEqualize()
}
function watchEqualize() {
if (resizeCount === charts.length) {
equalizeHeights()
resizeCount = 0
}
}
function equalizeHeights() {
let maxHeight = 0
charts.forEach(c => {
let container = getContainer(c)
if (maxHeight < container.clientHeight) {
maxHeight = container.clientHeight
}
})
charts.forEach(c => {
let container = getContainer(c)
container.style.height = parseInt(maxHeight) + 'px'
})
}
function getTotal(items) {
const el = document.getElementById('total-span')
if (!el) return
const total = items.reduce((acc, d) => acc + d.total, 0)
const formatted = total.toString().toHHMM()
el.innerText = `${formatted.split(':')[0]} hours, ${formatted.split(':')[1]} minutes`
function getColor(seed, index) {
if (index < baseColors.length) return baseColors[(index + 5) % baseColors.length]
return getRandomColor(seed)
}
function getRandomColor(seed) {
@ -427,6 +394,25 @@ function showUserMenuPopup(event) {
event.stopPropagation()
}
function hideUserMenuPopup(event) {
const el = document.getElementById('user-menu-popup')
el.classList.remove('block')
el.classList.add('hidden')
event.stopPropagation()
}
function toggleTimePickerPopup(event) {
const el = document.getElementById('time-picker-popup')
if (el.classList.contains('hidden')) {
el.classList.remove('hidden')
el.classList.add('block')
} else {
el.classList.add('hidden')
el.classList.remove('block')
}
event.stopPropagation()
}
function showApiKeyPopup(event) {
const el = document.getElementById('api-key-popup')
el.classList.remove('hidden')
@ -442,6 +428,27 @@ function copyApiKey(event) {
event.stopPropagation()
}
function submitTimePicker(event) {
const el = document.getElementById('time-picker-form')
el.submit()
}
function swapCharts(showEntity, hideEntity) {
document.getElementById(`${showEntity}-container`).parentElement.classList.remove('hidden')
document.getElementById(`${hideEntity}-container`).parentElement.classList.add('hidden')
}
function updateTimeSelection() {
const query = new URLSearchParams(window.location.search)
if (query.has('interval')) {
const targetEl = document.getElementById('current-time-selection')
const refEl = document.getElementById(`time-option-${query.get('interval')}`)
targetEl.innerText = refEl ? refEl.innerText : 'Unknown'
}
}
// Click outside
window.addEventListener('click', function (event) {
if (event.target.classList.contains('popup')) {
@ -454,6 +461,8 @@ window.addEventListener('click', function (event) {
})
window.addEventListener('load', function () {
updateTimeSelection()
topNPickers.forEach(e => e.addEventListener('change', () => {
parseTopN()
draw([parseInt(e.attributes['data-entity'].value)])

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 24 24"
version="1.1"
id="svg8"
sodipodi:docname="unknown.svg"
inkscape:version="1.1 (c68e22c387, 2021-05-23)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs12" />
<sodipodi:namedview
id="namedview10"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
showgrid="false"
inkscape:zoom="36.375"
inkscape:cx="6.5841924"
inkscape:cy="11.986254"
inkscape:window-width="2560"
inkscape:window-height="1372"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg8" />
<title
id="title2" />
<circle
cx="12"
cy="8"
fill="#464646"
r="4"
id="circle4"
style="fill:#2d3748;fill-opacity:1" />
<path
d="M20,19v1a1,1,0,0,1-1,1H5a1,1,0,0,1-1-1V19a6,6,0,0,1,6-6h4A6,6,0,0,1,20,19Z"
fill="#464646"
id="path6"
style="fill:#2d3748;fill-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,5 +1,3 @@
<header class="flex justify-between mb-10">
<a id="logo-container" class="text-2xl font-semibold text-white inline-block" href="">
<img src="assets/images/logo.svg" width="110px" alt="Logo">
</a>
{{ template "logo.tpl.html" . }}
</header>

View File

@ -3,7 +3,7 @@
{{ template "head.tpl.html" . }}
<body class="bg-gray-850 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
<body class="bg-gray-900 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
{{ template "header.tpl.html" . }}

View File

@ -3,7 +3,7 @@
{{ template "head.tpl.html" . }}
<body class="relative bg-gray-850 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-lg mx-auto justify-center">
<body class="relative bg-gray-900 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-lg mx-auto justify-center">
{{ template "header.tpl.html" . }}
@ -11,17 +11,17 @@
<div class="absolute flex top-0 right-0 mr-8 mt-10 py-2">
<div class="mx-1">
<a href="login" class="py-1 px-3 h-8 block rounded border border-green-700 text-white text-sm">
<a href="login" class="py-2 px-4 block rounded bg-green-700 hover:bg-green-800 text-white text-sm">
<span class="iconify inline" data-icon="fxemoji:key"></span> &nbsp;Login</a>
</div>
</div>
<main class="mt-10 flex-grow flex justify-center w-full">
<main class="mt-10 px-16 flex-grow flex justify-center w-full">
<div class="flex flex-col text-white">
<h1 class="text-4xl font-semibold antialiased text-center mb-2">Keep Track of <span
class="text-green-700">Your</span> Coding Time <span class="iconify inline" data-icon="flat-color-icons:clock"></span></h1>
<p class="text-center text-gray-500 text-xl my-2">Wakapi is an open-source tool that helps you keep track of the
time you have spent coding on different projects in different programming languages and more. Ideal for
time you have spent coding on different projects in different programming languages and more.<br>Ideal for
statistics freaks and anyone else.</p>
<p class="text-center text-gray-500 text-xl my-4">
@ -39,19 +39,19 @@
<div class="flex justify-center mt-4 mb-8 space-x-2">
<a href="login">
<button type="button"
class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white font-semibold"><span class="iconify inline" data-icon="fxemoji:rocket"></span> Try it!
class="py-2 px-4 rounded bg-green-700 hover:bg-green-800 text-white font-semibold"><span class="iconify inline" data-icon="fxemoji:rocket"></span> Try it!
</button>
</a>
<a href="https://github.com/muety/wakapi#%EF%B8%8F-how-to-use" target="_blank" rel="noopener noreferrer">
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white"><span class="iconify inline" data-icon="fxemoji:satelliteantenna"></span> Host it
<button type="button" class="py-2 px-4 rounded bg-gray-800 hover:bg-gray-850 text-white"><span class="iconify inline" data-icon="fxemoji:satelliteantenna"></span> Host it
</button>
</a>
<a href="https://github.com/sponsors/muety" target="_blank" rel="noopener noreferrer">
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white"><span class="iconify inline" data-icon="flat-color-icons:donate"></span> Support it
<button type="button" class="py-2 px-4 rounded bg-gray-800 hover:bg-gray-850 text-white"><span class="iconify inline" data-icon="flat-color-icons:donate"></span> Support it
</button>
</a>
<a href="https://github.com/muety/wakapi" target="_blank" rel="noopener noreferrer">
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white">
<button type="button" class="py-2 px-4 rounded bg-gray-800 hover:bg-gray-850 text-white">
<span class="iconify inline text-white" data-icon="codicon:github-inverted"></span>
</button>
</a>

View File

@ -3,44 +3,38 @@
{{ template "head.tpl.html" . }}
<body class="bg-gray-850 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-lg mx-auto justify-center">
<body class="bg-gray-900 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-lg mx-auto justify-center">
{{ template "header.tpl.html" . }}
<div class="w-full flex justify-center">
<div class="flex items-center justify-between max-w-lg flex-grow">
<div><a onclick="window.history.back()" class="text-gray-500 text-sm cursor-pointer">&larr; Go back</a></div>
<div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Login</h1></div>
<div></div>
</div>
</div>
{{ template "alerts.tpl.html" . }}
<main class="mt-10 flex-grow flex justify-center w-full">
<div class="flex-grow max-w-lg mt-10">
<div class="mb-8">
<h1 class="font-semibold text-2xl text-white m-0">Welcome!</h1>
<span class="text-gray-600">Log in to continue using Wakapi</span>
</div>
<form action="login" method="post">
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="username">Username</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
<div class="mb-4">
<input class="appearance-none bg-gray-850 focus:bg-gray-800 text-gray-300 outline-none rounded w-full py-2 px-4"
type="text" id="username" autocomplete="username"
name="username" placeholder="Enter your username" minlength="1" required autofocus>
name="username" placeholder="Username" minlength="1" required autofocus>
</div>
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="password">Password</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
<div class="mb-4">
<input class="appearance-none bg-gray-850 focus:bg-gray-800 text-gray-300 outline-none rounded w-full py-2 px-4"
type="password" id="password" autocomplete="current-password"
name="password" placeholder="******" minlength="6" required>
name="password" placeholder="Password" minlength="6" required>
</div>
<div class="flex justify-between items-center">
<a href="reset-password" class="text-gray-500 text-sm">
<a href="reset-password" class="text-gray-600 text-sm">
Forgot password?
</a>
<div class="flex space-x-2">
<a href="signup">
<button type="button" class="py-1 px-3 rounded border border-green-700 text-white text-sm">Sign up</button>
<button type="button" class="py-2 px-4 font-semibold rounded bg-gray-800 hover:bg-gray-850 text-white text-sm">Sign up</button>
</a>
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">Log in</button>
<button type="submit" class="py-2 px-4 font-semibold rounded bg-green-700 hover:bg-green-800 text-white text-sm">Log in</button>
</div>
</div>
</form>

3
views/logo.tpl.html Normal file
View File

@ -0,0 +1,3 @@
<a id="logo-container" class="text-2xl font-semibold text-white inline-block align-middle" href="">
<img src="assets/images/logo.svg" width="110px" alt="Logo">
</a>

84
views/menu-main.tpl.html Normal file
View File

@ -0,0 +1,84 @@
<div class="flex justify-between space-x-4 items-center relative">
<div class="mr-8">
{{ template "logo.tpl.html" }}
</div>
<div class="menu-item flex items-center text-sm font-semibold space-x-2 rounded hover:bg-gray-850 py-2 px-4 cursor-pointer">
<span class="iconify inline text-2xl text-gray-400" data-icon="ic:round-dashboard"></span>
<a class="text-gray-300" href="summary">Dashboard</a>
</div>
<div class="menu-item flex items-center text-sm font-semibold space-x-2 rounded hover:bg-gray-850 py-2 px-4 cursor-not-allowed">
<span class="iconify inline text-2xl text-gray-700" data-icon="bi:people-fill"></span>
<a class="text-gray-600 leading-none">
Team<br>
<span class="text-xxs">(coming soon)</span>
</a>
</div>
<div class="menu-item flex items-center text-sm font-semibold space-x-2 rounded hover:bg-gray-850 py-2 px-4 cursor-not-allowed">
<span class="iconify inline text-2xl text-gray-700" data-icon="fluent:data-bar-horizontal-24-filled"></span>
<a class="text-gray-600 leading-none">
Leaderboard<br>
<span class="text-xxs">(coming soon)</span>
</a>
</div>
<div class="menu-item flex items-center text-sm font-semibold space-x-2 rounded hover:bg-gray-850 py-2 px-4 cursor-pointer">
<span class="iconify inline text-2xl text-gray-400" data-icon="ph:books-bold"></span>
<a class="text-gray-400">Resources</a>
<span class="iconify inline text-xl text-gray-400" data-icon="akar-icons:chevron-down"></span>
</div>
<div class="menu-item flex items-center text-sm font-semibold space-x-2 rounded hover:bg-gray-850 py-2 px-4 cursor-pointer">
<span class="iconify inline text-2xl text-gray-400" data-icon="ci:settings-filled"></span>
<a class="text-gray-400" href="settings">Settings</a>
</div>
<div class="flex-grow"></div>
<div class="menu-item flex items-center text-sm font-semibold space-x-3 rounded hover:bg-gray-850 py-2 px-4 cursor-pointer" onclick="showUserMenuPopup(event)">
<div class="flex flex-col text-right">
<a class="text-gray-300">{{ .User.ID }}</a>
{{ if .User.Email }}
<span class="text-xxs text-gray-500">{{ .User.Email }}</span>
{{ end }}
</div>
{{ if avatarUrlTemplate }}
<img src="{{ .User.AvatarURL avatarUrlTemplate }}" width="32px" class="rounded-full border-green-700" alt="User Profile Avatar" title="Looks like you, doesn't it?"/>
{{ else }}
<span class="iconify inline cursor-pointer text-gray-500 rounded-full border-green-700" style="width: 32px; height: 32px" data-icon="ic:round-person" onclick="showUserMenuPopup(event)"></span>
{{ end }}
</div>
<div class="hidden flex bg-gray-850 shadow-md z-10 p-2 absolute top-0 right-0 rounded popup mt-14"
id="user-menu-popup" style="min-width: 156px;">
<div class="flex-grow flex flex-col">
<div class="submenu-item hover:bg-gray-800 rounded p-1 text-right">
<button class="flex justify-between w-full text-gray-300 items-center px-2 font-semibold" onclick="showApiKeyPopup(event); hideUserMenuPopup(event)">
<span class="text-sm">Show API Key</span>
<span class="iconify inline" data-icon="fluent:key-32-filled"></span>
</button>
</div>
<div class="submenu-item hover:bg-gray-800 rounded p-1 text-right">
<form action="logout" method="post" class="flex-grow">
<button type="submit" class="flex justify-between w-full text-gray-300 items-center px-2 font-semibold">
<span class="text-sm">Logout</span>
<span class="iconify inline" data-icon="ls:logout"></span>
</button>
</form>
</div>
</div>
</div>
<div class="hidden flex bg-gray-850 shadow-md z-10 p-2 absolute top-0 right-0 rounded popup" id="api-key-popup">
<div class="flex-grow flex flex-col px-2">
<span class="text-xxs text-gray-500 mx-1">API Key</span>
<input type="text" class="bg-transparent text-sm text-white mx-1 font-mono" id="api-key-container" readonly
value="{{ .ApiKey }}" style="min-width: 330px">
</div>
<div class="flex items-center px-2 border-l border-gray-700">
<button title="Copy to clipboard" onclick="copyApiKey(event)"><span class="iconify inline text-gray-300" data-icon="majesticons:clipboard-copy"></span></button>
</div>
</div>
</div>

View File

@ -3,7 +3,7 @@
{{ template "head.tpl.html" . }}
<body class="bg-gray-850 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-lg mx-auto justify-center">
<body class="bg-gray-900 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-lg mx-auto justify-center">
{{ template "header.tpl.html" . }}

View File

@ -3,7 +3,7 @@
{{ template "head.tpl.html" . }}
<body class="bg-gray-850 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-lg mx-auto justify-center">
<body class="bg-gray-900 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-lg mx-auto justify-center">
{{ template "header.tpl.html" . }}

View File

@ -4,7 +4,7 @@
{{ template "head.tpl.html" . }}
<script src="assets/timezones.js"></script>
<body class="bg-gray-850 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
<body class="bg-gray-900 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
<style>
.inline-bullet-list li a {

View File

@ -3,63 +3,61 @@
{{ template "head.tpl.html" . }}
<body class="bg-gray-850 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-lg mx-auto justify-center">
<body class="bg-gray-900 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-lg mx-auto justify-center">
{{ template "header.tpl.html" . }}
<div class="w-full flex justify-center">
<div class="flex items-center justify-between max-w-lg flex-grow">
<div><a onclick="window.history.back()" class="text-gray-500 text-sm cursor-pointer">&larr; Go back</a></div>
<div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Sign Up</h1></div>
<div></div>
</div>
</div>
{{ template "alerts.tpl.html" . }}
<main class="mt-10 flex-grow flex justify-center w-full">
<div class="flex-grow max-w-lg mt-8">
<div>
<p class="text-sm text-gray-300">
💡 In order to use Wakapi, you need to create an account.
After successful signup, you still need to set up the <a href="https://wakatime.com" target="_blank"
rel="noopener noreferrer"
class="border-b border-green-700">WakaTime</a>
client tools.
Please refer to <a href="https://github.com/muety/wakapi#-client-setup" target="_blank"
<div class="flex-grow max-w-lg mt-10">
<div class="mb-8">
<h1 class="font-semibold text-2xl text-white m-0 mb-2">Sign up to Wakapi</h1>
<p class="text-sm text-gray-600">
Welcome to Wakapi! Your first step is to create an account.
Afterwards, make sure to set up the <a href="https://wakatime.com" target="_blank" rel="noopener noreferrer" class="text-gray-300 hover:text-gray-400">WakaTime</a> client tools.
Instruction can be found in our <a href="https://github.com/muety/wakapi#-client-setup" target="_blank"
rel="noopener noreferrer"
class="border-b border-green-700">this readme section</a> for instructions.
You will be able to view you <strong>API Key</strong> once you log in.
class="text-gray-300 hover:text-gray-400">README</a>.
</p>
</div>
<div>
</div>
<form class="mt-10" action="signup" method="post">
<input type="hidden" name="location" id="input-location">
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="username">Username</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
type="text" id="username"
name="username" placeholder="Choose a username" minlength="1" required autofocus>
<div class="flex space-x-4">
<div class="mt-1">
<img id="avatar" src="assets/images/unknown.svg" width="96px" class="rounded-full border-4 border-green-700 cursor-pointer" alt="User Profile Avatar" title="Your Avatar"/>
</div>
<div>
<div class="mb-4">
<input class="appearance-none bg-gray-850 focus:bg-gray-800 text-gray-300 outline-none rounded w-full py-2 px-4"
type="text" id="username"
name="username" placeholder="Choose a username" minlength="1"
onkeyup="updateAvatar()"
required autofocus>
</div>
<div class="mb-4">
<input class="appearance-none bg-gray-850 focus:bg-gray-800 text-gray-300 outline-none rounded w-full py-2 px-4"
type="email" id="email"
name="email" onkeyup="updateAvatar()" placeholder="Your e-mail address">
<div class="text-xs text-gray-600 mt-2">E-Mail address is optional, but required for some weekly reports and password reset.</div>
</div>
</div>
</div>
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="email">E-Mail</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
type="email" id="email"
name="email" placeholder="Optionally add your e-mail address">
<div class="text-xs text-gray-500 mt-2 italic">E-Mail address is optional, but required for some features that you cannot use else. Also, if you do not add an e-mail address, you will not be able to reset your password in case you forget it.</div>
</div>
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="password">Password</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
<div class="mb-4">
<input class="appearance-none bg-gray-850 focus:bg-gray-800 text-gray-300 outline-none rounded w-full py-2 px-4"
type="password" id="password"
name="password" placeholder="Choose a password" minlength="6" required>
</div>
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="password_repeat">And again ...</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
<div class="mb-4">
<input class="appearance-none bg-gray-850 focus:bg-gray-800 text-gray-300 outline-none rounded w-full py-2 px-4"
type="password" id="password_repeat"
name="password_repeat" placeholder="Repeat your password" minlength="6" required>
name="password_repeat" placeholder="And again..." minlength="6" required>
</div>
{{ if eq .TotalUsers 0 }}
@ -68,8 +66,11 @@
</p>
{{ end }}
<div class="flex justify-between float-right">
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
<div class="flex space-x-2 justify-end">
<a href="login">
<button type="button" class="py-2 px-4 font-semibold rounded bg-gray-800 hover:bg-gray-850 text-white text-sm">Log in</button>
</a>
<button type="submit" class="py-2 px-4 font-semibold rounded bg-green-700 hover:bg-green-800 text-white text-sm">
Create Account
</button>
</div>
@ -82,11 +83,50 @@
{{ template "foot.tpl.html" . }}
<script type="text/javascript">
// @formatter:off
const MD5 = function(d){var r = M(V(Y(X(d),8*d.length)));return r.toLowerCase()};function M(d){for(var _,m="0123456789ABCDEF",f="",r=0;r<d.length;r++)_=d.charCodeAt(r),f+=m.charAt(_>>>4&15)+m.charAt(15&_);return f}function X(d){for(var _=Array(d.length>>2),m=0;m<_.length;m++)_[m]=0;for(m=0;m<8*d.length;m+=8)_[m>>5]|=(255&d.charCodeAt(m/8))<<m%32;return _}function V(d){for(var _="",m=0;m<32*d.length;m+=8)_+=String.fromCharCode(d[m>>5]>>>m%32&255);return _}function Y(d,_){d[_>>5]|=128<<_%32,d[14+(_+64>>>9<<4)]=_;for(var m=1732584193,f=-271733879,r=-1732584194,i=271733878,n=0;n<d.length;n+=16){var h=m,t=f,g=r,e=i;f=md5_ii(f=md5_ii(f=md5_ii(f=md5_ii(f=md5_hh(f=md5_hh(f=md5_hh(f=md5_hh(f=md5_gg(f=md5_gg(f=md5_gg(f=md5_gg(f=md5_ff(f=md5_ff(f=md5_ff(f=md5_ff(f,r=md5_ff(r,i=md5_ff(i,m=md5_ff(m,f,r,i,d[n+0],7,-680876936),f,r,d[n+1],12,-389564586),m,f,d[n+2],17,606105819),i,m,d[n+3],22,-1044525330),r=md5_ff(r,i=md5_ff(i,m=md5_ff(m,f,r,i,d[n+4],7,-176418897),f,r,d[n+5],12,1200080426),m,f,d[n+6],17,-1473231341),i,m,d[n+7],22,-45705983),r=md5_ff(r,i=md5_ff(i,m=md5_ff(m,f,r,i,d[n+8],7,1770035416),f,r,d[n+9],12,-1958414417),m,f,d[n+10],17,-42063),i,m,d[n+11],22,-1990404162),r=md5_ff(r,i=md5_ff(i,m=md5_ff(m,f,r,i,d[n+12],7,1804603682),f,r,d[n+13],12,-40341101),m,f,d[n+14],17,-1502002290),i,m,d[n+15],22,1236535329),r=md5_gg(r,i=md5_gg(i,m=md5_gg(m,f,r,i,d[n+1],5,-165796510),f,r,d[n+6],9,-1069501632),m,f,d[n+11],14,643717713),i,m,d[n+0],20,-373897302),r=md5_gg(r,i=md5_gg(i,m=md5_gg(m,f,r,i,d[n+5],5,-701558691),f,r,d[n+10],9,38016083),m,f,d[n+15],14,-660478335),i,m,d[n+4],20,-405537848),r=md5_gg(r,i=md5_gg(i,m=md5_gg(m,f,r,i,d[n+9],5,568446438),f,r,d[n+14],9,-1019803690),m,f,d[n+3],14,-187363961),i,m,d[n+8],20,1163531501),r=md5_gg(r,i=md5_gg(i,m=md5_gg(m,f,r,i,d[n+13],5,-1444681467),f,r,d[n+2],9,-51403784),m,f,d[n+7],14,1735328473),i,m,d[n+12],20,-1926607734),r=md5_hh(r,i=md5_hh(i,m=md5_hh(m,f,r,i,d[n+5],4,-378558),f,r,d[n+8],11,-2022574463),m,f,d[n+11],16,1839030562),i,m,d[n+14],23,-35309556),r=md5_hh(r,i=md5_hh(i,m=md5_hh(m,f,r,i,d[n+1],4,-1530992060),f,r,d[n+4],11,1272893353),m,f,d[n+7],16,-155497632),i,m,d[n+10],23,-1094730640),r=md5_hh(r,i=md5_hh(i,m=md5_hh(m,f,r,i,d[n+13],4,681279174),f,r,d[n+0],11,-358537222),m,f,d[n+3],16,-722521979),i,m,d[n+6],23,76029189),r=md5_hh(r,i=md5_hh(i,m=md5_hh(m,f,r,i,d[n+9],4,-640364487),f,r,d[n+12],11,-421815835),m,f,d[n+15],16,530742520),i,m,d[n+2],23,-995338651),r=md5_ii(r,i=md5_ii(i,m=md5_ii(m,f,r,i,d[n+0],6,-198630844),f,r,d[n+7],10,1126891415),m,f,d[n+14],15,-1416354905),i,m,d[n+5],21,-57434055),r=md5_ii(r,i=md5_ii(i,m=md5_ii(m,f,r,i,d[n+12],6,1700485571),f,r,d[n+3],10,-1894986606),m,f,d[n+10],15,-1051523),i,m,d[n+1],21,-2054922799),r=md5_ii(r,i=md5_ii(i,m=md5_ii(m,f,r,i,d[n+8],6,1873313359),f,r,d[n+15],10,-30611744),m,f,d[n+6],15,-1560198380),i,m,d[n+13],21,1309151649),r=md5_ii(r,i=md5_ii(i,m=md5_ii(m,f,r,i,d[n+4],6,-145523070),f,r,d[n+11],10,-1120210379),m,f,d[n+2],15,718787259),i,m,d[n+9],21,-343485551),m=safe_add(m,h),f=safe_add(f,t),r=safe_add(r,g),i=safe_add(i,e)}return Array(m,f,r,i)}function md5_cmn(d,_,m,f,r,i){return safe_add(bit_rol(safe_add(safe_add(_,d),safe_add(f,i)),r),m)}function md5_ff(d,_,m,f,r,i,n){return md5_cmn(_&m|~_&f,d,_,r,i,n)}function md5_gg(d,_,m,f,r,i,n){return md5_cmn(_&f|m&~f,d,_,r,i,n)}function md5_hh(d,_,m,f,r,i,n){return md5_cmn(_^m^f,d,_,r,i,n)}function md5_ii(d,_,m,f,r,i,n){return md5_cmn(m^(_|~f),d,_,r,i,n)}function safe_add(d,_){var m=(65535&d)+(65535&_);return(d>>16)+(_>>16)+(m>>16)<<16|65535&m}function bit_rol(d,_){return d<<_|d>>>32-_}
function guessTimezone() {
return Intl.DateTimeFormat().resolvedOptions().timeZone
}
document.getElementById('input-location').setAttribute('value', guessTimezone())
// Avatar
let debounceTimeout
const avatarEl = document.getElementById('avatar')
const usernameInput = document.getElementById('username')
const emailInput = document.getElementById('email')
const defaultAvatarUrl = 'assets/images/unknown.svg'
const avatarUrlTemplate = {{ avatarUrlTemplate }}
function updateAvatar() {
if (!avatarUrlTemplate) return
if (debounceTimeout) {
clearTimeout(debounceTimeout)
}
debounceTimeout = setTimeout(() => {
let url = avatarUrlTemplate
if ((url.includes('{username') && !usernameInput.value) || (url.includes('{email') && !emailInput.value)) {
url = defaultAvatarUrl
} else {
url = url.replaceAll('{username}', usernameInput.value)
url = url.replaceAll('{email}', emailInput.value)
url = url.replaceAll('{username_hash}', MD5(usernameInput.value))
url = url.replaceAll('{email_hash}', MD5(emailInput.value))
url = url.includes('{') ? defaultAvatarUrl : url
}
avatarEl.src = url
}, 500)
}
</script>
</body>

View File

@ -3,93 +3,44 @@
{{ template "head.tpl.html" . }}
<body class="relative bg-gray-850 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
<body class="relative bg-gray-900 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
{{ template "header.tpl.html" . }}
<div class="hidden flex bg-gray-800 shadow-md z-10 p-2 absolute top-0 right-0 mt-10 mr-8 border border-green-700 rounded popup"
id="api-key-popup">
<div class="flex-grow flex flex-col px-2">
<span class="text-xs text-gray-500 mx-1">API Key</span>
<input type="text" class="bg-transparent text-sm text-white mx-1 font-mono" id="api-key-container" readonly
value="{{ .ApiKey }}" style="min-width: 330px">
</div>
<div class="flex items-center px-2 border-l border-gray-700">
<button title="Copy to clipboard" onclick="copyApiKey(event)"><span class="iconify inline" data-icon="fxemoji:clipboard"></span></button>
</div>
</div>
<div class="hidden flex bg-gray-800 shadow-md z-10 p-2 absolute top-0 right-0 mt-10 mr-8 border border-green-700 rounded popup mt-24"
id="user-menu-popup" style="min-width: 200px;">
<div class="flex-grow flex flex-col px-2">
<div class="flex flex-col text-xs text-gray-300 mx-1 mb-4 items-center">
<span class="font-semibold">{{ .User.ID }}</span>
{{ if .User.Email }}
<span>({{ .User.Email }})</span>
{{ end }}
</div>
<form action="logout" method="post" class="flex-grow">
<button type="submit" class="py-1 px-3 h-8 rounded bg-green-700 text-white text-sm w-full">
<span>Logout</span>
<span class="iconify inline" data-icon="fxemoji:running"></span>
</button>
</form>
</div>
</div>
<div class="absolute flex top-0 right-0 mr-8 mt-10 py-2">
<div class="mx-1">
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white text-sm"
onclick="showApiKeyPopup(event)"><span class="iconify inline" data-icon="fxemoji:lockandkey"></span>
</button>
</div>
<div class="mx-1">
<a href="settings" class="py-1 px-3 h-8 block rounded border border-green-700 text-white text-sm">
<span class="iconify inline" data-icon="twemoji:gear"></span>
</a>
</div>
<div class="mx-1 flex items-center">
{{ if avatarUrlTemplate }}
<img src="{{ .User.AvatarURL avatarUrlTemplate }}" width="32px" class="rounded-full border-2 border-green-700 cursor-pointer" onclick="showUserMenuPopup(event)" alt="User Profile Avatar" title="Looks like you, doesn't it?"/>
{{ else }}
<span class="iconify inline cursor-pointer text-gray-500 rounded-full border-2 border-green-700" style="width: 32px; height: 32px" data-icon="ic:round-person" onclick="showUserMenuPopup(event)"></span>
{{ end }}
</div>
</div>
<div class="flex items-center justify-center">
<h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Summary</h1>
</div>
{{ template "menu-main.tpl.html" . }}
{{ if .User.HasData }}
<div class="self-center border border-gray-700 shadow mt-8 rounded-md p-4 bg-gray-900">
<form class="text-white flex flex-nowrap items-center justify-center self-center max-w-xl flex-wrap space-x-8">
<div class="flex space-x-1">
<label for="from-date-picker" class="text-gray-300 pl-1"><span class="iconify inline" data-icon="noto:play-button"></span> Start:</label>
<input id="from-date-picker" type="date" name="from" max="{{ .ToTime.T | simpledate }}" class="text-sm text-gray-300 bg-gray-800 rounded-md text-center cursor-pointer"
value="{{ .From | simpledate }}" required>
</div>
<div class="flex space-x-1">
<label for="to-date-picker" class="text-gray-300 pl-1"><span class="iconify inline" data-icon="noto:stop-button"></span> End:</label>
<input id="to-date-picker" type="date" name="to" min="{{ .FromTime.T | simpledate }}" class="text-sm text-gray-300 bg-gray-800 rounded-md text-center cursor-pointer"
value="{{ .To | ceildate | simpledate }}" required>
</div>
<div>
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">Show</button>
</div>
</form>
<div class="flex justify-end mt-12 relative">
<div class="menu-item flex items-center text-sm font-semibold space-x-2 rounded hover:bg-gray-850 py-2 px-4 cursor-pointer justify-end" onclick="toggleTimePickerPopup(event)">
<span class="iconify inline text-2xl text-gray-400 flex-grow" data-icon="fa-regular:calendar-alt"></span>
<a id="current-time-selection" class="text-gray-300 -mb-1">{{ .From | datetime }} - {{ .To | ceildate | datetime }}</a>
<span class="iconify inline text-2xl text-gray-400" data-icon="akar-icons:chevron-down"></span>
</div>
<div class="text-gray-300 text-sm flex items-center justify-center mt-4 self-center max-w-lg flex-wrap">
<a href="summary?interval=today" class="px-1 my-1 mx-1 border-b hover:border-b-2 border-gray-700 hover:bg-green-700 rounded hover:border-none">Today</a>
<a href="summary?interval=yesterday" class="px-1 my-1 mx-1 border-b hover:border-b-2 border-gray-700 hover:bg-green-700 rounded hover:border-none">Yesterday</a>
<a href="summary?interval=week" class="px-1 my-1 mx-1 border-b hover:border-b-2 border-gray-700 hover:bg-green-700 rounded hover:border-none">This Week</a>
<a href="summary?interval=month" class="px-1 my-1 mx-1 border-b hover:border-b-2 border-gray-700 hover:bg-green-700 rounded hover:border-none">This Month</a>
<a href="summary?interval=year" class="px-1 my-1 mx-1 border-b hover:border-b-2 border-gray-700 hover:bg-green-700 rounded hover:border-none">This Year</a>
<a href="summary?interval=last_7_days" class="px-1 my-1 mx-1 border-b hover:border-b-2 border-gray-700 hover:bg-green-700 rounded hover:border-none">Past 7 Days</a>
<a href="summary?interval=last_30_days" class="px-1 my-1 mx-1 border-b hover:border-b-2 border-gray-700 hover:bg-green-700 rounded hover:border-none">Past 30 Days</a>
<a href="summary?interval=last_12_months" class="px-1 my-1 mx-1 border-b hover:border-b-2 border-gray-700 hover:bg-green-700 rounded hover:border-none">Past 12 Months</a>
<a href="summary?interval=any" class="px-1 my-1 mx-1 border-b hover:border-b-2 border-gray-700 hover:bg-green-700 rounded hover:border-none">All Time</a>
<div class="hidden z-10 absolute top-0 right-0 popup mt-12 w-40" id="time-picker-popup">
<div class="flex-grow flex flex-col flex bg-gray-850 shadow-md rounded w-40 p-1 ">
<a id="time-option-today" class="submenu-item hover:bg-gray-800 rounded p-1 text-right w-full text-gray-300 px-2 font-semibold text-sm" href="summary?interval=today" onclick="toggleTimePickerPopup(event)">Today</a>
<a id="time-option-yesterday" class="submenu-item hover:bg-gray-800 rounded p-1 text-right w-full text-gray-300 px-2 font-semibold text-sm" href="summary?interval=yesterday" onclick="toggleTimePickerPopup(event)">Yesterday</a>
<a id="time-option-week" class="submenu-item hover:bg-gray-800 rounded p-1 text-right w-full text-gray-300 px-2 font-semibold text-sm" href="summary?interval=week" onclick="toggleTimePickerPopup(event)">This Week</a>
<a id="time-option-month" class="submenu-item hover:bg-gray-800 rounded p-1 text-right w-full text-gray-300 px-2 font-semibold text-sm" href="summary?interval=month" onclick="toggleTimePickerPopup(event)">This Month</a>
<a id="time-option-year" class="submenu-item hover:bg-gray-800 rounded p-1 text-right w-full text-gray-300 px-2 font-semibold text-sm" href="summary?interval=year" onclick="toggleTimePickerPopup(event)">This Year</a>
<a id="time-option-last_7_days" class="submenu-item hover:bg-gray-800 rounded p-1 text-right w-full text-gray-300 px-2 font-semibold text-sm" href="summary?interval=last_7_days" onclick="toggleTimePickerPopup(event)">Past 7 Days</a>
<a id="time-option-last_30_days" class="submenu-item hover:bg-gray-800 rounded p-1 text-right w-full text-gray-300 px-2 font-semibold text-sm" href="summary?interval=last_30_days" onclick="toggleTimePickerPopup(event)">Past 30 Days</a>
<a id="time-option-last_12_months" class="submenu-item hover:bg-gray-800 rounded p-1 text-right w-full text-gray-300 px-2 font-semibold text-sm" href="summary?interval=last_12_months" onclick="toggleTimePickerPopup(event)">Past 12 Months</a>
<a id="time-option-any" class="submenu-item hover:bg-gray-800 rounded p-1 text-right w-full text-gray-300 px-2 font-semibold text-sm" href="summary?interval=any" onclick="toggleTimePickerPopup(event)">All Time</a>
<hr class="my-2">
<form id="time-picker-form" class="flex flex-col space-y-1">
<div class="flex flex-col space-x-1 bg-gray-900 rounded p-1 border-2 border-gray-800">
<label for="from-date-picker" class="text-gray-500 text-xs ml-2">Start:</label>
<input id="from-date-picker" type="date" name="from" max="{{ .ToTime.T | simpledate }}" class="text-sm text-gray-300 bg-gray-900 cursor-pointer"
value="{{ .From | simpledate }}" onclick="event.stopPropagation()" oninput="submitTimePicker(event)" required>
</div>
<div class="flex flex-col space-x-1 bg-gray-900 rounded p-1 border-2 border-gray-800">
<label for="to-date-picker" class="text-gray-500 text-xs ml-2">End:</label>
<input id="to-date-picker" type="date" name="to" min="{{ .FromTime.T | simpledate }}" class="text-sm text-gray-300 bg-gray-900 cursor-pointer"
value="{{ .To | ceildate | simpledate }}" onclick="event.stopPropagation()" oninput="submitTimePicker(event)" required>
</div>
</form>
</div>
</div>
</div>
@ -101,23 +52,37 @@
{{ if .User.HasData }}
<span class="text-white text-lg text-gray-300 text-center mb-4">
<span class="text-xl"><span class="iconify inline" data-icon="emojione-v1:alarm-clock"></span>&nbsp;</span>
Showing a total of <span id="total-span" title="Total Hours" class="text-white text-xl font-semibold border-b-2 border-green-700"></span>
<span class="text-sm my-2">
(from <span title="Start Time" class="border-b border-gray-700">{{ .FromTime.T | datetime }}</span> to <span title="End Time" class="border-b border-gray-700">{{ .ToTime.T | datetime }}</span>)
</span>
</span>
<!-- KPIs -->
<div class="flex space-x-6 w-full mb-4 flex-wrap">
<div class="flex flex-col space-y-2 my-1 w-48 p-4 rounded-md p-4 text-gray-300 bg-gray-850 leading-none border-2 border-green-700">
<span class="text-xs text-gray-500 font-semibold">Total Time</span>
<span class="font-semibold text-xl truncate">{{ .TotalTime | duration }}</span>
</div>
<div class="flex flex-col space-y-2 my-1 w-48 p-4 rounded-md p-4 text-gray-300 bg-gray-850 leading-none border-2 border-green-700">
<span class="text-xs text-gray-500 font-semibold">Top Project</span>
<span class="font-semibold text-xl truncate">{{ .MaxByToString 0 }}</span>
</div>
<div class="flex flex-col space-y-2 my-1 w-48 p-4 rounded-md p-4 text-gray-300 bg-gray-850 leading-none border-2 border-green-700">
<span class="text-xs text-gray-500 font-semibold">Top Language</span>
<span class="font-semibold text-xl truncate">{{ .MaxByToString 1 }}</span>
</div>
<div class="flex flex-col space-y-2 my-1 w-48 p-4 rounded-md p-4 text-gray-300 bg-gray-850 leading-none border-2 border-green-700">
<span class="text-xs text-gray-500 font-semibold">Top OS</span>
<span class="font-semibold text-xl truncate">{{ .MaxByToString 3 }}</span>
</div>
<div class="flex flex-col space-y-2 my-1 w-48 p-4 rounded-md p-4 text-gray-300 bg-gray-850 leading-none border-2 border-green-700">
<span class="text-xs text-gray-500 font-semibold">Top Editor</span>
<span class="font-semibold text-xl truncate">{{ .MaxByToString 2 }}</span>
</div>
</div>
<div class="flex flex-wrap justify-center">
<div class="flex flex-wrap w-full justify-center mt-4">
<div class="w-full lg:w-1/2 p-1" style="max-width: 100vw;">
<div class="p-4 pb-10 bg-gray-900 border border-gray-700 text-gray-300 rounded-md shadow m-2 flex flex-col" id="project-container" style="height: 300px">
<div class="p-4 px-6 pb-10 bg-gray-850 text-gray-300 rounded-md shadow flex flex-col" id="project-container" style="height: 608px">
<div class="flex justify-between">
<div class="w-1/4 flex-1"></div>
<span class="font-semibold w-1/2 text-center flex-1 whitespace-no-wrap">Projects</span>
<span class="font-semibold text-lg w-1/2 flex-1 whitespace-no-wrap">Projects</span>
<div class="flex justify-end flex-1 text-xs items-center">
<label for="project-top-picker" class="mr-1">Show:&nbsp;</label>
<input type="number" min="1" id="project-top-picker" data-entity="0" class="w-1/4 top-picker bg-gray-800 rounded-md text-center" value="10">
<input type="number" min="1" id="project-top-picker" data-entity="0" class="top-picker bg-gray-800 rounded-md text-center" value="10">
</div>
</div>
<canvas id="chart-projects" class="mt-2"></canvas>
@ -126,85 +91,85 @@
</div>
</div>
</div>
<div class="w-full lg:w-1/2 p-1" style="max-width: 100vw;">
<div class="p-4 pb-10 bg-gray-900 border border-gray-700 text-gray-300 rounded-md shadow m-2 flex flex-col" id="os-container" style="height: 300px">
<div class="flex justify-between">
<div class="w-1/4 flex-1"></div>
<span class="font-semibold w-1/2 text-center flex-1 whitespace-no-wrap">Operating Systems</span>
<div class="flex justify-end flex-1 text-xs items-center">
<label for="os-top-picker" class="mr-1">Show:&nbsp;</label>
<input type="number" min="1" id="os-top-picker" data-entity="1" class="w-1/4 top-picker bg-gray-800 rounded-md text-center" value="10">
<div class="flex flex-col space-y-2">
<div class="p-4 px-6 pb-10 bg-gray-850 text-gray-300 rounded-md shadow flex flex-col" id="language-container" style="height: 300px">
<div class="flex justify-between">
<span class="font-semibold text-lg w-1/2 flex-1 whitespace-no-wrap">Languages</span>
<div class="flex justify-end flex-1 text-xs items-center">
<input type="number" min="1" id="language-top-picker" data-entity="3" class="top-picker bg-gray-800 rounded-md text-center" value="10">
</div>
</div>
<canvas id="chart-language" class="mt-4"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
</div>
</div>
<canvas id="chart-os"></canvas>
<div class="p-4 px-6 pb-10 bg-gray-850 text-gray-300 rounded-md shadow flex flex-col" id="editor-container" style="height: 300px">
<div class="flex justify-between">
<span class="font-semibold text-lg w-1/2 flex-1 whitespace-no-wrap">Editors</span>
<div class="flex justify-end flex-1 text-xs items-center">
<input type="number" min="1" id="editor-top-picker" data-entity="2" class="top-picker bg-gray-800 rounded-md text-center" value="10">
</div>
</div>
<canvas id="chart-editor" class="mt-4"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
</div>
</div>
</div>
</div>
<div class="w-full lg:w-1/2 p-1" style="max-width: 100vw;">
<div class="p-4 px-6 pb-10 bg-gray-850 text-gray-300 rounded-md shadow flex flex-col" id="os-container" style="height: 300px">
<div class="flex justify-between">
<div>
<span class="font-semibold text-lg w-1/2 flex-1 whitespace-no-wrap mr-1 cursor-pointer">Operating Systems</span>
<span class="font-semibold text-lg w-1/2 flex-1 whitespace-no-wrap ml-1 cursor-pointer text-gray-600" onclick="swapCharts('machine', 'os')">Machines</span>
</div>
<div class="flex justify-end flex-1 text-xs items-center">
<input type="number" min="1" id="os-top-picker" data-entity="1" class="top-picker bg-gray-800 rounded-md text-center" value="10">
</div>
</div>
<canvas id="chart-os" class="mt-4"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
</div>
</div>
</div>
<div class="hidden w-full lg:w-1/2 p-1" style="max-width: 100vw;">
<div class="p-4 px-6 pb-10 bg-gray-850 text-gray-300 rounded-md shadow flex flex-col" id="machine-container" style="height: 300px">
<div class="flex justify-between">
<div>
<span class="font-semibold text-lg w-1/2 flex-1 whitespace-no-wrap mr-1 cursor-pointer text-gray-600" onclick="swapCharts('os', 'machine')">Operating Systems</span>
<span class="font-semibold text-lg w-1/2 flex-1 whitespace-no-wrap ml-1 cursor-pointer">Machines</span>
</div>
<div class="flex justify-end flex-1 text-xs items-center">
<input type="number" min="1" id="machine-top-picker" data-entity="4" class="top-picker bg-gray-800 rounded-md text-center" value="10">
</div>
</div>
<canvas id="chart-machine" class="mt-4"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
</div>
</div>
</div>
<div class="w-full lg:w-1/2 p-1" style="max-width: 100vw;">
<div class="p-4 pb-10 bg-gray-900 border border-gray-700 text-gray-300 rounded-md shadow m-2 flex flex-col relative" id="language-container" style="height: 300px">
<div class="flex justify-between">
<div class="w-1/4 flex-1"></div>
<span class="font-semibold w-1/2 text-center flex-1 whitespace-no-wrap">Languages</span>
<div class="flex justify-end flex-1 text-xs items-center">
<label for="language-top-picker" class="mr-1">Show:&nbsp;</label>
<input type="number" min="1" id="language-top-picker" data-entity="3" class="w-1/4 top-picker bg-gray-800 rounded-md text-center" value="10">
</div>
</div>
<canvas id="chart-language"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
</div>
</div>
</div>
<div class="w-full lg:w-1/2 p-1" style="max-width: 100vw;">
<div class="p-4 pb-10 bg-gray-900 border border-gray-700 text-gray-300 rounded-md shadow m-2 flex flex-col" id="editor-container" style="height: 300px">
<div class="flex justify-between">
<div class="w-1/4 flex-1"></div>
<span class="font-semibold w-1/2 text-center flex-1 whitespace-no-wrap">Editors</span>
<div class="flex justify-end flex-1 text-xs items-center">
<label for="editor-top-picker" class="mr-1">Show:&nbsp;</label>
<input type="number" min="1" id="editor-top-picker" data-entity="2" class="w-1/4 top-picker bg-gray-800 rounded-md text-center" value="10">
</div>
</div>
<canvas id="chart-editor"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
</div>
</div>
</div>
<div class="w-full lg:w-1/2 p-1" style="max-width: 100vw;">
<div class="p-4 pb-10 bg-gray-900 border border-gray-700 text-gray-300 rounded-md shadow m-2 flex flex-col" id="machine-container" style="height: 300px">
<div class="flex justify-between">
<div class="w-1/4 flex-1"></div>
<span class="font-semibold w-1/2 text-center flex-1 whitespace-no-wrap">Machines</span>
<div class="flex justify-end flex-1 text-xs items-center">
<label for="machine-top-picker" class="mr-1">Show:&nbsp;</label>
<input type="number" min="1" id="machine-top-picker" data-entity="4" class="w-1/4 top-picker bg-gray-800 rounded-md text-center" value="10">
</div>
</div>
<canvas id="chart-machine"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
</div>
</div>
</div>
<div class="w-full lg:w-1/2 p-1" style="max-width: 100vw;">
<div class="p-4 pb-10 bg-gray-900 border border-gray-700 text-gray-300 rounded-md shadow m-2 flex flex-col" id="label-container" style="height: 300px">
<div class="flex justify-between">
<div class="w-1/4 flex-1">
<a href="settings#details-labels" class="h-8 inline">
<span class="iconify inline" data-icon="twemoji:gear"></span>
</a>
</div>
<span class="font-semibold w-1/2 text-center flex-1 whitespace-no-wrap">Labels</span>
<div class="p-4 px-6 pb-10 bg-gray-850 text-gray-300 rounded-md shadow flex flex-col" id="label-container" style="height: 300px">
<div class="flex justify-between text-lg">
<span class="font-semibold whitespace-no-wrap">Labels</span>
<a href="settings#details-labels" class="ml-4 h-8 inline flex-grow">
<span class="iconify inline" data-icon="twemoji:gear"></span>
</a>
<div class="flex justify-end flex-1 text-xs items-center">
<label for="label-top-picker" class="mr-1">Show:&nbsp;</label>
<input type="number" min="1" id="label-top-picker" data-entity="5" class="w-1/4 top-picker bg-gray-800 rounded-md text-center" value="10">
<input type="number" min="1" id="label-top-picker" data-entity="5" class="top-picker bg-gray-800 rounded-md text-center" value="10">
</div>
</div>
<canvas id="chart-label"></canvas>
<canvas id="chart-label" class="mt-4"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
</div>
@ -214,18 +179,17 @@
{{ else }}
<div class="max-w-screen-sm flex flex-col items-center mt-12 space-y-8 text-gray-300 text-center">
<div class="max-w-screen-sm flex flex-col items-center mt-12 space-y-8 text-gray-300">
<div class="pb-4">
<img src="assets/images/welcome.svg" width="200px" alt="User welcome illustration">
</div>
<p class="text-sm">
<strong>Welcome to Wakapi! 👋</strong> It looks like there is no data available for the specified time range.<br>If you logged in to Wakapi for the first time, see the setup instructions below on how to get started.
<h1 class="font-semibold text-2xl text-white m-0 w-full">Welcome to Wakapi!</h1>
<p>
It looks like there is no data available for the specified time range.<br>If you logged in to Wakapi for the first time, see the setup instructions below on how to get started.
</p>
<div class="w-full pt-10 flex flex-col space-y-4">
<div>
<h3 class="inline-block font-semibold text-md border-b border-green-700">Setup Instructions</h3>
</div>
<div class="w-full bg-gray-900 text-left rounded-md py-4 px-8 text-xs font-mono shadow-md">
<h1 class="font-semibold text-2xl text-white m-0 mb-2">Setup Instructions</h1>
<div class="w-full bg-gray-850 text-left rounded-md py-4 px-8 text-xs font-mono shadow-md">
# <strong>Step 1:</strong> Download WakaTime plugin for your IDE<br>
# See: https://wakatime.com/plugins<br><br>
@ -238,9 +202,6 @@
# <strong>Step 3:</strong> Start coding and then check back here!
</div>
<p class="pt-4 text-sm">
More at <a href="https://github.com/muety/wakapi" target="_blank" rel="noreferrer noopener" class="font-mono border-b border-green-700">github.com/muety/wakapi</a>.
</p>
</div>
</div>
@ -254,8 +215,6 @@
<script>
const languageColors = {{ .LanguageColors | json }}
const editorColors = {{ .EditorColors | json }}
const osColors = {{ .OSColors | json }}
const wakapiData = {}
wakapiData.projects = {{ .Projects | json }}