mirror of
https://github.com/riwiwa/muzi.git
synced 2026-02-28 11:56:57 -08:00
Compare commits
3 Commits
5c5b295961
...
90121b4fd1
| Author | SHA1 | Date | |
|---|---|---|---|
| 90121b4fd1 | |||
| 78bc1a9974 | |||
| b3c2446add |
13
static/assets/icons/settings.svg
Normal file
13
static/assets/icons/settings.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<!--
|
||||
version: "2.0"
|
||||
unicode: "f69e"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M14.647 4.081a.724 .724 0 0 0 1.08 .448c2.439 -1.485 5.23 1.305 3.745 3.744a.724 .724 0 0 0 .447 1.08c2.775 .673 2.775 4.62 0 5.294a.724 .724 0 0 0 -.448 1.08c1.485 2.439 -1.305 5.23 -3.744 3.745a.724 .724 0 0 0 -1.08 .447c-.673 2.775 -4.62 2.775 -5.294 0a.724 .724 0 0 0 -1.08 -.448c-2.439 1.485 -5.23 -1.305 -3.745 -3.744a.724 .724 0 0 0 -.447 -1.08c-2.775 -.673 -2.775 -4.62 0 -5.294a.724 .724 0 0 0 .448 -1.08c-1.485 -2.439 1.305 -5.23 3.744 -3.745a.722 .722 0 0 0 1.08 -.447c.673 -2.775 4.62 -2.775 5.294 0zm-2.647 4.919a3 3 0 1 0 0 6a3 3 0 0 0 0 -6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 732 B |
14
static/assets/icons/user.svg
Normal file
14
static/assets/icons/user.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<!--
|
||||
version: "2.39"
|
||||
unicode: "fd19"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M12 2a5 5 0 1 1 -5 5l.005 -.217a5 5 0 0 1 4.995 -4.783z" />
|
||||
<path d="M14 14a5 5 0 0 1 5 5v1a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-1a5 5 0 0 1 5 -5h4z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 328 B |
32
static/menu.js
Normal file
32
static/menu.js
Normal file
@@ -0,0 +1,32 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const menuButton = document.getElementById('menuButton');
|
||||
const sideMenu = document.getElementById('sideMenu');
|
||||
const menuOverlay = document.getElementById('menuOverlay');
|
||||
|
||||
function toggleMenu() {
|
||||
menuButton.classList.toggle('active');
|
||||
sideMenu.classList.toggle('active');
|
||||
menuOverlay.classList.toggle('active');
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
menuButton.classList.remove('active');
|
||||
sideMenu.classList.remove('active');
|
||||
menuOverlay.classList.remove('active');
|
||||
}
|
||||
|
||||
if (menuButton) {
|
||||
menuButton.addEventListener('click', toggleMenu);
|
||||
}
|
||||
|
||||
if (menuOverlay) {
|
||||
menuOverlay.addEventListener('click', closeMenu);
|
||||
}
|
||||
|
||||
// Close menu on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
});
|
||||
155
static/style.css
155
static/style.css
@@ -11,6 +11,124 @@
|
||||
margin: 0 auto;
|
||||
width: 70vw;
|
||||
font-family: sans-serif;
|
||||
padding-top: 80px;
|
||||
}
|
||||
|
||||
/* Hamburger Menu Button - left side */
|
||||
.menu-button {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
cursor: pointer;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.menu-button span {
|
||||
display: block;
|
||||
width: 28px;
|
||||
height: 3px;
|
||||
background-color: #AFA;
|
||||
border-radius: 2px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.menu-button.active span:nth-child(1) {
|
||||
transform: rotate(45deg) translate(5px, 6px);
|
||||
}
|
||||
|
||||
.menu-button.active span:nth-child(2) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.menu-button.active span:nth-child(3) {
|
||||
transform: rotate(-45deg) translate(5px, -6px);
|
||||
}
|
||||
|
||||
/* Slide-out Menu */
|
||||
.side-menu {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: -280px;
|
||||
width: 280px;
|
||||
height: 100vh;
|
||||
background-color: #1a1a1a;
|
||||
z-index: 999;
|
||||
transition: left 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 60px;
|
||||
}
|
||||
|
||||
.side-menu.active {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.menu-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.menu-header h3 {
|
||||
margin: 0;
|
||||
color: #AFA;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.menu-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 15px 20px;
|
||||
color: #EEE;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
.menu-item .menu-icon {
|
||||
color: #FFF;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
filter: invert(1) brightness(1.5);
|
||||
}
|
||||
|
||||
/* Menu Overlay */
|
||||
.menu-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 998;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||
}
|
||||
|
||||
.menu-overlay.active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.page_buttons {
|
||||
@@ -146,6 +264,43 @@
|
||||
background: #444;
|
||||
}
|
||||
|
||||
/* Settings Tab Navigation */
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border-bottom: 1px solid #333;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: 12px 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
color: #AFA;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
color: #AFA;
|
||||
}
|
||||
|
||||
.tab-button.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background-color: #AFA;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
margin-top: 15px;
|
||||
padding: 15px;
|
||||
|
||||
51
templates/base.gohtml
Normal file
51
templates/base.gohtml
Normal file
@@ -0,0 +1,51 @@
|
||||
{{define "base"}}
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="/files/style.css" type="text/css">
|
||||
<title>{{.Title}}</title>
|
||||
{{block "head" .}}{{end}}
|
||||
</head>
|
||||
<body>
|
||||
<!-- Hamburger Menu Button -->
|
||||
<div class="menu-button" id="menuButton">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
|
||||
<!-- Slide-out Menu -->
|
||||
<div class="side-menu" id="sideMenu">
|
||||
<div class="menu-header">
|
||||
<h3>muzi</h3>
|
||||
</div>
|
||||
<nav class="menu-nav">
|
||||
{{if .LoggedInUsername}}
|
||||
<a href="/profile/{{.LoggedInUsername}}" class="menu-item">
|
||||
<img src="/files/assets/icons/user.svg" class="menu-icon" alt="Profile">
|
||||
<span>My Profile</span>
|
||||
</a>
|
||||
{{else}}
|
||||
<a href="/login" class="menu-item">
|
||||
<img src="/files/assets/icons/user.svg" class="menu-icon" alt="Login">
|
||||
<span>Login</span>
|
||||
</a>
|
||||
{{end}}
|
||||
<a href="/settings" class="menu-item">
|
||||
<img src="/files/assets/icons/settings.svg" class="menu-icon" alt="Settings">
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Overlay for closing menu -->
|
||||
<div class="menu-overlay" id="menuOverlay"></div>
|
||||
|
||||
<!-- Main Content -->
|
||||
{{ if eq .TemplateName "profile"}}{{block "profile" .}}{{end}}{{end}}
|
||||
{{ if eq .TemplateName "settings"}}{{block "settings" .}}{{end}}{{end}}
|
||||
|
||||
<script src="/files/menu.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -1,12 +1,4 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="/files/style.css" type="text/css">
|
||||
<title>
|
||||
muzi | {{.Username}}'s Profile
|
||||
</title>
|
||||
</head>
|
||||
<body>
|
||||
{{define "profile"}}
|
||||
<div class="profile-top">
|
||||
<img src="{{.Pfp}}" alt="{{.Username}}'s avatar">
|
||||
<div class="username-bio">
|
||||
@@ -20,9 +12,6 @@
|
||||
<h3>{{formatInt .ArtistCount}}</h3> <p>Artists<p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-actions">
|
||||
<a href="/import" class="btn">Import Data</a>
|
||||
</div>
|
||||
<div class="history">
|
||||
<h3>Listening History</h3>
|
||||
<table>
|
||||
@@ -37,7 +26,7 @@
|
||||
<tr>
|
||||
<td>{{index $artists $index}}</td>
|
||||
<td>{{$title}}</td>
|
||||
<td>{{index $times $index}}</td>
|
||||
<td title="{{formatTimestampFull (index $times $index)}}">{{formatTimestamp (index $times $index)}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
@@ -48,5 +37,4 @@
|
||||
{{end}}
|
||||
<a href="/profile/{{.Username}}?page={{add .Page 1}}">Next Page</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
59
templates/settings.gohtml
Normal file
59
templates/settings.gohtml
Normal file
@@ -0,0 +1,59 @@
|
||||
{{define "settings"}}
|
||||
<div class="settings-container">
|
||||
<h1>Settings</h1>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="settings-tabs">
|
||||
<button class="tab-button active" data-tab="import">Import Data</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="tab-content">
|
||||
<!-- Import Data Tab -->
|
||||
<div class="tab-panel active" id="import">
|
||||
<div class="import-section">
|
||||
<h2>Spotify</h2>
|
||||
<p>Import your Spotify listening history from your data export.</p>
|
||||
<form id="spotify-form" method="POST" action="/settings/import/spotify" enctype="multipart/form-data">
|
||||
<input type="file" name="json_files" accept=".json,application/json" multiple required>
|
||||
<button type="submit">Upload Spotify Data</button>
|
||||
</form>
|
||||
|
||||
<div id="spotify-progress" class="progress-container" style="display: none;">
|
||||
<div class="progress-status" id="spotify-progress-status">Initializing...</div>
|
||||
<div class="progress-bar-wrapper">
|
||||
<div class="progress-bar-fill" id="spotify-progress-fill"></div>
|
||||
<div class="progress-text" id="spotify-progress-text">0%</div>
|
||||
</div>
|
||||
<div class="progress-tracks" id="spotify-progress-tracks"></div>
|
||||
<div class="progress-error" id="spotify-progress-error"></div>
|
||||
<div class="progress-success" id="spotify-progress-success"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="import-section">
|
||||
<h2>Last.fm</h2>
|
||||
<p>Import your Last.fm scrobbles.</p>
|
||||
<form id="lastfm-form" method="POST" action="/settings/import/lastfm">
|
||||
<input type="text" name="lastfm_username" placeholder="Last.FM Username" required>
|
||||
<input type="text" name="lastfm_api_key" placeholder="Last.FM API Key" required>
|
||||
<button type="submit">Import from Last.fm</button>
|
||||
</form>
|
||||
|
||||
<div id="lastfm-progress" class="progress-container" style="display: none;">
|
||||
<div class="progress-status" id="lastfm-progress-status">Initializing...</div>
|
||||
<div class="progress-bar-wrapper">
|
||||
<div class="progress-bar-fill" id="lastfm-progress-fill"></div>
|
||||
<div class="progress-text" id="lastfm-progress-text">0%</div>
|
||||
</div>
|
||||
<div class="progress-tracks" id="lastfm-progress-tracks"></div>
|
||||
<div class="progress-error" id="lastfm-progress-error"></div>
|
||||
<div class="progress-success" id="lastfm-progress-success"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/files/import.js"></script>
|
||||
{{end}}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"muzi/db"
|
||||
|
||||
@@ -23,8 +24,11 @@ type ProfileData struct {
|
||||
ArtistCount int
|
||||
Artists []string
|
||||
Titles []string
|
||||
Times []string
|
||||
Times []time.Time
|
||||
Page int
|
||||
Title string
|
||||
LoggedInUsername string
|
||||
TemplateName string
|
||||
}
|
||||
|
||||
// Render a page of the profile in the URL
|
||||
@@ -57,6 +61,9 @@ func profilePageHandler() http.HandlerFunc {
|
||||
var profileData ProfileData
|
||||
profileData.Username = username
|
||||
profileData.Page = pageInt
|
||||
profileData.Title = username + "'s Profile"
|
||||
profileData.LoggedInUsername = getLoggedInUsername(r)
|
||||
profileData.TemplateName = "profile"
|
||||
|
||||
err = db.Pool.QueryRow(
|
||||
r.Context(),
|
||||
@@ -97,10 +104,10 @@ func profilePageHandler() http.HandlerFunc {
|
||||
}
|
||||
profileData.Artists = append(profileData.Artists, artist)
|
||||
profileData.Titles = append(profileData.Titles, title)
|
||||
profileData.Times = append(profileData.Times, time.Time.String())
|
||||
profileData.Times = append(profileData.Times, time.Time)
|
||||
}
|
||||
|
||||
err = templates.ExecuteTemplate(w, "profile.gohtml", profileData)
|
||||
err = templates.ExecuteTemplate(w, "base", profileData)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
27
web/settings.go
Normal file
27
web/settings.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package web
|
||||
|
||||
import "net/http"
|
||||
|
||||
func settingsPageHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
username := getLoggedInUsername(r)
|
||||
if username == "" {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
type data struct {
|
||||
Title string
|
||||
LoggedInUsername string
|
||||
TemplateName string
|
||||
}
|
||||
d := data{
|
||||
Title: "muzi | Settings",
|
||||
LoggedInUsername: username,
|
||||
TemplateName: "settings",
|
||||
}
|
||||
err := templates.ExecuteTemplate(w, "base", d)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
32
web/utils.go
32
web/utils.go
@@ -4,6 +4,7 @@ package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Subtracts two integers
|
||||
@@ -24,3 +25,34 @@ func formatInt(n int) string {
|
||||
return formatInt(n/1000) + "," + fmt.Sprintf("%03d", n%1000)
|
||||
}
|
||||
}
|
||||
|
||||
// Formats timestamps compared to local time
|
||||
func formatTimestamp(timestamp time.Time) string {
|
||||
now := time.Now()
|
||||
duration := now.Sub(timestamp)
|
||||
|
||||
if duration < 24*time.Hour {
|
||||
seconds := int(duration.Seconds())
|
||||
if seconds < 60 {
|
||||
return fmt.Sprintf("%d seconds ago", seconds)
|
||||
}
|
||||
minutes := seconds / 60
|
||||
if minutes < 60 {
|
||||
return fmt.Sprintf("%d minutes ago", minutes)
|
||||
}
|
||||
hours := minutes / 60
|
||||
return fmt.Sprintf("%d hours ago", hours)
|
||||
}
|
||||
|
||||
year := now.Year()
|
||||
if timestamp.Year() == year {
|
||||
return timestamp.Format("2 Jan 3:04pm")
|
||||
}
|
||||
|
||||
return timestamp.Format("2 Jan 2006 3:04pm")
|
||||
}
|
||||
|
||||
// Full timestamp format for browser hover
|
||||
func formatTimestampFull(timestamp time.Time) string {
|
||||
return timestamp.Format("Monday 2 Jan 2006, 3:04pm")
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ func init() {
|
||||
"sub": sub,
|
||||
"add": add,
|
||||
"formatInt": formatInt,
|
||||
"formatTimestamp": formatTimestamp,
|
||||
"formatTimestampFull": formatTimestampFull,
|
||||
}
|
||||
templates = template.Must(template.New("").Funcs(funcMap).ParseGlob("./templates/*.gohtml"))
|
||||
}
|
||||
@@ -80,6 +82,7 @@ func Start() {
|
||||
r.Post("/import/spotify", importSpotifyHandler)
|
||||
r.Get("/import/lastfm/progress", importLastFMProgressHandler)
|
||||
r.Get("/import/spotify/progress", importSpotifyProgressHandler)
|
||||
r.Get("/settings", settingsPageHandler())
|
||||
fmt.Printf("WebUI starting on %s\n", addr)
|
||||
prot := http.NewCrossOriginProtection()
|
||||
http.ListenAndServe(addr, prot.Handler(r))
|
||||
|
||||
Reference in New Issue
Block a user