608 lines
21 KiB
Go
608 lines
21 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mathieu/personotes/internal/indexer"
|
|
"github.com/yuin/goldmark"
|
|
"github.com/yuin/goldmark/extension"
|
|
"github.com/yuin/goldmark/parser"
|
|
"github.com/yuin/goldmark/renderer/html"
|
|
)
|
|
|
|
// PublicNote représente une note partagée publiquement
|
|
type PublicNote struct {
|
|
Path string `json:"path"`
|
|
Title string `json:"title"`
|
|
PublishedAt time.Time `json:"published_at"`
|
|
}
|
|
|
|
// PublicNotesData contient la liste des notes publiques
|
|
type PublicNotesData struct {
|
|
Notes []PublicNote `json:"notes"`
|
|
}
|
|
|
|
// getPublicDirPath retourne le chemin du dossier public HTML
|
|
func (h *Handler) getPublicDirPath() string {
|
|
return filepath.Join(h.notesDir, "..", "public")
|
|
}
|
|
|
|
// getPublicNotesFilePath retourne le chemin du fichier .public.json
|
|
func (h *Handler) getPublicNotesFilePath() string {
|
|
return filepath.Join(h.notesDir, ".public.json")
|
|
}
|
|
|
|
// loadPublicNotes charge les notes publiques depuis le fichier JSON
|
|
func (h *Handler) loadPublicNotes() (*PublicNotesData, error) {
|
|
path := h.getPublicNotesFilePath()
|
|
|
|
// Si le fichier n'existe pas, retourner une liste vide
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
return &PublicNotesData{Notes: []PublicNote{}}, nil
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var publicNotes PublicNotesData
|
|
if err := json.Unmarshal(data, &publicNotes); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Trier par date de publication (plus récent d'abord)
|
|
sort.Slice(publicNotes.Notes, func(i, j int) bool {
|
|
return publicNotes.Notes[i].PublishedAt.After(publicNotes.Notes[j].PublishedAt)
|
|
})
|
|
|
|
return &publicNotes, nil
|
|
}
|
|
|
|
// savePublicNotes sauvegarde les notes publiques dans le fichier JSON
|
|
func (h *Handler) savePublicNotes(publicNotes *PublicNotesData) error {
|
|
path := h.getPublicNotesFilePath()
|
|
|
|
data, err := json.MarshalIndent(publicNotes, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return os.WriteFile(path, data, 0644)
|
|
}
|
|
|
|
// isPublic vérifie si une note est publique
|
|
func (h *Handler) isPublic(notePath string) bool {
|
|
publicNotes, err := h.loadPublicNotes()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
for _, note := range publicNotes.Notes {
|
|
if note.Path == notePath {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ensurePublicDir crée le dossier public s'il n'existe pas
|
|
func (h *Handler) ensurePublicDir() error {
|
|
publicDir := h.getPublicDirPath()
|
|
|
|
// Créer public/
|
|
if err := os.MkdirAll(publicDir, 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Créer public/static/
|
|
staticDir := filepath.Join(publicDir, "static")
|
|
if err := os.MkdirAll(staticDir, 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// copyStaticAssets copie les fichiers CSS/fonts nécessaires
|
|
func (h *Handler) copyStaticAssets() error {
|
|
publicStaticDir := filepath.Join(h.getPublicDirPath(), "static")
|
|
|
|
// Fichiers à copier
|
|
filesToCopy := []string{
|
|
"static/theme.css",
|
|
"static/themes.css",
|
|
}
|
|
|
|
for _, file := range filesToCopy {
|
|
src := file
|
|
dst := filepath.Join(publicStaticDir, filepath.Base(file))
|
|
|
|
if err := copyFile(src, dst); err != nil {
|
|
h.logger.Printf("Avertissement: impossible de copier %s: %v", file, err)
|
|
// Continuer même si la copie échoue
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// copyFile copie un fichier
|
|
func copyFile(src, dst string) error {
|
|
sourceFile, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer sourceFile.Close()
|
|
|
|
destFile, err := os.Create(dst)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer destFile.Close()
|
|
|
|
_, err = io.Copy(destFile, sourceFile)
|
|
return err
|
|
}
|
|
|
|
// generateNoteHTML génère le fichier HTML pour une note publique
|
|
func (h *Handler) generateNoteHTML(notePath string) error {
|
|
// Lire le fichier Markdown
|
|
absPath := filepath.Join(h.notesDir, notePath)
|
|
content, err := os.ReadFile(absPath)
|
|
if err != nil {
|
|
return fmt.Errorf("lecture fichier: %w", err)
|
|
}
|
|
|
|
// Extraire le front matter et le contenu
|
|
fm, body, err := indexer.ExtractFrontMatterAndBodyFromReader(bytes.NewReader(content))
|
|
if err != nil {
|
|
// Continuer avec le contenu complet si erreur
|
|
body = string(content)
|
|
}
|
|
|
|
// Déterminer le titre
|
|
title := filepath.Base(notePath)
|
|
if fm.Title != "" {
|
|
title = fm.Title
|
|
} else {
|
|
title = strings.TrimSuffix(title, ".md")
|
|
}
|
|
|
|
// Convertir Markdown en HTML avec goldmark
|
|
md := goldmark.New(
|
|
goldmark.WithExtensions(
|
|
extension.GFM,
|
|
extension.Typographer,
|
|
),
|
|
goldmark.WithParserOptions(
|
|
parser.WithAutoHeadingID(),
|
|
),
|
|
goldmark.WithRendererOptions(
|
|
html.WithHardWraps(),
|
|
html.WithXHTML(),
|
|
),
|
|
)
|
|
|
|
var buf bytes.Buffer
|
|
if err := md.Convert([]byte(body), &buf); err != nil {
|
|
return fmt.Errorf("conversion markdown: %w", err)
|
|
}
|
|
|
|
// Générer le HTML complet standalone
|
|
htmlContent := h.generateStandaloneHTML(title, buf.String(), fm.Tags, fm.Date)
|
|
|
|
// Écrire le fichier HTML - structure plate avec seulement le nom du fichier
|
|
filename := filepath.Base(notePath)
|
|
outputPath := filepath.Join(h.getPublicDirPath(), strings.TrimSuffix(filename, ".md")+".html")
|
|
|
|
if err := os.WriteFile(outputPath, []byte(htmlContent), 0644); err != nil {
|
|
return fmt.Errorf("écriture fichier: %w", err)
|
|
}
|
|
|
|
h.logger.Printf("HTML généré: %s", outputPath)
|
|
return nil
|
|
}
|
|
|
|
// deleteNoteHTML supprime le fichier HTML d'une note
|
|
func (h *Handler) deleteNoteHTML(notePath string) error {
|
|
filename := filepath.Base(notePath)
|
|
outputPath := filepath.Join(h.getPublicDirPath(), strings.TrimSuffix(filename, ".md")+".html")
|
|
|
|
if err := os.Remove(outputPath); err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
|
|
h.logger.Printf("HTML supprimé: %s", outputPath)
|
|
return nil
|
|
}
|
|
|
|
// generatePublicIndex génère le fichier index.html avec la liste des notes
|
|
func (h *Handler) generatePublicIndex() error {
|
|
publicNotes, err := h.loadPublicNotes()
|
|
if err != nil {
|
|
return fmt.Errorf("chargement notes publiques: %w", err)
|
|
}
|
|
|
|
// Enrichir avec les métadonnées
|
|
enrichedNotes := []map[string]interface{}{}
|
|
for _, note := range publicNotes.Notes {
|
|
filename := filepath.Base(note.Path)
|
|
item := map[string]interface{}{
|
|
"Path": strings.TrimSuffix(filename, ".md") + ".html",
|
|
"Title": note.Title,
|
|
"PublishedAt": note.PublishedAt.Format("02/01/2006"),
|
|
"Tags": []string{},
|
|
}
|
|
|
|
// Essayer de lire le fichier pour obtenir les métadonnées
|
|
absPath := filepath.Join(h.notesDir, note.Path)
|
|
if content, err := os.ReadFile(absPath); err == nil {
|
|
if fm, _, err := indexer.ExtractFrontMatterAndBodyFromReader(bytes.NewReader(content)); err == nil {
|
|
if fm.Title != "" {
|
|
item["Title"] = fm.Title
|
|
}
|
|
item["Tags"] = fm.Tags
|
|
}
|
|
}
|
|
|
|
enrichedNotes = append(enrichedNotes, item)
|
|
}
|
|
|
|
// Générer le HTML de l'index
|
|
htmlContent := h.generateIndexHTML(enrichedNotes)
|
|
|
|
// Écrire index.html
|
|
indexPath := filepath.Join(h.getPublicDirPath(), "index.html")
|
|
if err := os.WriteFile(indexPath, []byte(htmlContent), 0644); err != nil {
|
|
return fmt.Errorf("écriture index.html: %w", err)
|
|
}
|
|
|
|
h.logger.Printf("Index généré: %s", indexPath)
|
|
return nil
|
|
}
|
|
|
|
// handlePublicList retourne la liste des notes publiques
|
|
func (h *Handler) handlePublicList(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Méthode non autorisée", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
publicNotes, err := h.loadPublicNotes()
|
|
if err != nil {
|
|
h.logger.Printf("Erreur chargement notes publiques: %v", err)
|
|
http.Error(w, "Erreur de chargement", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(publicNotes)
|
|
}
|
|
|
|
// handlePublicToggle bascule le statut public d'une note + génère/supprime HTML
|
|
func (h *Handler) handlePublicToggle(w http.ResponseWriter, r *http.Request) {
|
|
h.logger.Printf("handlePublicToggle appelé")
|
|
|
|
if r.Method != http.MethodPost {
|
|
h.logger.Printf("Méthode non autorisée: %s", r.Method)
|
|
http.Error(w, "Méthode non autorisée", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
h.logger.Printf("Erreur ParseForm: %v", err)
|
|
http.Error(w, "Formulaire invalide", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
path := r.FormValue("path")
|
|
h.logger.Printf("Chemin reçu: %s", path)
|
|
if path == "" {
|
|
h.logger.Printf("Chemin vide")
|
|
http.Error(w, "Chemin requis", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Valider que le fichier existe
|
|
absPath := filepath.Join(h.notesDir, path)
|
|
if _, err := os.Stat(absPath); os.IsNotExist(err) {
|
|
http.Error(w, "Fichier introuvable", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
publicNotes, err := h.loadPublicNotes()
|
|
if err != nil {
|
|
h.logger.Printf("Erreur chargement notes publiques: %v", err)
|
|
http.Error(w, "Erreur de chargement", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Vérifier si la note est déjà publique
|
|
isCurrentlyPublic := false
|
|
newNotes := []PublicNote{}
|
|
for _, note := range publicNotes.Notes {
|
|
if note.Path == path {
|
|
isCurrentlyPublic = true
|
|
} else {
|
|
newNotes = append(newNotes, note)
|
|
}
|
|
}
|
|
|
|
if isCurrentlyPublic {
|
|
// Retirer du public - supprimer le HTML
|
|
publicNotes.Notes = newNotes
|
|
|
|
if err := h.deleteNoteHTML(path); err != nil {
|
|
h.logger.Printf("Erreur suppression HTML: %v", err)
|
|
}
|
|
} else {
|
|
// Ajouter au public - générer le HTML
|
|
title := filepath.Base(path)
|
|
title = strings.TrimSuffix(title, ".md")
|
|
|
|
// Essayer de lire le titre depuis le fichier
|
|
if content, err := os.ReadFile(absPath); err == nil {
|
|
if fm, _, err := indexer.ExtractFrontMatterAndBodyFromReader(bytes.NewReader(content)); err == nil && fm.Title != "" {
|
|
title = fm.Title
|
|
}
|
|
}
|
|
|
|
newNote := PublicNote{
|
|
Path: path,
|
|
Title: title,
|
|
PublishedAt: time.Now(),
|
|
}
|
|
publicNotes.Notes = append(publicNotes.Notes, newNote)
|
|
|
|
// Créer les dossiers nécessaires
|
|
if err := h.ensurePublicDir(); err != nil {
|
|
h.logger.Printf("Erreur création dossiers: %v", err)
|
|
http.Error(w, "Erreur de création des dossiers", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Copier les assets statiques
|
|
if err := h.copyStaticAssets(); err != nil {
|
|
h.logger.Printf("Avertissement copie assets: %v", err)
|
|
}
|
|
|
|
// Générer le HTML de la note
|
|
if err := h.generateNoteHTML(path); err != nil {
|
|
h.logger.Printf("Erreur génération HTML: %v", err)
|
|
http.Error(w, "Erreur de génération HTML", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Sauvegarder le fichier .public.json
|
|
if err := h.savePublicNotes(publicNotes); err != nil {
|
|
h.logger.Printf("Erreur sauvegarde notes publiques: %v", err)
|
|
http.Error(w, "Erreur de sauvegarde", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Régénérer l'index
|
|
if err := h.generatePublicIndex(); err != nil {
|
|
h.logger.Printf("Erreur génération index: %v", err)
|
|
}
|
|
|
|
// Retourner le nouveau statut
|
|
status := "private"
|
|
if !isCurrentlyPublic {
|
|
status = "public"
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{
|
|
"status": status,
|
|
"path": path,
|
|
})
|
|
}
|
|
|
|
// generateStandaloneHTML génère un fichier HTML standalone complet
|
|
func (h *Handler) generateStandaloneHTML(title, content string, tags []string, date string) string {
|
|
tagsHTML := ""
|
|
if len(tags) > 0 {
|
|
tagsHTML = `<div class="public-tags">`
|
|
for _, tag := range tags {
|
|
tagsHTML += fmt.Sprintf(`<span class="tag">#%s</span>`, template.HTMLEscapeString(tag))
|
|
}
|
|
tagsHTML += `</div>`
|
|
}
|
|
|
|
dateHTML := ""
|
|
if date != "" {
|
|
dateHTML = fmt.Sprintf(`<span><i data-lucide="calendar" class="icon-sm"></i> %s</span>`, template.HTMLEscapeString(date))
|
|
}
|
|
|
|
return fmt.Sprintf(`<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>%s - PersoNotes</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
<link rel="stylesheet" href="static/theme.css" />
|
|
<link rel="stylesheet" href="static/themes.css" />
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/styles/atom-one-dark.min.css" />
|
|
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
|
|
<!-- Lucide Icons -->
|
|
<script src="https://unpkg.com/lucide@latest"></script>
|
|
<style>
|
|
body { margin: 0; padding: 0; background: var(--bg-primary, #1e1e1e); color: var(--text-primary, #e0e0e0); font-family: 'JetBrains Mono', monospace; }
|
|
.public-view-container { max-width: 800px; margin: 0 auto; padding: 2rem; }
|
|
.public-nav { margin-bottom: 2rem; }
|
|
.public-nav a { color: var(--accent-blue, #42a5f5); text-decoration: none; display: inline-flex; align-items: center; gap: 0.5rem; }
|
|
.public-nav a:hover { text-decoration: underline; }
|
|
.public-header { margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 2px solid var(--bg-tertiary, #2d2d2d); }
|
|
.public-title { font-size: 2.5rem; margin: 0 0 1rem 0; color: var(--text-primary, #e0e0e0); }
|
|
.public-meta { display: flex; gap: 1.5rem; color: var(--text-secondary, #b0b0b0); font-size: 0.95rem; }
|
|
.public-tags { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 1rem; }
|
|
.tag { background: var(--bg-tertiary, #2d2d2d); color: var(--text-secondary, #b0b0b0); padding: 0.25rem 0.75rem; border-radius: 4px; font-size: 0.85rem; }
|
|
.public-content { line-height: 1.8; color: var(--text-primary, #e0e0e0); font-size: 1.05rem; }
|
|
.public-content h1, .public-content h2, .public-content h3, .public-content h4, .public-content h5, .public-content h6 { color: var(--text-primary, #e0e0e0); margin-top: 2rem; margin-bottom: 1rem; }
|
|
.public-content h1 { font-size: 2rem; } .public-content h2 { font-size: 1.75rem; } .public-content h3 { font-size: 1.5rem; } .public-content h4 { font-size: 1.25rem; }
|
|
.public-content p { margin-bottom: 1rem; }
|
|
.public-content a { color: var(--accent-blue, #42a5f5); text-decoration: none; }
|
|
.public-content a:hover { text-decoration: underline; }
|
|
.public-content pre { background: var(--bg-secondary, #252525); border: 1px solid var(--bg-tertiary, #2d2d2d); border-radius: 6px; padding: 1rem; overflow-x: auto; margin: 1rem 0; }
|
|
.public-content code { font-family: 'JetBrains Mono', monospace; background: var(--bg-secondary, #252525); padding: 0.2rem 0.4rem; border-radius: 3px; font-size: 0.9em; }
|
|
.public-content pre code { background: none; padding: 0; }
|
|
.public-content blockquote { border-left: 4px solid var(--accent-blue, #42a5f5); padding-left: 1rem; margin: 1rem 0; color: var(--text-secondary, #b0b0b0); font-style: italic; }
|
|
.public-content ul, .public-content ol { margin: 1rem 0; padding-left: 2rem; }
|
|
.public-content li { margin-bottom: 0.5rem; }
|
|
.public-content table { width: 100%%; border-collapse: collapse; margin: 1rem 0; }
|
|
.public-content th, .public-content td { border: 1px solid var(--bg-tertiary, #2d2d2d); padding: 0.75rem; text-align: left; }
|
|
.public-content th { background: var(--bg-secondary, #252525); font-weight: 600; }
|
|
.public-content img { max-width: 100%%; height: auto; border-radius: 6px; margin: 1rem 0; }
|
|
.public-content hr { border: none; border-top: 2px solid var(--bg-tertiary, #2d2d2d); margin: 2rem 0; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="public-view-container">
|
|
<div class="public-nav">
|
|
<a href="index.html">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<line x1="19" y1="12" x2="5" y2="12"></line>
|
|
<polyline points="12 19 5 12 12 5"></polyline>
|
|
</svg>
|
|
Back to public notes
|
|
</a>
|
|
</div>
|
|
<article>
|
|
<div class="public-header">
|
|
<h1 class="public-title">%s</h1>
|
|
<div class="public-meta">%s</div>
|
|
%s
|
|
</div>
|
|
<div class="public-content">
|
|
%s
|
|
</div>
|
|
</article>
|
|
</div>
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
document.querySelectorAll('pre code').forEach((block) => {
|
|
hljs.highlightElement(block);
|
|
});
|
|
});
|
|
</script>
|
|
<script>
|
|
// Initialize Lucide icons
|
|
if (typeof lucide !== 'undefined') {
|
|
lucide.createIcons();
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>`, template.HTMLEscapeString(title), template.HTMLEscapeString(title), dateHTML, tagsHTML, content)
|
|
}
|
|
|
|
// generateIndexHTML génère le HTML de la page d'index
|
|
func (h *Handler) generateIndexHTML(notes []map[string]interface{}) string {
|
|
notesHTML := ""
|
|
|
|
if len(notes) > 0 {
|
|
notesHTML = `<ul class="notes-list">`
|
|
for _, note := range notes {
|
|
path, _ := note["Path"].(string)
|
|
title, _ := note["Title"].(string)
|
|
publishedAt, _ := note["PublishedAt"].(string)
|
|
tags := []string{}
|
|
if tagsInterface, ok := note["Tags"].([]string); ok {
|
|
tags = tagsInterface
|
|
}
|
|
|
|
tagsHTML := ""
|
|
if len(tags) > 0 {
|
|
tagsHTML = `<div class="note-tags">`
|
|
for _, tag := range tags {
|
|
tagsHTML += fmt.Sprintf(`<span class="tag">#%s</span>`, template.HTMLEscapeString(tag))
|
|
}
|
|
tagsHTML += `</div>`
|
|
}
|
|
|
|
notesHTML += fmt.Sprintf(`
|
|
<li class="note-item">
|
|
<a href="%s">
|
|
<h2 class="note-title">%s</h2>
|
|
<div class="note-meta">
|
|
<span><i data-lucide="calendar" class="icon-sm"></i> Published on %s</span>
|
|
</div>
|
|
%s
|
|
</a>
|
|
</li>`, path, template.HTMLEscapeString(title), publishedAt, tagsHTML)
|
|
}
|
|
notesHTML += `</ul>`
|
|
} else {
|
|
notesHTML = `
|
|
<div class="empty-state">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
|
<polyline points="14 2 14 8 20 8"></polyline>
|
|
</svg>
|
|
<p>No public notes yet</p>
|
|
</div>`
|
|
}
|
|
|
|
return fmt.Sprintf(`<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Public Notes - PersoNotes</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
<link rel="stylesheet" href="static/theme.css" />
|
|
<link rel="stylesheet" href="static/themes.css" />
|
|
<!-- Lucide Icons -->
|
|
<script src="https://unpkg.com/lucide@latest"></script>
|
|
<style>
|
|
body { margin: 0; padding: 0; background: var(--bg-primary, #1e1e1e); color: var(--text-primary, #e0e0e0); font-family: 'JetBrains Mono', monospace; }
|
|
.public-container { max-width: 900px; margin: 0 auto; padding: 2rem; }
|
|
.public-header { text-align: center; margin-bottom: 3rem; padding-bottom: 1.5rem; border-bottom: 2px solid var(--bg-tertiary, #2d2d2d); }
|
|
.public-header h1 { font-size: 2.5rem; margin: 0 0 0.5rem 0; color: var(--text-primary, #e0e0e0); }
|
|
.public-header p { color: var(--text-secondary, #b0b0b0); font-size: 1.1rem; }
|
|
.notes-list { list-style: none; padding: 0; margin: 0; }
|
|
.note-item { background: var(--bg-secondary, #252525); border: 1px solid var(--bg-tertiary, #2d2d2d); border-radius: 8px; padding: 1.5rem; margin-bottom: 1rem; transition: transform 0.2s, box-shadow 0.2s; }
|
|
.note-item:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); }
|
|
.note-item a { text-decoration: none; color: inherit; display: block; }
|
|
.note-title { font-size: 1.5rem; font-weight: 600; margin: 0 0 0.5rem 0; color: var(--text-primary, #e0e0e0); }
|
|
.note-meta { display: flex; gap: 1rem; color: var(--text-secondary, #b0b0b0); font-size: 0.9rem; margin-bottom: 0.5rem; }
|
|
.note-tags { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
|
.tag { background: var(--bg-tertiary, #2d2d2d); color: var(--text-secondary, #b0b0b0); padding: 0.25rem 0.75rem; border-radius: 4px; font-size: 0.85rem; }
|
|
.empty-state { text-align: center; padding: 3rem; color: var(--text-secondary, #b0b0b0); }
|
|
.empty-state svg { width: 64px; height: 64px; margin-bottom: 1rem; opacity: 0.5; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="public-container">
|
|
<div class="public-header">
|
|
<h1><i data-lucide="book-open" class="icon-md"></i> Public Notes</h1>
|
|
<p>Discover my shared notes</p>
|
|
</div>
|
|
%s
|
|
</div>
|
|
<script>
|
|
// Initialize Lucide icons
|
|
if (typeof lucide !== 'undefined') {
|
|
lucide.createIcons();
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>`, notesHTML)
|
|
}
|