Add asset pipeline and professionalize theme
CI / build (push) Failing after 6m2s

Introduce a Node build system for minified CSS/JS with content hashes,
add PWA support, i18n helpers, accessibility improvements, and more
translatable strings. Move theme configuration from bookmarks to
localStorage, add Playwright tests, and set up CI workflows.
This commit is contained in:
2026-08-09 09:25:03 -04:00
parent 10af62dd6c
commit f753b27542
68 changed files with 4743 additions and 1989 deletions
+42
View File
@@ -0,0 +1,42 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: shaarli-pro
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: shaarli-pro/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build theme assets
run: npm run build
- name: Verify JavaScript syntax
run: |
for f in js/*.js js/modules/*.js dist/*.js; do
node --check "$f"
done
- name: Ensure dist/ is up-to-date
run: |
git diff --exit-code dist/ || (
echo "dist/ is out of date. Run 'npm run build' and commit the changes." && exit 1
)
+43
View File
@@ -0,0 +1,43 @@
name: E2E Tests
on:
workflow_dispatch:
schedule:
# Run every Sunday at 03:00 UTC.
- cron: '0 3 * * 0'
jobs:
e2e:
runs-on: ubuntu-latest
defaults:
run:
working-directory: shaarli-pro
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: shaarli-pro/package-lock.json
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run E2E tests
env:
SHAARLI_URL: ${{ secrets.SHAARLI_URL }}
run: npm run test:e2e
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: shaarli-pro/playwright-report/
retention-days: 7
+22
View File
@@ -0,0 +1,22 @@
# Dependencies
node_modules/
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS files
.DS_Store
Thumbs.db
# Editor files
.vscode/
.idea/
*.swp
*.swo
# The dist/ directory contains generated minified assets.
# It is intentionally committed so the theme works without running npm install.
# If you prefer to generate it in CI, uncomment the next line.
# dist/
+41
View File
@@ -0,0 +1,41 @@
# Changelog
Toutes les modifications notables de ce projet seront documentées ici.
Le format est basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/),
et ce projet adhère à [Semantic Versioning](https://semver.org/lang/fr/).
## [1.1.0] - 2026-08-09
### Ajouté
- Pipeline de build frontend (`npm run build`) minifiant CSS/JS avec PostCSS/cssnano et Terser.
- Génération d'assets dans `shaarli-pro/dist/` avec hash de contenu dans le manifeste.
- Cache-busting des CSS/JS via `{$version_hash}` de Shaarli.
- Chargement de la police Inter via `<link>` au lieu d'un `@import` CSS (meilleures performances).
- Modules JavaScript thématiques dans `js/modules/` : `theme.js`, `navigation.js`, `search.js`, `bulk-actions.js`, `a11y.js`.
- Système d'internationalisation côté client (`js/i18n.js`) et clés documentées dans `i18n-keys.md`.
- Support PWA : `manifest.json`, icônes 192×192 et 512×512, et service worker de cache des assets statiques.
- Workflow GitHub Actions CI vérifiant le build et la syntaxe JS.
- Socle de tests E2E avec Playwright (`tests/smoke.spec.js`) et workflow dédié.
### Modifié
- Mise à jour de `theme_info.php` en version `1.1.0`.
- Nettoyage des `console.log` de debug.
- Suppression de `migrate-todos.php` du dossier thème.
- Amélioration de la gestion des erreurs sur les appels `fetch`.
### Corrigé
- Failles XSS potentielles liées à l'utilisation d'`innerHTML` avec du contenu utilisateur.
- Template `pluginscontent.html` manquant, requis par certains plugins officiels.
- Duplication de la zone `tools_plugin` dans `tools.html`.
- Boutons icônes sans `aria-label` et contrastes insuffisants.
- Support `prefers-reduced-motion` et `prefers-contrast: more`.
## [1.0.0] - 2026
### Ajouté
- Version initiale du thème Shaarli Pro avec sidebar, header, modes clair/sombre, 16 palettes, recherche Spotlight, vues notes/todos/archive, lecteur multimédia et multi-sélection.
+233
View File
@@ -0,0 +1,233 @@
# Plan d'amélioration – Shaarli Professional Theme (`shaarli-pro`)
> Objectif : transformer le thème actuel, riche en fonctionnalités mais encore très artisanal, en une solution **professionnelle**, **sécurisée**, **maintenable**, **performante** et **accessible**.
Ce plan repose sur une analyse statique complète du code (README, rapport d'analyse existant, templates, CSS, JS, comparaison avec le thème `default` officiel de Shaarli).
---
## 1. Synthèse de l'état actuel
### 1.1 Métriques
| Fichier / catégorie | Taille | Remarque |
|---|---|---|
| `js/script.js` | ~3 620 lignes | Thème, navigation, recherche, lecteur, multi-sélection, etc. |
| `js/custom_views.js` | ~6 160 lignes | Notes, todos, archive, color picker, background studio |
| `js/shaarit-rules.js` | 377 lignes | Règles métier ShaarIt (tags, détection, URLs) |
| `css/style.css` | ~4 700 lignes | Design system principal |
| `css/custom_views.css` | ~1 780 lignes | Styles des vues notes/todos |
| `css/themes.css` | ~1 680 lignes | 16 palettes de couleurs |
| Templates HTML | ~40 fichiers | Dont plusieurs non documentés |
| Tests / CI / lint | 0 | Aucun outil d'assurance qualité |
### 1.2 Forces à préserver
- UI moderne, cohérente, avec sidebar + header.
- Mode clair/sombre et système de thèmes multiples.
- Fonctionnalités avancées : notes, todos, archive, lecteur média, multi-sélection, recherche Spotlight.
- Variables CSS et design system en place.
- Utilisation de `localStorage` pour la persistance locale.
### 1.3 Appréciation globale
Le thème vaut **~6,5 / 10** en termes de professionnalisation. Il est fonctionnellement abouti, mais accumule de la dette technique : failles XSS potentielles, code monolithique, duplication, dépendances externes non maîtrisées, absence de tests et documentation obsolète.
---
## 2. Problèmes critiques – à traiter en priorité (Phase 1)
Ces points bloquent une mise en production professionnelle.
### 2.1 Sécurité
| Problème | Fichier(s) | Risque | Action |
|---|---|---|---|
| **XSS via `innerHTML`** sur descriptions, previews, lecteur popup, tags | `js/script.js` (l. 1547, 1603, 2151…), `js/custom_views.js` (passim) | Exécution de code si un bookmark contient du HTML/JS malveillant | Tout contenu dynamique doit passer par `textContent` ou être échappé avec `escapeHtml`. Remplacer `innerHTML` par `innerText` / création DOM sécurisée. |
| **Génération de code JavaScript en chaîne** pour la popup lecteur | `js/script.js` `buildPlayerHTML()` (l. 2523–3053) | Injection possible si titre/URL contiennent des quotes ou du code | Supprimer `buildPlayerHTML()` ; utiliser le template `player.html` existant ou un composant DOM réel. |
| **Persistance de la configuration via un bookmark privé** (`shaarit_config`) | `js/script.js` `ShaaritThemeConfig` (l. 7–154) | Fuite de config, pollution de la base, risque si un attaquant crée un bookmark avec ce tag | Remplacer par `localStorage` uniquement, ou par un vrai plugin backend dédié. |
| **URLs internes fixes vers `shaarit.app`** | `js/shaarit-rules.js` (l. 142–148), `js/script.js`, `migrate-todos.php` | Dépendance externe, fuite de données, rupture si le domaine change | Rendre les schémas d'URLs internes configurables (`note://`, `todo://`, ou URLs relatives). |
| **Script PHP de migration dans le thème** | `migrate-todos.php` | Modification directe de la base, pas de contrôle d'accès, exécutable via web | Supprimer du thème ; en faire un plugin admin ou un script CLI documenté hors `tpl/`. |
| **`descEl.innerHTML` utilisé comme source de Markdown** | `js/custom_views.js` (l. 2422, 2492, 4239) | Récupère le HTML rendu par Shaarli, potentiellement exécutable | Utiliser `textContent` comme source unique. |
### 2.2 Bugs et instabilités
| Problème | Fichier(s) | Action |
|---|---|---|
| **`player.html` n'est jamais utilisé** mais dupliqué en JS | `player.html`, `js/script.js` | Supprimer `player.html` si inutile, ou l'utiliser comme cible de la popup. |
| **Template officiel `pluginscontent.html` absent** | `tpl/default/pluginscontent.html` | Ajouter `pluginscontent.html` pour ne pas casser les plugins qui utilisent `plugin_content`. |
| **Gestion d'erreurs `fetch` insuffisante** | `js/script.js`, `js/custom_views.js` | Ajouter des `try/catch`, vérifier `response.ok`, informer l'utilisateur en cas d'échec. |
| **Écouteurs d'événements ajoutés mais jamais supprimés** | `js/script.js` (102 `addEventListener`), `js/custom_views.js` (104) | Nettoyer les listeners dans les modales, popups, sidebars mobile. |
| **`tools_plugin` inclus deux fois** | `tools.html` (l. 217 et 283) | Ne garder qu'une seule zone. |
| **Hidden tags stockés uniquement dans `localStorage`** | `hidden-tags.html`, `tools.html` | Per-browser, non synchronisés. Persister côté serveur (plugin) ou au minimum documenter la limitation. |
| **Sélecteurs DOM très longs et fragiles** | `css/style.css`, JS | Raccourcir et utiliser des classes sémantiques (BEM ou équivalent). |
### 2.3 Compatibilité avec Shaarli officiel
- Le thème `default` utilise `{$version_hash}` pour le cache-busting ; `shaarli-pro` utilise des versions codées en dur (`v=1.0.5`, etc.) qui ne correspondent pas à `theme_info.php` (`1.0.0`).
- OpenGraph et `apple-touch-icon` sont absents du `includes.html` alors que le thème officiel les fournit.
- Les templates officiels utilisent systématiquement `{'key'|t}` pour l'internationalisation ; `shaarli-pro` mélange français, anglais et textes en dur.
---
## 3. Points non finis / fonctionnalités à finaliser
### 3.1 Vue Archive
- `initArchiveView()` existe (`js/custom_views.js`) mais l'expérience semble moins aboutie que Notes/Todos (moins de styles dédiés, pas de création d'archive explicite).
- **Action** : finaliser l'UI archive ou fusionner avec une fonction "archiver" depuis la vue Notes.
### 3.2 Gestion des tags cachés
- Double implémentation : `hidden-tags.html` et le panneau dans `tools.html`.
- La logique est dupliquée entre `js/script.js` (chargement) et le script inline de `hidden-tags.html`.
- **Action** : centraliser la liste des tags système, définir une source unique (idéalement côté serveur via un plugin).
### 3.3 Lecteur média
- Le lecteur inline et la popup partagent un état via `localStorage` et `BroadcastChannel`, ce qui est complexe.
- `player.html` n'est pas utilisé.
- **Action** : choisir une architecture unique (inline OU popup via un vrai template) et la stabiliser.
### 3.4 Panneau des thèmes
- `themes.css` charge 16 palettes (37 Ko) même si l'utilisateur n'en utilise qu'une.
- **Action** : charger dynamiquement le CSS du thème actif, ou au minimum splitter `themes.css` par thème.
### 3.5 README et documentation
- Le README annonce une "Structure du Projet" incomplète : il omet `custom_views.js/css`, `themes.css`, `player.html`, `hidden-tags.html`, `install.html`, `migrate-todos.php`, `shaarit-rules.js`, `backgrounds-manifest.json`, les fonds d'écran `note-bg-*`.
- **Action** : réécrire le README, ajouter un CHANGELOG et un guide de contribution.
---
## 4. Axes d'amélioration structurants
### 4.1 Qualité et maintenabilité du code
1. **Modulariser le JavaScript**
- Découper `script.js` en modules thématiques : `theme.js`, `search.js`, `media-player.js`, `bulk-actions.js`, `editor.js`, `mobile-nav.js`.
- Découper `custom_views.js` en : `notes.js`, `todos.js`, `archive.js`, `palette.js`, `background-studio.js`.
2. **Supprimer les `console.log` de debug** (calendar, pin, theme sync, etc.).
3. **Uniformiser la gestion des erreurs** : toutes les fonctions `async` doivent avoir un `try/catch` et un retour utilisateur.
4. **Réduire la spécificité CSS** : éviter les sélecteurs de plus de 3 niveaux, nettoyer les `!important` (26 dans `custom_views.css`).
5. **Dédupliquer la navigation** sidebar / header, soit via un template partagé, soit via génération JS à partir d'un tableau de liens.
### 4.2 Performance
1. **Mettre en place un build frontend** (Vite, Webpack ou Rollup) pour :
- minifier JS/CSS,
- splitter les thèmes,
- ajouter des hashes de cache-busting,
- héberger localement les polices et les icônes si possible.
2. **Charger `custom_views.js` uniquement sur les pages concernées** (actuellement chargé partout via `includes.html`).
3. **Remplacer l'`@import` Google Fonts** par un `<link rel="preload" as="style">` ou des polices locales.
4. **Optimiser les images** de fond (`note-bg-*`) : WebP/AVIF, redimensionnement, lazy-loading.
5. **Supprimer le CSS mort** avec un outil comme PurgeCSS ou un audit manuel des classes.
### 4.3 Accessibilité (a11y)
1. **Rendre les modales focus-trap** (recherche, filtres, QR code, notes, todos).
2. **Utiliser `aria-selected`** sur les boutons de mode de recherche et les onglets.
3. **Ajouter `prefers-reduced-motion`** pour désactiver les animations.
4. **Vérifier tous les contrastes** (notamment `--tag-green-text` signalé comme limite).
5. **Ajouter des skip-links** et s'assurer que tous les boutons icônes ont un `aria-label` explicite.
6. **Remplacer les emojis utilisés comme indicateurs** (📌 dans `linklist.html`) par des icônes sémantiques.
### 4.4 Internationalisation (i18n)
1. **Extraire toutes les chaînes** des templates vers le format Shaarli : `{'Ma clé'|t}`.
2. Faire de même pour les textes générés en JS (labels de boutons, messages d'erreur, placeholders).
3. Documenter les nouvelles clés de traduction ajoutées.
### 4.5 Architecture des fonctionnalités ShaarIt
1. **Rendre les règles ShaarIt configurables** : le serveur Shaarli, pas `shaarit.app`, doit être la source de vérité pour les URLs internes.
2. **Ne plus utiliser des tags comme base de données** pour stocker la config thème ; utiliser `localStorage` ou un plugin backend.
3. **Réfléchir à un backend dédié** pour notes/todos (plugin PHP) afin d'éviter les contournements par bookmarks et `fetch` sur les formulaires d'édition.
---
## 5. Nouvelles idées à intégrer (par ordre de valeur)
| Idée | Intérêt | Complexité |
|---|---|---|
| **Service Worker / PWA** (offline, manifest, icônes) | Professionnalisation forte, usage mobile | Moyenne |
| **Tests E2E avec Playwright** (recherche, thème, multi-sélection, lecteur) | Garantie de non-régression | Moyenne |
| **CI/CD GitHub Actions** (lint, build, tests, release) | Qualité continue | Faible |
| **ESLint + Stylelint + Prettier** | Maintenabilité, style cohérent | Faible |
| **Mode haut contraste** (`prefers-contrast`) | Accessibilité | Faible |
| **Amélioration du lecteur** : playlist, historique, gestion des erreurs | UX | Moyenne |
| **Recherche serveur** pour les grosses collections (au lieu de parser le DOM) | Performance | Moyenne/forte |
| **API côté serveur** pour notes/todos/tags cachés | Sécurité, fiabilité | Forte |
| **Dashboard de statistiques** plus riche dans Tools | UX | Moyenne |
| **Documentation technique auto-générée** (JSDoc, Storybook-like) | Maintenabilité | Moyenne |
---
## 6. Roadmap recommandée
### Phase 1 – Sécurité et stabilité (2 à 3 semaines)
- [ ] Audit XSS : remplacer tous les `innerHTML` à risque par du DOM sécurisé.
- [ ] Supprimer ou sécuriser `buildPlayerHTML()` ; utiliser `player.html` ou un composant DOM.
- [ ] Retirer `migrate-todos.php` du thème (plugin/CLI séparé).
- [ ] Remplacer la persistance "bookmark config" par `localStorage` pur ou un plugin backend.
- [ ] Ajouter le template `pluginscontent.html` manquant.
- [ ] Nettoyer les `console.log` de debug.
- [ ] Gestion d'erreurs robuste sur tous les `fetch`.
- [ ] Tests manuels complets sur une instance Shaarli propre.
### Phase 2 – Qualité et maintenabilité (2 à 3 semaines)
- [ ] Découper `script.js` et `custom_views.js` en modules thématiques.
- [ ] Refactoriser la navigation sidebar/header pour supprimer la duplication.
- [ ] Unifier le CSS variables et supprimer les variables mortes.
- [ ] Extraire les textes en dur vers `{'key'|t}`.
- [ ] Mettre en place ESLint + Stylelint + Prettier.
- [ ] Réécrire le README et ajouter un CHANGELOG.
### Phase 3 – Performance et accessibilité (1 à 2 semaines)
- [ ] Mettre en place un build (minification, hash, split des thèmes).
- [ ] Charger `custom_views.js` à la demande.
- [ ] Optimiser les polices et icônes (local/swap).
- [ ] Audit a11y complet (modales, contrastes, reduced-motion).
- [ ] Optimiser les images de fond.
### Phase 4 – Évolutions (selon les priorités utilisateur)
- [ ] PWA / Service Worker.
- [ ] Tests E2E Playwright.
- [ ] CI/CD GitHub Actions.
- [ ] Backend plugin pour notes/todos/config.
- [ ] Améliorations du lecteur (playlist, historique).
---
## 7. Critères de validation
Pour considérer le thème comme professionnel, les critères suivants doivent être atteints :
1. **Sécurité** : aucune alerte critique (XSS, injection, `eval`, `document.write`) ; contenu utilisateur toujours échappé.
2. **Stabilité** : aucune erreur JS en console sur les parcours principaux (login, liste, ajout, édition, recherche, notes, todos, tools).
3. **Compatibilité** : tous les templates officiels sont présents ou justifiés ; les plugins standard fonctionnent.
4. **Performance** : score Lighthouse > 90 sur Performance et Accessibilité (sur une instance de référence).
5. **Maintenabilité** : code modularisé, linté, documenté.
6. **Internationalisation** : toutes les chaînes utilisateur passent par le système de traduction Shaarli.
7. **Tests** : tests E2E critiques passants (recherche, multi-sélection, changement de thème, lecteur).
---
## 8. Premières actions immédiates (si vous voulez démarrer demain)
1. Corriger les failles XSS dans `js/script.js::renderResults`, `js/custom_views.js` et la popup lecteur.
2. Supprimer `migrate-todos.php` du dossier thème.
3. Décider de l'avenir de la persistance thème : `localStorage` uniquement ou plugin backend ?
4. Créer `pluginscontent.html`.
5. Nettoyer les `console.log` restants.
6. Mettre à jour le README avec la structure réelle.
---
*Plan rédigé le 8 août 2026. Il doit être affiné avec les retours utilisateurs et les contraintes réelles de déploiement (version de Shaarli, hébergement, usage du mobile).*
+98 -9
View File
@@ -7,10 +7,11 @@
| | |
|---|---|
| **Nom interne** | Professional (`shaarli-pro`) |
| **Version** | 1.0.0 |
| **Version** | 1.1.0 |
| **Compatibilité** | Shaarli ≥ 0.9, PHP ≥ 7.4 |
| **Licence** | MIT |
| **Dossier** | `tpl/shaarli-pro` |
| **Build** | Node.js ≥ 18, `npm run build` |
Le thème offre un layout de type application avec barre latérale fixe, navigation fluide, mode clair/sombre natif, et une couche JavaScript riche dédiée à l'interactivité.
@@ -155,6 +156,55 @@ Le thème offre un layout de type application avec barre latérale fixe, navigat
---
## Build & Développement
Le thème inclut un pipeline de build frontend pour minifier les assets et ajouter du cache-busting.
```bash
cd shaarli-pro
npm install # installer les dépendances
npm run build # minifier CSS/JS et générer dist/
npm run build:css # CSS uniquement
npm run build:js # JS uniquement
npm run clean # supprimer dist/
```
Les templates chargent les fichiers minifiés depuis `dist/` avec le hash de version Shaarli (`{$version_hash}`).
Les sources (`css/`, `js/`) restent les fichiers de référence : **ne modifiez jamais `dist/` à la main**.
Après chaque build, pensez à committer `dist/` pour que le thème reste utilisable sans Node.js côté production.
## Tests
### Tests E2E (Playwright)
Un socle de tests E2E est fourni pour valider les parcours critiques :
```bash
cd shaarli-pro
npm run test:e2e:install # installer les navigateurs Playwright (une fois)
SHAARLI_URL=http://localhost:8080 npm run test:e2e
```
Les tests couvrent actuellement :
- chargement de la page d'accueil,
- ouverture de la recherche au clavier (`S`),
- bascule clair/sombre,
- présence du skip-link,
- présence du bouton de multi-sélection.
Un workflow GitHub Actions dédié (`.github/workflows/e2e.yml`) permet de les exécuter manuellement ou via cron une fois configuré avec `SHAARLI_URL`.
## PWA (Progressive Web App)
Le thème fournit un manifeste et un service worker de base :
- `manifest.json` : icônes, theme-color, mode standalone.
- `service-worker.js` : cache les assets statiques du thème (CSS/JS/icônes) pour un fonctionnement dégradé offline.
Le service worker intercepte uniquement les requêtes situées dans son scope (le dossier du thème). Les pages Shaarli dynamiques continuent d'être servies par le réseau.
## Installation
### 1. Téléchargement
@@ -163,7 +213,7 @@ Le thème offre un layout de type application avec barre latérale fixe, navigat
git clone https://git.dracodev.net/Projets/Shaarli_bm_theme.git
```
### 3. Supprimer le thème en place
### 2. Supprimer le thème en place
```bash
# Via Docker
@@ -173,7 +223,9 @@ docker exec -it shaarli_bookmarks rm -rf /var/www/shaarli/tpl/shaarli-pro
rm -rf /path/to/shaarli/tpl/shaarli-pro
```
### 2. Copie dans Shaarli
### 3. Copie dans Shaarli
Le dossier `dist/` contient les assets CSS/JS déjà minifiés et est versionné. Vous n'avez **pas besoin de lancer `npm install`** si vous utilisez le thème tel quel.
Copier le dossier `shaarli-pro/` dans le répertoire `tpl/` de votre instance Shaarli, à côté du dossier `default/` :
@@ -185,7 +237,7 @@ docker cp "./shaarli-pro" shaarli_bookmarks:/var/www/shaarli/tpl/
cp -r shaarli-pro/ /path/to/shaarli/tpl/
```
### 3. Permissions
### 4. Permissions
```bash
# Docker
@@ -226,6 +278,19 @@ docker exec -it myshaarli sed -i 's/"theme": "default"/"theme": "shaarli-pro"/'
Redémarrez PHP si nécessaire et videz le cache navigateur.
### Notes importantes
- Le dossier `dist/` (assets minifiés) est versionné. Si vous clonez/pull le dépôt tel quel, le thème fonctionne immédiatement sans Node.js.
- Si vous modifiez `css/` ou `js/`, vous devez relancer le build et committer le nouveau `dist/` :
```bash
cd shaarli-pro
npm install
npm run build
git add dist/
git commit -m "rebuild assets"
```
---
## Configuration & Personnalisation
@@ -261,22 +326,48 @@ Créez un fichier `tpl/shaarli-pro/extra.html` pour injecter du CSS/JS suppléme
```
shaarli-pro/
├── css/
│ └── style.css # Styles principaux + variables CSS (light/dark)
│ ├── style.css # Styles principaux + variables CSS (light/dark)
│ ├── themes.css # 16 palettes de couleurs
│ ├── custom_views.css # Styles des vues Notes / Todos / Archive
│ └── awesomplete.css # Autocomplete des tags
├── js/
│ └── script.js # Interactions (thème, recherche, filtres, sélection, lecteur)
│ ├── script.js # Thème, recherche, filtres, multi-sélection, lecteur média
│ ├── custom_views.js # Vues Notes, Todos, Archive, Background Studio, Color Picker
│ ├── i18n.js # Système d'internationalisation côté client
│ ├── shaarit-rules.js # Détection notes/todos/pins et règles métier ShaarIt
│ ├── modules/ # Modules JS thématiques (theme, search, navigation, bulk-actions, a11y)
│ ├── awesomplete.min.js # Autocomplete des tags
│ ├── metadata.min.js # Récupération asynchrone des métadonnées de lien
│ ├── backgrounds-manifest.js # Manifest des fonds d'écran notes
│ └── backgrounds-manifest.json # Données des fonds d'écran
├── dist/ # Assets minifiés générés par le build (CSS + JS)
├── img/
│ ├── favicon.png
│ ├── note-bg-light/ # Fonds d'écran clairs pour les notes
│ └── note-bg-dark/ # Fonds d'écran sombres pour les notes
├── theme_info.php # Métadonnées du thème
├── manifest.json # Manifest PWA
├── service-worker.js # Service Worker pour le cache offline
├── package.json # Dépendances et scripts de build
├── build.js # Pipeline de minification CSS/JS
├── includes.html # Head commun (meta, CSS, JS, config Shaarli)
├── page.header.html # Sidebar + Header + Search overlay + Filtres
├── page.footer.html # Footer + Bulk actions bar + Media player
├── linklist.html # Page principale des bookmarks
├── linklist.paging.html # Composant de pagination
├── daily.html # Vue quotidienne / hebdomadaire / mensuelle
├── dailyrss.html # Template RSS quotidien
├── editlink.html # Formulaire d'ajout/édition de bookmark
├── editlink.batch.html # Édition par lot
├── picwall.html # Mur d'images avec contrôle de taille
├── tag.cloud.html # Nuage de tags avec filtre alphabétique
├── tag.list.html # Liste de tags avec recherche dynamique
├── tag.sort.html # Navigation entre vues tag
├── tools.html # Page d'administration
├── tools.html # Page d'administration + gestion des thèmes et tags cachés
├── hidden-tags.html # Gestion des tags système cachés
├── player.html # Lecteur média popup (template autonome)
├── pluginscontent.html # Zone de contenu injectée par les plugins
├── install.html # Page d'installation Shaarli
├── loginform.html # Formulaire de connexion
├── configure.html # Page de configuration
├── changepassword.html # Changement de mot de passe
@@ -288,12 +379,10 @@ shaarli-pro/
├── export.html # Export de bookmarks
├── export.bookmarks.html # Template d'export Netscape
├── addlink.html # Ajout rapide de lien
├── editlink.batch.html # Édition par lot
├── thumbnails.html # Synchronisation des miniatures
├── opensearch.html # Descripteur OpenSearch
├── feed.rss.html # Template de flux RSS
├── feed.atom.html # Template de flux Atom
├── dailyrss.html # Template RSS quotidien
├── 404.html # Page d'erreur 404
├── error.html # Page d'erreur générique
└── page.html # Page wrapper
+235
View File
@@ -0,0 +1,235 @@
#!/usr/bin/env node
/**
* Build pipeline for the Shaarli Pro theme.
*
* Actions:
* - Minify CSS with PostCSS + cssnano.
* - Minify JavaScript with Terser.
* - Compute content hashes for cache-busting.
* - Emit a manifest.json in dist/.
*
* Usage:
* npm run build # full build
* npm run build:css # CSS only
* npm run build:js # JS only
*/
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const terserAvailable = (function () {
try {
require.resolve('terser');
return true;
} catch (e) {
return false;
}
})();
const postcssAvailable = (function () {
try {
require.resolve('postcss');
require.resolve('cssnano');
return true;
} catch (e) {
return false;
}
})();
const ROOT = __dirname;
const DIST_DIR = path.join(ROOT, 'dist');
const MANIFEST_PATH = path.join(DIST_DIR, 'manifest.json');
const cssFiles = [
'css/style.css',
'css/themes.css',
'css/custom_views.css',
'css/awesomplete.css'
];
const jsFiles = [
'js/i18n.js',
'js/shaarit-rules.js',
'js/modules/a11y.js',
'js/modules/theme.js',
'js/modules/navigation.js',
'js/modules/search.js',
'js/modules/bulk-actions.js',
'js/script.js',
'js/custom_views.js',
// Already minified vendor files – copy them as-is but still hash them.
'js/awesomplete.min.js',
'js/metadata.min.js'
];
const args = process.argv.slice(2);
const cssOnly = args.includes('--css-only');
const jsOnly = args.includes('--js-only');
const buildCss = !jsOnly;
const buildJs = !cssOnly;
function hash(content) {
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 12);
}
function ensureDir(dir) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
async function minifyCss(file) {
if (!postcssAvailable) {
throw new Error('PostCSS / cssnano dependencies are missing. Run npm install.');
}
const postcss = require('postcss');
const cssnano = require('cssnano');
const inputPath = path.join(ROOT, file);
const content = fs.readFileSync(inputPath, 'utf8');
const result = await postcss([cssnano({ preset: 'default' })]).process(content, {
from: inputPath,
to: inputPath
});
return result.css;
}
async function minifyJs(file) {
if (!terserAvailable) {
throw new Error('Terser dependency is missing. Run npm install.');
}
const { minify } = require('terser');
const inputPath = path.join(ROOT, file);
const content = fs.readFileSync(inputPath, 'utf8');
const isVendor = file.includes('.min.js');
if (isVendor) {
// Vendor files are already minified; just validate syntax and copy.
return content;
}
const result = await minify(content, {
compress: {
drop_console: false, // keep user-facing console warnings
drop_debugger: true,
passes: 1
},
mangle: {
reserved: ['shaarli', 'ShaarliProI18n', 'ShaarliProUtils', 'ShaarliProA11y']
},
format: {
comments: /^!|@preserve|@license|copyright/i
},
sourceMap: false
});
if (result.error) {
throw new Error(`Terser error in ${file}: ${result.error}`);
}
return result.code;
}
async function build() {
const start = Date.now();
if (fs.existsSync(DIST_DIR)) {
fs.rmSync(DIST_DIR, { recursive: true, force: true });
}
ensureDir(DIST_DIR);
const manifest = {
generatedAt: new Date().toISOString(),
files: {}
};
const processed = [];
const errors = [];
if (buildCss) {
for (const file of cssFiles) {
const inputPath = path.join(ROOT, file);
if (!fs.existsSync(inputPath)) {
errors.push(`CSS file not found: ${file}`);
continue;
}
try {
const minified = await minifyCss(file);
const fileHash = hash(minified);
const baseName = path.basename(file, '.css');
const ext = path.extname(file);
const outputName = `${baseName}.min${ext}`;
const outputPath = path.join(DIST_DIR, outputName);
fs.writeFileSync(outputPath, minified, 'utf8');
manifest.files[file] = {
dist: `dist/${outputName}`,
hash: fileHash,
size: minified.length
};
processed.push(`css/${outputName}`);
} catch (e) {
errors.push(`CSS ${file}: ${e.message}`);
}
}
}
if (buildJs) {
for (const file of jsFiles) {
const inputPath = path.join(ROOT, file);
if (!fs.existsSync(inputPath)) {
errors.push(`JS file not found: ${file}`);
continue;
}
try {
const minified = await minifyJs(file);
const fileHash = hash(minified);
const baseName = path.basename(file, '.js');
const ext = path.extname(file);
// Avoid double .min for vendor files already minified.
const outputName = baseName.endsWith('.min')
? `${baseName}${ext}`
: `${baseName}.min${ext}`;
const outputPath = path.join(DIST_DIR, outputName);
fs.writeFileSync(outputPath, minified, 'utf8');
manifest.files[file] = {
dist: `dist/${outputName}`,
hash: fileHash,
size: minified.length
};
processed.push(`js/${outputName}`);
} catch (e) {
errors.push(`JS ${file}: ${e.message}`);
}
}
}
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2), 'utf8');
const duration = Date.now() - start;
if (errors.length > 0) {
console.error('\nBuild completed with errors:');
for (const err of errors) {
console.error(` ✗ ${err}`);
}
}
if (processed.length > 0) {
console.log(`\nBuilt ${processed.length} file(s) in ${duration}ms:`);
for (const name of processed) {
console.log(` ✓ ${name}`);
}
}
console.log(`Manifest written to ${path.relative(ROOT, MANIFEST_PATH)}`);
if (errors.length > 0) {
process.exit(1);
}
}
build().catch(err => {
console.error('Build failed:', err);
process.exit(1);
});
+5 -5
View File
@@ -15,17 +15,17 @@
<div class="card">
<div class="card-header">{"Manage tags"|t}</div>
<div class="card-body">
<p class="text-muted" style="margin-bottom: 1.5rem;">All modifications are case sensitive.</p>
<p class="text-muted" style="margin-bottom: 1.5rem;">{'All modifications are case sensitive.'|t}</p>
<div class="form-group">
<label class="form-label" for="fromtag">Modify this tag&#8230;</label>
<label class="form-label" for="fromtag">{'Modify this tag…'|t}</label>
<input type="text" name="fromtag" id="fromtag" value="{$fromtag}" class="form-control autofocus"
autocomplete="off" data-multiple data-minChars="1"
data-list="{loop="$tags"}{$key}, {/loop}" placeholder="Old tag"/>
data-list="{loop="$tags"}{$key}, {/loop}" placeholder="{'Old tag'|t}"/>
</div>
<div class="form-group">
<label class="form-label" for="totag">&#8230;into this tag</label>
<input type="text" name="totag" id="totag" class="form-control" placeholder="New tag">
<label class="form-label" for="totag">{'…into this tag'|t}</label>
<input type="text" name="totag" id="totag" class="form-control" placeholder="{'New tag'|t}">
</div>
<p class="text-muted" style="font-size: 0.9rem;">
{'You can also edit tags in the'|t} <a href="{$base_path}/tags/list?sort=usage">{'tag list'|t}</a>.
+2 -2
View File
@@ -16,7 +16,7 @@
<div class="card-header">{'Configuration'|t}</div>
<div class="card-body">
<div class="form-group">
<label class="form-label" for="title">Shaarli {'title'|t}</label>
<label class="form-label" for="title">{'Shaarli title'|t}</label>
<input type="text" name="title" id="title" class="form-control" value="{$title}" />
</div>
<div class="form-group">
@@ -122,7 +122,7 @@
<div class="filter-toggle-row">
<div>
<div class="filter-toggle-label">{'RSS direct links'|t}</div>
<div class="sublabel">Enabling it will show a permalink in the description.</div>
<div class="sublabel">{'Enabling it will show a permalink in the description.'|t}</div>
</div>
<label class="toggle-switch">
<input type="checkbox" name="enableRssPermalinks" id="enableRssPermalinks" {if="$enable_rss_permalinks"}checked{/if}/>
+12 -1
View File
@@ -1777,4 +1777,15 @@ color: var(--note-card-fg, var(--tile-text));
.note-card[class*="note-color-"] .todo-checklist-preview-item,
.note-card[class*="note-color-"] .todo-checklist-preview-text {
color: var(--note-card-fg, var(--special-view-strong));
}
}
/* ===== Reduced motion ===== */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
+68 -4
View File
@@ -1,11 +1,31 @@
/* Google Font */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
/**
* Shaarli Professional Theme - Bookmarkify Style
* Modern Sidebar Layout + Light/Dark Mode
*
* Google Fonts are loaded via <link> in includes.html for better performance.
*/
/* ===== Skip link ===== */
.skip-link {
position: absolute;
top: -100%;
left: 1rem;
z-index: 10000;
padding: 0.5rem 1rem;
background: var(--primary);
color: #fff;
border-radius: 0.375rem;
text-decoration: none;
font-weight: 500;
transition: top 0.2s ease;
}
.skip-link:focus {
top: 1rem;
outline: 2px solid #fff;
outline-offset: 2px;
}
/* ===== CSS Variables ===== */
:root {
/* Light Mode */
@@ -35,7 +55,7 @@
--tag-bg: #e0e7ff;
--tag-text: #4338ca;
--tag-green-bg: #d1fae5;
--tag-green-text: #065f46;
--tag-green-text: #064e3b;
--tag-red-bg: #fee2e2;
--tag-red-text: #991b1b;
--tag-yellow-bg: #fef3c7;
@@ -4693,4 +4713,48 @@ body.view-archive {
background: color-mix(in srgb, var(--tile-bg) 70%, var(--primary) 30%);
border-bottom: 1px solid var(--tile-border);
color: var(--on-primary, var(--text-main));
}
/* ===== Reduced motion ===== */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* ===== High contrast mode ===== */
@media (prefers-contrast: more) {
:root {
--text-main: #000000;
--text-secondary: #000000;
--text-muted: #000000;
--border: #000000;
--primary: #0000ee;
--primary-hover: #0000cc;
}
[data-theme="dark"] {
--text-main: #ffffff;
--text-secondary: #ffffff;
--text-muted: #ffffff;
--border: #ffffff;
--primary: #ffff00;
--primary-hover: #ffff00;
}
a {
text-decoration: underline;
}
.link-outer,
.card,
.btn,
.form-control {
border-width: 2px;
}
}
+12 -12
View File
@@ -31,37 +31,37 @@
<div class="daily-calendar-panel" id="daily-calendar-panel" aria-hidden="true">
<div class="daily-calendar-shell">
<div class="daily-calendar-sidebar">
<span class="daily-calendar-title">Raccourcis</span>
<button type="button" class="daily-calendar-shortcut" data-range="0">Aujourd'hui</button>
<button type="button" class="daily-calendar-shortcut" data-range="1">Hier</button>
<button type="button" class="daily-calendar-shortcut" data-range="7">7 derniers jours</button>
<button type="button" class="daily-calendar-shortcut" data-range="30">30 derniers jours</button>
<span class="daily-calendar-title">{'Shortcuts'|t}</span>
<button type="button" class="daily-calendar-shortcut" data-range="0">{'Today'|t}</button>
<button type="button" class="daily-calendar-shortcut" data-range="1">{'Yesterday'|t}</button>
<button type="button" class="daily-calendar-shortcut" data-range="7">{'Last 7 days'|t}</button>
<button type="button" class="daily-calendar-shortcut" data-range="30">{'Last 30 days'|t}</button>
<div class="daily-calendar-summary">
<div>
<span>Du</span>
<span>{'From'|t}</span>
<strong id="daily-calendar-start">-</strong>
</div>
<div>
<span>Au</span>
<span>{'To'|t}</span>
<strong id="daily-calendar-end">-</strong>
</div>
</div>
</div>
<div class="daily-calendar-main">
<div class="daily-calendar-header">
<button type="button" class="daily-calendar-nav" id="daily-calendar-prev" aria-label="Mois précédent">
<button type="button" class="daily-calendar-nav" id="daily-calendar-prev" aria-label="{'Previous month'|t}">
<i class="mdi mdi-chevron-left"></i>
</button>
<div class="daily-calendar-month" id="daily-calendar-month"></div>
<button type="button" class="daily-calendar-nav" id="daily-calendar-next" aria-label="Mois suivant">
<button type="button" class="daily-calendar-nav" id="daily-calendar-next" aria-label="{'Next month'|t}">
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<div class="daily-calendar-weekdays" id="daily-calendar-weekdays"></div>
<div class="daily-calendar-grid" id="daily-calendar-grid"></div>
<div class="daily-calendar-footer">
<button type="button" class="daily-calendar-cancel" id="daily-calendar-cancel">Annuler</button>
<button type="button" class="daily-calendar-apply" id="daily-calendar-apply">Appliquer</button>
<button type="button" class="daily-calendar-cancel" id="daily-calendar-cancel">{'Cancel'|t}</button>
<button type="button" class="daily-calendar-apply" id="daily-calendar-apply">{'Apply'|t}</button>
</div>
</div>
</div>
@@ -136,7 +136,7 @@
{/loop}
</div>
{else}
<div>No articles on this day.</div>
<div>{'No articles on this day.'|t}</div>
{/if}
<div id="plugin_zone_end_picwall" class="plugin_zone">
+1 -1
View File
@@ -6,7 +6,7 @@
<description>{function="t('All links of one :type in a single page.', '', 1, 'shaarli', [':type' => t($type)])"}</description>
<language>{$language}</language>
<copyright>{$index_url}</copyright>
<generator>Shaarli</generator>
<generator>{'Shaarli'|t}</generator>
{loop="$days"}
<item>
+1
View File
@@ -0,0 +1 @@
!function(t){"use strict";const e=["a[href]","button:not([disabled])","input:not([disabled])","select:not([disabled])","textarea:not([disabled])",'[tabindex]:not([tabindex="-1"])'].join(", ");function n(t,n){if(!t)return null;const o=Array.prototype.slice.call(t.querySelectorAll(e));return n?o[0]:o[o.length-1]}t.ShaarliProA11y={focusFirst:function(t){const e=n(t,!0);e&&e.focus()},trapFocus:function(t){if(!t)return function(){};function e(e){if("Tab"!==e.key)return;const o=n(t,!0),i=n(t,!1);o&&i&&(e.shiftKey&&document.activeElement===o?(e.preventDefault(),i.focus()):e.shiftKey||document.activeElement!==i||(e.preventDefault(),o.focus()))}return t.addEventListener("keydown",e),function(){t.removeEventListener("keydown",e)}},getFocusable:n}}(window);
+1
View File
@@ -0,0 +1 @@
.awesomplete [hidden]{display:none}.awesomplete .visually-hidden{position:absolute;clip:rect(0,0,0,0)}.awesomplete{position:relative}.awesomplete,.awesomplete>input{display:block;width:100%}.awesomplete>ul{animation:slideDown .2s ease;background:var(--bg-card);border:1px solid var(--border);border-radius:.5rem;box-shadow:var(--shadow-lg);box-sizing:border-box;color:var(--text-main);left:0;list-style:none;margin:.25rem 0 0;min-width:100%;overflow:hidden;padding:0;position:absolute;text-shadow:none;z-index:1000}.awesomplete>ul:empty{display:none}.awesomplete>ul>li{border-bottom:1px solid var(--border-light);color:var(--text-main);cursor:pointer;font-size:.95rem;padding:.75rem 1rem;position:relative;transition:background .1s ease}.awesomplete>ul>li:last-child{border-bottom:none}.awesomplete>ul>li:hover{background:var(--bg-card-hover);color:var(--primary)}.awesomplete>ul>li[aria-selected=true]{background:var(--primary);color:#fff}.awesomplete mark{background:rgba(255,255,0,.3);color:inherit;font-weight:700;padding:0}.awesomplete li[aria-selected=true] mark{background:hsla(0,0%,100%,.3);color:inherit}[data-theme=dark] .awesomplete>ul{background:var(--bg-card);border-color:var(--border)}[data-theme=dark] .awesomplete>ul>li:hover{background:var(--bg-card-hover)}.card,.form-add .card,.page-edit .card{overflow:visible!important}@keyframes slideDown{0%{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:translateY(0)}}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+80
View File
@@ -0,0 +1,80 @@
{
"generatedAt": "2026-08-09T13:02:05.428Z",
"files": {
"css/style.css": {
"dist": "dist/style.min.css",
"hash": "a171bd2c9207",
"size": 71039
},
"css/themes.css": {
"dist": "dist/themes.min.css",
"hash": "eef4d8eb1c77",
"size": 29289
},
"css/custom_views.css": {
"dist": "dist/custom_views.min.css",
"hash": "4632d8790b9a",
"size": 29701
},
"css/awesomplete.css": {
"dist": "dist/awesomplete.min.css",
"hash": "3d064bc68c5d",
"size": 1440
},
"js/i18n.js": {
"dist": "dist/i18n.min.js",
"hash": "65a93efc158a",
"size": 5699
},
"js/shaarit-rules.js": {
"dist": "dist/shaarit-rules.min.js",
"hash": "13bd03a5472d",
"size": 6394
},
"js/modules/a11y.js": {
"dist": "dist/a11y.min.js",
"hash": "74b084c2aa76",
"size": 756
},
"js/modules/theme.js": {
"dist": "dist/theme.min.js",
"hash": "71ee5209634d",
"size": 1920
},
"js/modules/navigation.js": {
"dist": "dist/navigation.min.js",
"hash": "e6a4e847315c",
"size": 1671
},
"js/modules/search.js": {
"dist": "dist/search.min.js",
"hash": "86ff97ee522f",
"size": 12525
},
"js/modules/bulk-actions.js": {
"dist": "dist/bulk-actions.min.js",
"hash": "3ecca607bd44",
"size": 5165
},
"js/script.js": {
"dist": "dist/script.min.js",
"hash": "d14722799315",
"size": 49872
},
"js/custom_views.js": {
"dist": "dist/custom_views.min.js",
"hash": "595e6e4ac4c0",
"size": 143968
},
"js/awesomplete.min.js": {
"dist": "dist/awesomplete.min.js",
"hash": "784fb5005249",
"size": 7438
},
"js/metadata.min.js": {
"dist": "dist/metadata.min.js",
"hash": "61422013d705",
"size": 1552
}
}
}
+62
View File
@@ -0,0 +1,62 @@
(function () {
var loaded = false;
var tried = {};
function toAbsolute(url) {
try {
return new URL(url, window.location.href).toString();
} catch (e) {
return url;
}
}
function loadNext(candidates, index) {
if (loaded || index >= candidates.length) {
return;
}
var src = toAbsolute(candidates[index]);
if (tried[src]) {
loadNext(candidates, index + 1);
return;
}
tried[src] = true;
var script = document.createElement('script');
script.src = src;
script.async = false;
script.onload = function () {
loaded = true;
};
script.onerror = function () {
script.remove();
loadNext(candidates, index + 1);
};
document.head.appendChild(script);
}
var rootPath = (window.shaarli && window.shaarli.rootPath) || '';
var basePath = (window.shaarli && window.shaarli.basePath) || '';
var assetPath = (window.shaarli && window.shaarli.assetPath) || '';
var candidates = [];
if (assetPath) {
candidates.push(assetPath.replace(/\/tpl\/shaarli-pro\/?$/, '/tpl/default') + '/js/metadata.min.js');
}
if (basePath) {
candidates.push(basePath + '/tpl/default/js/metadata.min.js');
candidates.push(basePath + '/js/metadata.min.js');
}
if (rootPath) {
candidates.push(rootPath + '/tpl/default/js/metadata.min.js');
candidates.push(rootPath + '/js/metadata.min.js');
}
candidates.push('/js/metadata.min.js');
candidates.push('/tpl/default/js/metadata.min.js');
loadNext(candidates, 0);
})();
+1
View File
@@ -0,0 +1 @@
document.addEventListener("DOMContentLoaded",()=>{"use strict";const e=document.querySelector(".sidebar-add-note");e&&e.addEventListener("click",t=>{if(t.preventDefault(),!window.ShaarItRules)return void console.warn("[shaarit] ShaarItRules not available, cannot generate note URL");const a=e.getAttribute("data-base-path")||"",n=window.ShaarItRules.generateNoteUrl(),o=`${a}/admin/shaare?post=${encodeURIComponent(n)}&tags=note`;window.location.href=o});const t=document.querySelector(".sidebar-add-todo");t&&t.addEventListener("click",e=>{if(e.preventDefault(),!window.ShaarItRules)return void console.warn("[shaarit] ShaarItRules not available, cannot generate todo URL");const a=t.getAttribute("data-base-path")||"",n=window.ShaarItRules.generateTodoUrl(),o=`${a}/admin/shaare?post=${encodeURIComponent(n)}&tags=todo&title=%E2%9C%85%20`;window.location.href=o});const a=document.getElementById("sidebar"),n=document.getElementById("sidebar-overlay"),o=document.getElementById("mobile-menu-btn");function d(){return window.matchMedia("(max-width: 1100px)").matches}function s(){a&&n&&(a.classList.remove("show"),n.classList.remove("show"),o?.setAttribute("aria-expanded","false"),n.setAttribute("aria-hidden","true"),document.body.style.overflow="")}o?.addEventListener("click",function(){a?.classList.contains("show")?s():a&&n&&(a.classList.add("show"),n.classList.add("show"),o?.setAttribute("aria-expanded","true"),n.setAttribute("aria-hidden","false"),d()&&(document.body.style.overflow="hidden"))}),n?.addEventListener("click",s),a?.querySelectorAll("a").forEach(e=>{e.addEventListener("click",()=>{d()&&s()})}),window.addEventListener("resize",()=>{d()||s()})});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
!function(){"use strict";const e="shaarit_themes_config";function t(){try{const t=localStorage.getItem(e);if(!t)return null;const n=JSON.parse(t);return n&&"object"==typeof n?n:null}catch(e){return null}}window.ShaaritThemeConfig={save:function(n){const o=t()||{},r=Object.assign({version:2,themes:[],default:"DEFAULT",mode:"dark"},o,n||{}),a=function(t){try{return localStorage.setItem(e,JSON.stringify(t)),!0}catch(e){return!1}}(r);return Promise.resolve({ok:a,config:r})},load:function(){return Promise.resolve(t())},findCandidates:function(){return Promise.resolve([])}}}(),document.addEventListener("DOMContentLoaded",()=>{"use strict";const e=document.getElementById("theme-toggle-checkbox"),t=document.getElementById("theme-icon-light"),n=document.querySelector(".theme-toggle-label span"),o=window.ShaarliProI18n||{t:function(e){return e}};function r(){const e=localStorage.getItem("shaarit_theme_id")||"DEFAULT";return-1!==["LINEAR","SPOTIFY","NOTION","DISCORD","DRACULA","ONE_DARK_PRO","TOKYO_NIGHT","NORD","NIGHT_OWL","ANTHRACITE","CYBERPUNK","NAVY_ELEGANCE","EARTHY"].indexOf(e)}function a(a){r()&&(a="dark"),document.documentElement.setAttribute("data-theme",a),localStorage.setItem("theme",a),e&&(e.checked="dark"===a,e.disabled=r(),e.closest(".theme-switch").style.opacity=e.disabled?"0.5":"1"),t&&(t.className="dark"===a?"mdi mdi-weather-night":"mdi mdi-weather-sunny"),n&&(n.textContent="dark"===a?o.t("Dark Mode"):o.t("Light Mode"))}window.addEventListener("themeChanged",()=>{a(localStorage.getItem("theme")||"dark")});a(localStorage.getItem("theme")||(window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light")),e&&e.addEventListener("change",()=>{if(r())return;const t=e.checked?"dark":"light";a(t),async function(e){try{if(!window.ShaaritThemeConfig)return;await window.ShaaritThemeConfig.save({mode:e})}catch(e){console.error("[shaarit] Failed to sync mode to bookmark",e)}}(t)})});
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -107,11 +107,11 @@
</label>
<label class="bookmark-toggle-item" for="lf_note{$index}">
<input type="checkbox" id="lf_note{$index}" class="bookmark-toggle-note" {if="$noteChecked"}checked="checked"{/if}>
<span>Note</span>
<span>{'Note'|t}</span>
</label>
<label class="bookmark-toggle-item" for="lf_todo{$index}">
<input type="checkbox" id="lf_todo{$index}" class="bookmark-toggle-todo" {if="$todoChecked"}checked="checked"{/if}>
<span>Todo</span>
<span>{'Todo'|t}</span>
</label>
<label class="bookmark-toggle-item" for="lf_private{$index}">
<input type="checkbox" {if="$privateChecked"}checked="checked"{/if} name="lf_private" id="lf_private{$index}" />
+1 -1
View File
@@ -21,7 +21,7 @@
{/if}
{if="!empty($stacktrace)"}
<div style="margin-top: 1.5rem;">
<strong>Stack Trace:</strong>
<strong>{'Stack Trace:'|t}</strong>
<pre style="background: var(--bg-body); padding: 1rem; border-radius: 0.375rem; border: 1px solid var(--border); overflow-x: auto; margin-top: 0.5rem;">{$stacktrace}</pre>
</div>
{/if}
+1 -1
View File
@@ -4,7 +4,7 @@
It will be read and overwritten.
Do Not Edit! -->{ignore}The RainTPL loop is formatted to avoid generating extra newlines{/ignore}
<TITLE>{$pagetitle}</TITLE>
<H1>Shaarli export of {$selection} bookmarks on {$date}</H1>
<H1>{'Shaarli export of'|t} {$selection} {'bookmarks on'|t} {$date}</H1>
<DL><p>{loop="links"}
<DT><A HREF="{$value.url}" ADD_DATE="{$value.timestamp}" PRIVATE="{$value.private}" TAGS="{$value.taglist}">{$value.title}</A>{if="$value.description"}{$eol}<DD>{$value.description}{/if}{/loop}
</DL><p>
+4 -4
View File
@@ -28,26 +28,26 @@
<hr style="margin: 1.5rem 0; border-top: 1px solid var(--border);">
<h3 style="font-size: 1.1rem; font-weight: 600; color: var(--text-main); margin-bottom: 1rem;">Selection</h3>
<h3 style="font-size: 1.1rem; font-weight: 600; color: var(--text-main); margin-bottom: 1rem;">{'Selection'|t}</h3>
<div class="form-group">
<div class="radio-group">
<div class="form-group checkbox-wrapper" style="margin-bottom: 0.5rem;">
<input name="selection" type="radio" value="all" id="selection-all" checked style="width: auto;" />
<label for="selection-all" style="margin: 0;">
<strong>Export all</strong> <span class="text-muted">- Export all links</span>
<strong>{'Export all'|t}</strong> <span class="text-muted">- {'Export all links'|t}</span>
</label>
</div>
<div class="form-group checkbox-wrapper" style="margin-bottom: 0.5rem;">
<input name="selection" type="radio" value="public" id="selection-public" style="width: auto;" />
<label for="selection-public" style="margin: 0;">
<strong>Export public</strong> <span class="text-muted">- Export public links only</span>
<strong>{'Export public'|t}</strong> <span class="text-muted">- {'Export public links only'|t}</span>
</label>
</div>
<div class="form-group checkbox-wrapper">
<input name="selection" type="radio" value="private" id="selection-private" style="width: auto;" />
<label for="selection-private" style="margin: 0;">
<strong>Export private</strong> <span class="text-muted">- Export private links only</span>
<strong>{'Export private'|t}</strong> <span class="text-muted">- {'Export private links only'|t}</span>
</label>
</div>
</div>
+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>{$pagetitle}</title>
<subtitle>Shaared links</subtitle>
<subtitle>{'Shaared links'|t}</subtitle>
{if="$show_dates"}
<updated>{$last_update}</updated>
{/if}
@@ -16,7 +16,7 @@
<uri>{$index_url}</uri>
</author>
<id>{$index_url}</id>
<generator>Shaarli</generator>
<generator>{'Shaarli'|t}</generator>
{loop="$links"}
<entry>
<title>{$value.title}</title>
+2 -2
View File
@@ -3,10 +3,10 @@
<channel>
<title>{$pagetitle}</title>
<link>{$index_url}</link>
<description>Shaared links</description>
<description>{'Shaared links'|t}</description>
<language>{$language}</language>
<copyright>{$index_url}</copyright>
<generator>Shaarli</generator>
<generator>{'Shaarli'|t}</generator>
<atom:link rel="self" href="{$self_link}" />
<atom:link rel="search" type="application/opensearchdescription+xml" href="{$index_url}open-search#"
title="Shaarli search - {$shaarlititle}" />
+193
View File
@@ -0,0 +1,193 @@
# Clés de traduction du thème Shaarli Pro
Ce fichier répertorie les chaînes de texte ajoutées ou réutilisées par le thème `shaarli-pro`, au format du filtre de traduction Shaarli : `{'key'|t}`.
> Quand une clé n’existe pas dans le fichier de langue actif de Shaarli, le moteur affiche la clé elle-même. Les clés sont donc rédigées en anglais pour servir de fallback lisible.
## Navigation principale (`page.header.html`)
| Clé | Emplacement d’origine |
|---|---|
| `Main navigation` | `aria-label` du `<aside>` et du `<nav>` header |
| `Search` | Titre de section sidebar + champ header |
| `Open search` | `aria-label` du bouton recherche sidebar |
| `Quick create` | Titre de section sidebar |
| `Navigation` | Titre de section sidebar |
| `All bookmarks` | Lien vers la liste complète |
| `Pinned` | Lien vers les bookmarks épinglés |
| `Tag cloud` | Lien vers le nuage de tags |
| `Picture wall` | Lien vers le mur d’images |
| `Daily` | Lien vers la vue quotidienne |
| `My tasks` | Lien vers les tâches |
| `Notes` | Lien vers les notes |
| `Archive` | Lien vers l’archive |
| `Read It Later` | Lien vers la file de lecture |
| `Administration` | Titre de section sidebar |
| `Tools` | Lien vers les outils d’administration |
| `Settings` | Lien vers la configuration |
| `Light mode` | Libellé du toggle thème |
| `Toggle dark mode` | `aria-label` du toggle thème |
| `Open menu` | `aria-label` du bouton mobile |
| `Home` | Lien d’accueil dans le header |
| `Skip to content` | Lien d’accès rapide (skip-link) |
## Filtres (`page.header.html`)
| Clé | Emplacement d’origine |
|---|---|
| `Filters` | Titre du panneau de filtres |
| `Close filters` | `aria-label` du bouton fermer |
| `Links per page` | Section filtres |
| `20 links` | Option 20 liens/page |
| `50 links` | Option 50 liens/page |
| `100 links` | Option 100 liens/page |
| `Custom value` | Section valeur personnalisée |
| `Number of links per page` | `aria-label` de l’input |
| `Visibility filters` | Section visibilité |
| `Private links only` | Label toggle privé |
| `Filter private links` | `aria-label` toggle privé |
| `Public links only` | Label toggle public |
| `Filter public links` | `aria-label` toggle public |
| `Untagged links` | Label toggle sans tag |
| `Filter untagged links` | `aria-label` toggle sans tag |
| `Multi-select` | `aria-label` du bouton multi-sélection |
| `RSS feed` | `aria-label` du lien RSS |
| `Log out` | `aria-label` déconnexion |
| `Log in` | `aria-label` connexion |
## Recherche (`page.header.html`)
| Clé | Emplacement d’origine |
|---|---|
| `Search (S shortcut)` | `aria-label` du bouton recherche header |
| `Search notes, tags...` | `placeholder` de l’input de recherche |
| `Run search` | `aria-label` du bouton submit |
| `Search modes` | `aria-label` du groupe de modes |
| `Global` | Mode de recherche global |
| `Type to see suggestions...` | Message d’attente dans les résultats |
| `S global` | Raccourci mode global |
| `T tags` | Raccourci mode tags |
| `N notes` | Raccourci mode notes |
| `up down navigate` | Raccourci navigation |
| `Enter select` | Raccourci sélection |
| `Esc close` | Raccourci fermeture |
## Pied de page (`page.footer.html`)
| Clé | Emplacement d’origine |
|---|---|
| `Administration` | Lien admin footer |
| `RSS Feed` | `title` du lien RSS |
| `Atom Feed` | `title` du lien Atom |
| `Daily` | `title` du lien Daily |
| `Close confirmation` | `aria-label` fermeture modale |
| `Cancel` | Bouton annuler modale bulk |
| `Play/Pause` | `title` du bouton lecture |
| `No media` | Titre par défaut du lecteur |
| `Mute/Unmute` | `title` du bouton volume |
| `Close player` | `title` du bouton fermer lecteur |
## Liste de liens (`linklist.html`)
| Clé | Emplacement d’origine |
|---|---|
| `No bookmarks found` | État vide |
| `Bookmarks` | En-tête de section |
| `Title & URL` | En-tête compact |
| `Tags` | En-tête compact |
| `Date` | En-tête compact |
| `Actions` | En-tête compact |
## Actions groupées (`page.footer.html`)
| Clé | Emplacement d’origine |
|---|---|
| `links selected` | Texte bulk info |
| `select all` | Lien tout sélectionner |
| `CANCEL` | Bouton annuler bulk bar |
| `DELETE` | Bouton supprimer |
| `SET PUBLIC` | Bouton rendre public |
| `SET PRIVATE` | Bouton rendre privé |
## Formulaires et pages système
| Clé | Emplacement d’origine |
|---|---|
| `Note` | Choix de type dans `editlink.html` |
| `Todo` | Choix de type dans `editlink.html` |
| `Selection` | Titre section `export.html` |
| `Export all` | Option export totale |
| `Export public` | Option export public |
| `Export private` | Option export privé |
| `File to upload` | Label `import.html` |
| `Privacy Settings` | Titre section `import.html` |
## Vue Daily (`daily.html`)
| Clé | Emplacement d’origine |
|---|---|
| `Shortcuts` | Titre calendrier |
| `Today` | Raccourci aujourd’hui |
| `Yesterday` | Raccourci hier |
| `From` | Label date début |
| `To` | Label date fin |
| `Cancel` | Bouton annuler |
| `Apply` | Bouton appliquer |
| `No articles on this day.` | Message vide |
## Outils (`tools.html`)
| Clé | Emplacement d’origine |
|---|---|
| `Total Links` | Statistique |
| `Private Links` | Statistique |
| `Plugin settings` | Carte configuration plugins |
| `Bookmarklet` | Carte bookmarklet |
| `No custom hidden tags` | Message liste tags cachés |
## Autres
| Clé | Emplacement d’origine |
|---|---|
| `Loading...` | `player.html` titre par défaut |
| `No tags found for this letter` | `tag.cloud.html` |
## JavaScript (`js/i18n.js`)
Ces clés sont utilisées par le helper `window.ShaarliProI18n.t()` pour internationaliser les textes générés dynamiquement.
| Clé | Utilisation |
|---|---|
| `Dark Mode` / `Light Mode` | Libellé du mode dans la sidebar |
| `Search in tags...` / `Search in notes...` / `Search in bookmarks and notes...` | Placeholder de la recherche |
| `Nothing found.` | Résultat de recherche vide |
| `Clear filters` / `Clear` | Bouton de réinitialisation des filtres |
| `The following links will be irretrievably deleted:` / `...set as public:` / `...set as private:` | Sous-titre de la modale bulk |
| `Are you sure to delete {count} link{plural}?` / `...set public?` / `...set private?` | Titre de la modale bulk |
| `DELETE {count} LINK{plural}` / `SET ... PUBLIC` / `SET ... PRIVATE` | Bouton de confirmation bulk |
| `Untitled link` | Lien sans titre dans la modale bulk |
| `Processing...` / `Current thumbnail` / `Thumbnail update done!` / `Error updating thumbnail #{index}` | Mise à jour des miniatures |
| `No description available.` | Modale description vide |
| `Generating QR Code...` / `Failed to generate QR Code` | QR code |
| `Delete tag {tag}` / `Delete` | Gestion des tags Read It Later |
| `Read Later` / `Remove from Read It Later` / `Add to Read It Later` | Read It Later |
| `Play media` / `Open Link` / `LIVE` / `Error loading media` / `No media` | Lecteur média |
| `Stop & Close` / `Play/Pause` / `Mute/Unmute` | Contrôles du lecteur |
| `Default`, `Red`, `Orange`, `Yellow`, `Green`, `Mint`, `Light blue`, `Blue`, `Purple`, `Pink`, `Beige`, `Gray`, `Custom` | Couleurs de notes |
| `None`, `Glass`, `Vignette`, `Lined`, `Grid`, `Noise`, `Dots`, `Stripes` | Filtres de notes |
| `Auto`, `Light`, `Dark`, `White`, `Black` | Couleurs de texte |
| `My images & colors` / `Solid color` / `Custom color` / `Custom font color` | Studio de fonds |
| `Close` / `Reset` / `Search my images…` / `Clear search` | Studio de fonds |
| `Color` / `Edit` / `Create a list` / `List title` / `Mark as completed` / `List item` / `Create a note` / `Title` / `Write your note in Markdown...` | Éditeur de notes/tâches |
| `Heading 1` / `Heading 2` / `Heading 3` / `Text` / `Bold` / `Italic` / `Underline` / `Strikethrough` / `Inline code` / `Bulleted list` / `Numbered list` / `Checklist` / `Quote` / `Link` / `Image` / `Code block` / `Clear formatting` / `Formatting options` | Barre d’outils Markdown |
| `Reminder` / `Collaborator` / `Archive` / `Unarchive` / `Pin` / `Unpin` | Actions note/tâche |
| `Click to edit` / `Move` / `Untitled` | Accessibilité / titres par défaut |
| `Create a task...` / `Add list item` / `Create a note...` / `Archived notes` | Placeholders et sous-titres |
| `January` … `December` / `Mon` … `Sun` | Calendrier Daily |
| `Error removing tag.` / `Error creating todo.` / `Error deleting.` / `Error saving todo.` / `Error creating checklist note.` / `Error creating note.` / `Error saving note.` / `Error archiving note.` / `Error unarchiving note.` / `Failed to save color.` / `Failed to save filter.` / `Failed to save background.` / `Failed to save font color.` / `Failed to save custom color.` / `Please refresh the page.` | Messages d’erreur utilisateur |
## Notes
- Les titres de section en majuscules dans le header (`ACCUEIL`, `ÉPINGLÉS`…) ont été remplacés par les mêmes clés que leur équivalent sidebar, affichées en CSS en majuscules.
- Les `aria-label` restent en anglais pour faciliter la maintenance, mais passent par le filtre de traduction.
- Les clés JavaScript sont définies dans `js/i18n.js` ; elles servent de fallback anglais si elles ne sont pas surchargées par le système de traduction de Shaarli.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+7 -7
View File
@@ -17,11 +17,11 @@
<div class="card-header">{"Import Database"|t}</div>
<div class="card-body">
<p class="text-muted" style="margin-bottom: 1.5rem;">
Import Netscape HTML bookmarks (as exported from Firefox/Chrome/Opera/Delicious/Diigo...).
{'Import Netscape HTML bookmarks (as exported from Firefox/Chrome/Opera/Delicious/Diigo...).'|t}
</p>
<div class="form-group">
<label class="form-label" for="filetoupload">File to upload</label>
<label class="form-label" for="filetoupload">{'File to upload'|t}</label>
<input type="file" id="filetoupload" name="filetoupload" class="form-control" style="height: auto; padding: 0.5rem;"/>
<div class="sublabel">{'Maximum size allowed:'|t} <strong>{$maxfilesizeHuman}</strong></div>
</div>
@@ -33,26 +33,26 @@
<hr style="margin: 1.5rem 0; border-top: 1px solid var(--border);">
<h3 style="font-size: 1.1rem; font-weight: 600; color: var(--text-main); margin-bottom: 1rem;">Privacy Settings</h3>
<h3 style="font-size: 1.1rem; font-weight: 600; color: var(--text-main); margin-bottom: 1rem;">{'Privacy Settings'|t}</h3>
<div class="form-group">
<div class="radio-group">
<div class="form-group checkbox-wrapper" style="margin-bottom: 0.5rem;">
<input name="privacy" type="radio" value="default" id="privacy-default" checked style="width: auto;" />
<label for="privacy-default" style="margin: 0;">
<strong>Default</strong> <span class="text-muted">- {'Use values from the imported file, default to public'|t}</span>
<strong>{'Default'|t}</strong> <span class="text-muted">- {'Use values from the imported file, default to public'|t}</span>
</label>
</div>
<div class="form-group checkbox-wrapper" style="margin-bottom: 0.5rem;">
<input name="privacy" type="radio" value="private" id="privacy-private" style="width: auto;" />
<label for="privacy-private" style="margin: 0;">
<strong>Private</strong> <span class="text-muted">- {'Import all bookmarks as private'|t}</span>
<strong>{'Private'|t}</strong> <span class="text-muted">- {'Import all bookmarks as private'|t}</span>
</label>
</div>
<div class="form-group checkbox-wrapper">
<input name="privacy" type="radio" value="public" id="privacy-public" style="width: auto;" />
<label for="privacy-public" style="margin: 0;">
<strong>Public</strong> <span class="text-muted">- {'Import all bookmarks as public'|t}</span>
<strong>{'Public'|t}</strong> <span class="text-muted">- {'Import all bookmarks as public'|t}</span>
</label>
</div>
</div>
@@ -60,7 +60,7 @@
<div class="form-group">
<label class="form-label" for="default-tags">{'Add default tags'|t}</label>
<input type="text" name="default_tags" id="default-tags" class="form-control" placeholder="Separate tags with comma...">
<input type="text" name="default_tags" id="default-tags" class="form-control" placeholder="{'Separate tags with comma...'|t}">
</div>
</div>
+39 -15
View File
@@ -3,6 +3,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="referrer" content="same-origin">
<link rel="icon" type="image/png" href="{$base_path}/{function="ltrim($asset_path, '/')"}/img/favicon.png">
<link rel="manifest" href="{$base_path}/{function="ltrim($asset_path, '/')"}/manifest.json">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Shaarli Pro">
<link rel="apple-touch-icon" href="{$base_path}/{function="ltrim($asset_path, '/')"}/img/icons/icon-192x192.png">
<link rel="alternate" type="application/rss+xml" href="{$feedurl}?do=rss{$searchcrits}#" title="RSS Feed" />
<link rel="alternate" type="application/atom+xml" href="{$feedurl}?do=atom{$searchcrits}#" title="ATOM Feed" />
<link rel="search" type="application/opensearchdescription+xml" href="{$base_path}/open-search#" title="Shaarli search - {$shaarlititle}" />
@@ -10,17 +15,16 @@
<!-- Prevent dark mode flash (FOUC) - must run before CSS loads -->
<script>
(function() {
// Try to load theme config from bookmark first, fallback to localStorage
// Load theme preferences from localStorage to prevent FOUC
var themeMode = localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
var themeId = localStorage.getItem('shaarit_theme_id') || 'DEFAULT';
// Try to fetch theme config from bookmark (async, but we'll use localStorage as fallback)
// This is a simplified check - the full sync happens in script.js
var bookmarkConfig = sessionStorage.getItem('themes_config');
if (bookmarkConfig) {
// Apply persisted theme config (saved by the theme picker)
var storedConfig = localStorage.getItem('shaarit_themes_config');
if (storedConfig) {
try {
var config = JSON.parse(bookmarkConfig);
var config = JSON.parse(storedConfig);
if (config.default) themeId = config.default;
if (config.mode) themeMode = config.mode;
} catch(e) {}
@@ -48,13 +52,13 @@
})();
</script>
<!-- Professional Theme CSS -->
<link type="text/css" rel="stylesheet" href="{$base_path}/{function="ltrim($asset_path, '/')"}/css/style.css?v=1.0.4" />
<link type="text/css" rel="stylesheet" href="{$base_path}/{function="ltrim($asset_path, '/')"}/css/themes.css?v=1.0.0" />
<link type="text/css" rel="stylesheet" href="{$base_path}/{function="ltrim($asset_path, '/')"}/css/custom_views.css?v=1.0.5" />
<!-- Professional Theme CSS (minified build) -->
<link type="text/css" rel="stylesheet" href="{$base_path}/{function="ltrim($asset_path, '/')"}/dist/style.min.css?v={$version_hash}" />
<link type="text/css" rel="stylesheet" href="{$base_path}/{function="ltrim($asset_path, '/')"}/dist/themes.min.css?v={$version_hash}" />
<link type="text/css" rel="stylesheet" href="{$base_path}/{function="ltrim($asset_path, '/')"}/dist/custom_views.min.css?v={$version_hash}" />
{if="$pageName=='editlink' || $pageName=='addlink' || $pageName=='editlinkbatch'"}
<link type="text/css" rel="stylesheet" href="{$base_path}/{function="ltrim($asset_path, '/')"}/css/awesomplete.css?v=1.0.4" />
<script src="{$base_path}/{function="ltrim($asset_path, '/')"}/js/awesomplete.min.js?v=1.0.4" defer></script>
<link type="text/css" rel="stylesheet" href="{$base_path}/{function="ltrim($asset_path, '/')"}/dist/awesomplete.min.css?v={$version_hash}" />
<script src="{$base_path}/{function="ltrim($asset_path, '/')"}/dist/awesomplete.min.js?v={$version_hash}" defer></script>
{/if}
{if="$pageName=='editlink' || $pageName=='editlinkbatch' || ($pageName=='linklist' && isset($search_tags) && preg_match('/(^|[\s,])note([\s,]|$)/i', (string) $search_tags))"}
@@ -68,6 +72,9 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<!-- Google Fonts (loaded here instead of @import in CSS to avoid render block) -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<!-- Icons (Material Design Icons) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/MaterialDesign-Webfont/7.2.96/css/materialdesignicons.min.css" crossorigin="anonymous">
@@ -95,9 +102,26 @@ visibility: '{$visibility}',
untaggedonly: (function(){/*{if="$untaggedonly"}*/return true;/*{else}*/return false;/*{/if}*/})()
};
</script>
<script src="{$base_path}/{function="ltrim($asset_path, '/')"}/js/shaarit-rules.js?v=1.0.0" defer></script>
<script src="{$base_path}/{function="ltrim($asset_path, '/')"}/js/script.js?v=1.0.5" defer></script>
<script src="{$base_path}/{function="ltrim($asset_path, '/')"}/js/custom_views.js?v=1.0.6" defer></script>
<script src="{$base_path}/{function="ltrim($asset_path, '/')"}/dist/i18n.min.js?v={$version_hash}" defer></script>
<script src="{$base_path}/{function="ltrim($asset_path, '/')"}/dist/a11y.min.js?v={$version_hash}" defer></script>
<script src="{$base_path}/{function="ltrim($asset_path, '/')"}/dist/shaarit-rules.min.js?v={$version_hash}" defer></script>
<script src="{$base_path}/{function="ltrim($asset_path, '/')"}/dist/theme.min.js?v={$version_hash}" defer></script>
<script src="{$base_path}/{function="ltrim($asset_path, '/')"}/dist/navigation.min.js?v={$version_hash}" defer></script>
<script src="{$base_path}/{function="ltrim($asset_path, '/')"}/dist/search.min.js?v={$version_hash}" defer></script>
<script src="{$base_path}/{function="ltrim($asset_path, '/')"}/dist/bulk-actions.min.js?v={$version_hash}" defer></script>
<script src="{$base_path}/{function="ltrim($asset_path, '/')"}/dist/script.min.js?v={$version_hash}" defer></script>
<script src="{$base_path}/{function="ltrim($asset_path, '/')"}/dist/custom_views.min.js?v={$version_hash}" defer></script>
<!-- Register PWA service worker -->
<script>
(function() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('{$base_path}/{function="ltrim($asset_path, '/')"}/service-worker.js')
.then(function() { /* service worker registered */ })
.catch(function() { /* registration failed, continue normally */ });
}
})();
</script>
{if="file_exists('tpl/shaarli-pro/extra.html')"}
{include="extra"}
+1 -1
View File
@@ -11,7 +11,7 @@
<div class="col-md-6 col-md-offset-3">
<form method="post" action="{$base_path}/install" name="installform" id="installform">
<div class="card">
<div class="card-header">Welcome to your Shaarli</div>
<div class="card-header">{'Welcome to your Shaarli'|t}</div>
<div class="card-body">
<p class="text-muted" style="margin-bottom: 1.5rem;">{'It looks like it\'s the first time you run Shaarli. Please configure it.'|t}</p>
<div class="form-group">
+185 -265
View File
@@ -15,9 +15,12 @@ document.addEventListener("DOMContentLoaded", function () {
.filter((t) => t);
// Foolproof detection using sidebar active state and DOM rendered tags
const hasNoteActiveMenu = !!document.querySelector('.sidebar-link[aria-label="Notes"].active, .header-nav-link[aria-label="Notes"].active, .sidebar-link[href*="searchtags=note"].active, .sidebar-link[href*="searchtags=shaarli-note"].active');
const hasTodoActiveMenu = !!document.querySelector('.sidebar-link[aria-label="Mes tâches"].active, .header-nav-link[aria-label="Mes tâches"].active, .sidebar-link[href*="searchtags=shaarli-todo"].active, .sidebar-link[href*="searchtags=todo"].active');
const hasArchiveActiveMenu = !!document.querySelector('.sidebar-link[aria-label="Archive"].active, .header-nav-link[aria-label="Archive"].active, .sidebar-link[href*="searchtags=shaarli-archive"].active, .sidebar-link[href*="searchtags=shaarli-archiver"].active');
const hasNoteActiveMenu = !!document.querySelector('.sidebar-link[aria-label="Notes"].active, .header-nav-link[aria-label="Notes"].active, .sidebar-link[href*="searchtags=note"].active, .sidebar-link[href*="searchtags=shaarli-note"].active') ||
!!document.querySelector('.sidebar-link[aria-label="' + window.ShaarliProI18n.t('Notes') + '"].active, .header-nav-link[aria-label="' + window.ShaarliProI18n.t('Notes') + '"].active');
const hasTodoActiveMenu = !!document.querySelector('.sidebar-link[aria-label="Mes tâches"].active, .header-nav-link[aria-label="Mes tâches"].active, .sidebar-link[href*="searchtags=shaarli-todo"].active, .sidebar-link[href*="searchtags=todo"].active') ||
!!document.querySelector('.sidebar-link[aria-label="' + window.ShaarliProI18n.t('My tasks') + '"].active, .header-nav-link[aria-label="' + window.ShaarliProI18n.t('My tasks') + '"].active');
const hasArchiveActiveMenu = !!document.querySelector('.sidebar-link[aria-label="Archive"].active, .header-nav-link[aria-label="Archive"].active, .sidebar-link[href*="searchtags=shaarli-archive"].active, .sidebar-link[href*="searchtags=shaarli-archiver"].active') ||
!!document.querySelector('.sidebar-link[aria-label="' + window.ShaarliProI18n.t('Archive') + '"].active, .header-nav-link[aria-label="' + window.ShaarliProI18n.t('Archive') + '"].active');
const domChipTags = Array.from(document.querySelectorAll(".search-tag-chip")).map((el) => (el.textContent || "").trim().toLowerCase());
@@ -416,7 +419,6 @@ document.addEventListener("DOMContentLoaded", function () {
if (isTodoView) {
try {
console.log("[custom_views] Initializing Todo view");
initTodoView(linkList, container);
} catch (err) {
console.error("[custom_views] Erreur lors de l'initialisation de la vue Todo:", err);
@@ -424,7 +426,6 @@ document.addEventListener("DOMContentLoaded", function () {
}
} else if (isNoteView) {
try {
console.log("[custom_views] Initializing Note view");
// Pour la vue notes, parser les notes AVANT de supprimer les tags techniques
// afin que les propriétés visuelles (couleur, fond, etc.) soient correctement extraites
initNoteView(linkList, container);
@@ -438,7 +439,6 @@ document.addEventListener("DOMContentLoaded", function () {
}
} else if (isArchiveView) {
try {
console.log("[custom_views] Initializing Archive view");
// Vue Archive - similaire à Notes mais pour les notes archivées
initArchiveView(linkList, container);
// Puis supprimer les tags techniques de l'affichage
@@ -489,104 +489,39 @@ function applyKeepNoteFormatting(textarea, format) {
}
const NOTE_COLOR_OPTIONS = [
{
key: "default",
label: "Par défaut",
light: "#f8fafc",
dark: "#20293A",
},
{
key: "red",
label: "Rouge",
light: "#f28b82",
dark: "#9c2116",
},
{
key: "orange",
label: "Orange",
light: "#fbbc04",
dark: "#9c7a16",
},
{
key: "yellow",
label: "Jaune",
light: "#fff475",
dark: "#9c9116",
},
{
key: "green",
label: "Vert",
light: "#ccff90",
dark: "#5e9c16",
},
{
key: "teal",
label: "Menthe",
light: "#a7ffeb",
dark: "#169c7d",
},
{
key: "blue",
label: "Bleu clair",
light: "#cbf0f8",
dark: "#16849c",
},
{
key: "darkblue",
label: "Bleu",
light: "#aecbfa",
dark: "#16499c",
},
{
key: "purple",
label: "Violet",
light: "#d7aefb",
dark: "#5d169c",
},
{
key: "pink",
label: "Rose",
light: "#fdcfe8",
dark: "#9c165f",
},
{
key: "brown",
label: "Beige",
light: "#e6c9a8",
dark: "#9c5d16",
},
{
key: "grey",
label: "Gris",
light: "#e8eaed",
dark: "#4e5764",
},
{
key: "custom",
label: "Personnalisé",
light: "#custom",
dark: "#custom",
},
{ key: "default", label: window.ShaarliProI18n.t("Default"), light: "#f8fafc", dark: "#20293A" },
{ key: "red", label: window.ShaarliProI18n.t("Red"), light: "#f28b82", dark: "#9c2116" },
{ key: "orange", label: window.ShaarliProI18n.t("Orange"), light: "#fbbc04", dark: "#9c7a16" },
{ key: "yellow", label: window.ShaarliProI18n.t("Yellow"), light: "#fff475", dark: "#9c9116" },
{ key: "green", label: window.ShaarliProI18n.t("Green"), light: "#ccff90", dark: "#5e9c16" },
{ key: "teal", label: window.ShaarliProI18n.t("Mint"), light: "#a7ffeb", dark: "#169c7d" },
{ key: "blue", label: window.ShaarliProI18n.t("Light blue"), light: "#cbf0f8", dark: "#16849c" },
{ key: "darkblue", label: window.ShaarliProI18n.t("Blue"), light: "#aecbfa", dark: "#16499c" },
{ key: "purple", label: window.ShaarliProI18n.t("Purple"), light: "#d7aefb", dark: "#5d169c" },
{ key: "pink", label: window.ShaarliProI18n.t("Pink"), light: "#fdcfe8", dark: "#9c165f" },
{ key: "brown", label: window.ShaarliProI18n.t("Beige"), light: "#e6c9a8", dark: "#9c5d16" },
{ key: "grey", label: window.ShaarliProI18n.t("Gray"), light: "#e8eaed", dark: "#4e5764" },
{ key: "custom", label: window.ShaarliProI18n.t("Custom"), light: "#custom", dark: "#custom" },
];
const NOTE_FILTER_OPTIONS = [
{ key: "none", label: "Aucun", icon: "mdi-close-circle-outline" },
{ key: "glass", label: "Glass", icon: "mdi-blur" },
{ key: "vignette", label: "Vignette", icon: "mdi-vignette" },
{ key: "lined", label: "Ligné", icon: "mdi-format-align-justify" },
{ key: "grid", label: "Quadrillé", icon: "mdi-grid" },
{ key: "noise", label: "Noise", icon: "mdi-grain" },
{ key: "dots", label: "Points", icon: "mdi-dots-grid" },
{ key: "stripes", label: "Rayures", icon: "mdi-slash-forward" },
{ key: "none", label: window.ShaarliProI18n.t("None"), icon: "mdi-close-circle-outline" },
{ key: "glass", label: window.ShaarliProI18n.t("Glass"), icon: "mdi-blur" },
{ key: "vignette", label: window.ShaarliProI18n.t("Vignette"), icon: "mdi-vignette" },
{ key: "lined", label: window.ShaarliProI18n.t("Lined"), icon: "mdi-format-align-justify" },
{ key: "grid", label: window.ShaarliProI18n.t("Grid"), icon: "mdi-grid" },
{ key: "noise", label: window.ShaarliProI18n.t("Noise"), icon: "mdi-grain" },
{ key: "dots", label: window.ShaarliProI18n.t("Dots"), icon: "mdi-dots-grid" },
{ key: "stripes", label: window.ShaarliProI18n.t("Stripes"), icon: "mdi-slash-forward" },
];
const NOTE_FONT_COLOR_OPTIONS = [
{ key: "auto", label: "Auto", value: "auto" },
{ key: "light", label: "Clair", value: "#f5f7fb" },
{ key: "dark", label: "Sombre", value: "#202124" },
{ key: "white", label: "Blanc", value: "#ffffff" },
{ key: "black", label: "Noir", value: "#000000" },
{ key: "custom", label: "Personnalisé", value: "custom" },
{ key: "auto", label: window.ShaarliProI18n.t("Auto"), value: "auto" },
{ key: "light", label: window.ShaarliProI18n.t("Light"), value: "#f5f7fb" },
{ key: "dark", label: window.ShaarliProI18n.t("Dark"), value: "#202124" },
{ key: "white", label: window.ShaarliProI18n.t("White"), value: "#ffffff" },
{ key: "black", label: window.ShaarliProI18n.t("Black"), value: "#000000" },
{ key: "custom", label: window.ShaarliProI18n.t("Custom"), value: "custom" },
];
const NOTE_FONT_COLOR_TAG_PREFIX = "font-";
@@ -641,7 +576,7 @@ function createTagPill({ tag, onRemoveClass = "tag-remove-btn", tagClass = "tag-
btn.className = onRemoveClass;
btn.dataset.tag = tag;
btn.setAttribute("aria-label", `Supprimer le tag ${tag}`);
btn.title = "Supprimer";
btn.title = window.ShaarliProI18n.t("Delete");
btn.innerHTML = "&times;";
wrapper.appendChild(btn);
@@ -788,7 +723,7 @@ function initTagDisplayAndRemoval() {
})
.catch((err) => {
console.error("Error removing tag:", err);
alert("Erreur lors de la suppression du tag.");
alert(window.ShaarliProI18n.t("Error removing tag."));
});
});
@@ -835,7 +770,6 @@ let backgroundOptionsLoadPromise = null;
window.__shaarliCustomViewsVersion = "1.0.5-inline-bg-manifest";
if (typeof console !== "undefined" && console && typeof console.info === "function") {
console.info("[custom_views] loaded", window.__shaarliCustomViewsVersion);
}
function organizePinnedBookmarks() {
@@ -912,19 +846,19 @@ function initPinnedBookmarksSeparation() {
document.addEventListener("DOMContentLoaded", initPinnedBookmarksSeparation);
const NOTE_BACKGROUND_MANIFEST_INLINE = [
{ key: "cafe", label: "Café", files: { light: "bg-light-cafe.jpg", dark: "bg-dark-cafe.jpg" } },
{ key: "cafe", label: window.ShaarliProI18n.t("Coffee"), files: { light: "bg-light-cafe.jpg", dark: "bg-dark-cafe.jpg" } },
{ key: "codes", label: "Codes", files: { light: "bg-light-codes.jpg", dark: "bg-dark-codes.jpg" } },
{ key: "dune", label: "Dune", files: { light: "bg-light-dune.jpg", dark: "bg-dark-dune.jpg" } },
{ key: "feuille-ligne", label: "Feuille Ligne", files: { light: "bg-light-feuilleLigne.jpg", dark: "bg-dark-feuilleLigne.jpg" } },
{ key: "feuille-quadrillage", label: "Feuille Quadrillage", files: { light: "bg-light-feuilleQuadrille.jpg", dark: "bg-dark-feuilleQuadrille.jpg" } },
{ key: "fleurs", label: "Fleurs", files: { light: "bg-light-fleurs.jpg", dark: "bg-dark-fleurs.jpg" } },
{ key: "foret", label: "Forêt", files: { light: "bg-light-foret.jpg", dark: "bg-dark-foret.jpg" } },
{ key: "foret", label: window.ShaarliProI18n.t("Forest"), files: { light: "bg-light-foret.jpg", dark: "bg-dark-foret.jpg" } },
{ key: "grid", label: "Grid", files: { light: "bg-light-grid.jpg", dark: "bg-dark-grid.jpg" } },
{ key: "journal", label: "Journal", files: { light: "bg-light-journal.jpg", dark: "bg-dark-journal.jpg" } },
{ key: "lecture", label: "Lecture", files: { light: "bg-light-lecture.jpg", dark: "bg-dark-lecture.jpg" } },
{ key: "legumes", label: "Légumes", files: { light: "bg-light-legumes.jpg", dark: "bg-dark-legumes.jpg" } },
{ key: "legumes", label: window.ShaarliProI18n.t("Vegetables"), files: { light: "bg-light-legumes.jpg", dark: "bg-dark-legumes.jpg" } },
{ key: "montagnes", label: "Montagnes", files: { light: "bg-light-montagnes.jpg", dark: "bg-dark-montagnes.jpg" } },
{ key: "ocean", label: "Océan", files: { light: "bg-light-ocean.jpg", dark: "bg-dark-ocean.jpg" } },
{ key: "ocean", label: window.ShaarliProI18n.t("Ocean"), files: { light: "bg-light-ocean.jpg", dark: "bg-dark-ocean.jpg" } },
{ key: "radio", label: "Radio", files: { light: "bg-light-radio.jpg", dark: "bg-dark-radio.jpg" } },
{ key: "sports", label: "Sports", files: { light: "bg-light-sports.jpg", dark: "bg-dark-sports.jpg" } },
{ key: "vague1", label: "Vague 1", files: { light: "bg-light-vague1.jpg", dark: "bg-dark-vague1.jpg" } },
@@ -1167,7 +1101,7 @@ function refreshBackgroundPalettes() {
if (!noteId) return;
const actions = card.querySelector(".link-actions");
const editLink = actions ? actions.querySelector('a[title="Modifier"], a[aria-label="Modifier ce bookmark"], a[href*="/admin/shaare/"]') : null;
const editLink = actions ? actions.querySelector('a[title=window.ShaarliProI18n.t("Edit")], a[aria-label="Modifier ce bookmark"], a[href*="/admin/shaare/"]') : null;
palettePopup.innerHTML = generateBookmarkPaletteButtons(noteId, editLink && editLink.href ? editLink.href : "#", getElementVisualColor(card), getElementVisualBackground(card));
});
@@ -1528,7 +1462,7 @@ function openBackgroundStudioPanel({ anchorEl, mode, entityId, editUrl, currentC
panel.dataset.background = normalizeBackgroundKey(currentBackground || "") || "none";
panel.dataset.fontColor = currentFontColor || panel.dataset.fontColor || "auto";
panel.dataset.query = panel.dataset.query || "";
panel.dataset.title = title || "Mes images & couleurs";
panel.dataset.title = title || window.ShaarliProI18n.t("My images & colors");
panel.__anchorEl = anchorEl || null;
renderBackgroundStudioPanel(panel);
@@ -1616,7 +1550,7 @@ function getBackgroundStudioItems() {
type: "background",
category: "solid",
key: "none",
label: "Couleur unie",
label: window.ShaarliProI18n.t("Solid color"),
keywords: ["none", "sans image", "solid", "uni"],
},
...getAvailableBackgroundOptionsForMode().map((bg) => ({
@@ -1640,7 +1574,7 @@ function renderBackgroundStudioPanel(panel) {
const caretStart = wasSearchFocused && typeof activeEl.selectionStart === "number" ? activeEl.selectionStart : null;
const caretEnd = wasSearchFocused && typeof activeEl.selectionEnd === "number" ? activeEl.selectionEnd : null;
const title = panel.dataset.title || "Mes images & couleurs";
const title = panel.dataset.title || window.ShaarliProI18n.t("My images & colors");
const color = panel.dataset.color || "default";
const isCustomColor = typeof color === "string" && color.startsWith("custom:");
const currentFilter = panel.dataset.filter || "none";
@@ -1681,7 +1615,7 @@ function renderBackgroundStudioPanel(panel) {
})
.join("") +
// Add custom color button (opens color picker)
`<button class="bg-studio-swatch bg-studio-swatch-custom ${isCustomColor ? "is-active" : ""}" type="button" data-bg-studio-action="open-color-picker" title="Couleur personnalisée" aria-label="Couleur personnalisée" aria-pressed="${isCustomColor}">
`<button class="bg-studio-swatch bg-studio-swatch-custom ${isCustomColor ? "is-active" : ""}" type="button" data-bg-studio-action="open-color-picker" title="${window.ShaarliProI18n.t("Custom color")}" aria-label="${window.ShaarliProI18n.t("Custom color")}" aria-pressed="${isCustomColor}">
<i class="mdi mdi-plus" aria-hidden="true"></i>
</button>`;
@@ -1697,7 +1631,7 @@ function renderBackgroundStudioPanel(panel) {
})
.join("") +
// Add custom font color button
`<button class="bg-studio-swatch bg-studio-swatch-custom ${isCustomFontColor ? "is-active" : ""}" type="button" data-bg-studio-action="open-font-color-picker" title="Couleur de texte personnalisée" aria-label="Couleur de texte personnalisée" aria-pressed="${isCustomFontColor}">
`<button class="bg-studio-swatch bg-studio-swatch-custom ${isCustomFontColor ? "is-active" : ""}" type="button" data-bg-studio-action="open-font-color-picker" title="${window.ShaarliProI18n.t("Custom font color")}" aria-label="${window.ShaarliProI18n.t("Custom font color")}" aria-pressed="${isCustomFontColor}">
<i class="mdi mdi-plus" aria-hidden="true"></i>
</button>`;
@@ -1713,21 +1647,21 @@ function renderBackgroundStudioPanel(panel) {
panel.innerHTML = `
<div class="bg-studio-header">
<div class="bg-studio-title">${title}</div>
<button class="bg-studio-close" type="button" data-bg-studio-action="close" title="Fermer" aria-label="Fermer"><i class="mdi mdi-close" aria-hidden="true"></i></button>
<button class="bg-studio-close" type="button" data-bg-studio-action="close" title=window.ShaarliProI18n.t("Close") aria-label=window.ShaarliProI18n.t("Close")><i class="mdi mdi-close" aria-hidden="true"></i></button>
</div>
<div class="bg-studio-gallery" role="list">${galleryHtml}</div>
<div class="bg-studio-rows">
<div class="bg-studio-row">
<div class="bg-studio-row-heading">
<div class="bg-studio-row-label">Colors:</div>
<button type="button" class="bg-studio-reset" data-bg-studio-action="set-defaults" title="Réinitialiser" aria-label="Réinitialiser"><i class="mdi mdi-refresh" aria-hidden="true"></i></button>
<button type="button" class="bg-studio-reset" data-bg-studio-action="set-defaults" title=window.ShaarliProI18n.t("Reset") aria-label=window.ShaarliProI18n.t("Reset")><i class="mdi mdi-refresh" aria-hidden="true"></i></button>
</div>
<div class="bg-studio-swatches" role="list">${colorsRowHtml}</div>
</div>
<div class="bg-studio-row">
<div class="bg-studio-row-heading">
<div class="bg-studio-row-label">Font:</div>
<button type="button" class="bg-studio-reset" data-bg-studio-action="reset-font-color" title="Réinitialiser" aria-label="Réinitialiser"><i class="mdi mdi-refresh" aria-hidden="true"></i></button>
<button type="button" class="bg-studio-reset" data-bg-studio-action="reset-font-color" title=window.ShaarliProI18n.t("Reset") aria-label=window.ShaarliProI18n.t("Reset")><i class="mdi mdi-refresh" aria-hidden="true"></i></button>
</div>
<div class="bg-studio-swatches bg-studio-swatches-font" role="list">${fontColorsRowHtml}</div>
</div>
@@ -1738,8 +1672,8 @@ function renderBackgroundStudioPanel(panel) {
</div>
<div class="bg-studio-search">
<i class="mdi mdi-magnify" aria-hidden="true"></i>
<input class="bg-studio-search-input" type="search" placeholder="Rechercher mes images… (filtrer par couleur ou filtre)" value="${panel.dataset.query || ""}" />
<button class="bg-studio-clear" type="button" data-bg-studio-action="set-query" title="Effacer" aria-label="Effacer" style="${showClear ? "" : "display:none"}"><i class="mdi mdi-close-circle-outline" aria-hidden="true"></i></button>
<input class="bg-studio-search-input" type="search" placeholder=window.ShaarliProI18n.t("Search my images… (filter by color or filter)") value="${panel.dataset.query || ""}" />
<button class="bg-studio-clear" type="button" data-bg-studio-action="set-query" title=window.ShaarliProI18n.t("Clear search") aria-label=window.ShaarliProI18n.t("Clear search") style="${showClear ? "" : "display:none"}"><i class="mdi mdi-close-circle-outline" aria-hidden="true"></i></button>
</div>
`;
@@ -1922,7 +1856,7 @@ function initBookmarkPaletteButtons() {
if (!actions) return;
if (actions.querySelector(".bookmark-palette")) return;
const editLink = actions.querySelector('a[title="Modifier"], a[aria-label="Modifier ce bookmark"], a[href*="/admin/shaare/"]');
const editLink = actions.querySelector('a[title=window.ShaarliProI18n.t("Edit")], a[aria-label="Modifier ce bookmark"], a[href*="/admin/shaare/"]');
if (!editLink || !editLink.href) return;
const editUrl = editLink.href;
@@ -1931,10 +1865,10 @@ function initBookmarkPaletteButtons() {
wrapper.className = "bookmark-palette";
wrapper.style.position = "relative";
wrapper.innerHTML = `
<button type="button" title="Couleur" aria-label="Couleur" id="${paletteBtnId}"><i class="mdi mdi-palette-outline" aria-hidden="true"></i></button>
<button type="button" title=window.ShaarliProI18n.t("Color") aria-label=window.ShaarliProI18n.t("Color") id="${paletteBtnId}"><i class="mdi mdi-palette-outline" aria-hidden="true"></i></button>
`;
const pinLink = Array.from(actions.querySelectorAll('a[href*="/pin"], a[aria-label*="Épingler"], a[title*="Épingler"]'))[0];
const pinLink = Array.from(actions.querySelectorAll('a[href*="/pin"], a[aria-label*=window.ShaarliProI18n.t("Pin")], a[title*=window.ShaarliProI18n.t("Pin")]'))[0];
if (pinLink && pinLink.parentNode === actions) {
actions.insertBefore(wrapper, pinLink);
} else if (editLink && editLink.parentNode === actions) {
@@ -1959,7 +1893,7 @@ function initBookmarkPaletteButtons() {
currentFilter: getElementVisualFilter(card),
currentBackground: getElementVisualBackground(card),
currentFontColor: getElementVisualFontColor(card),
title: "Mes images & couleurs",
title: window.ShaarliProI18n.t("My images & colors"),
});
});
});
@@ -1990,7 +1924,7 @@ function generateUnifiedPaletteMenu({ entityId, editUrl, currentColor, currentFi
const onFilterClick = mode === "modal" ? (k) => `setModalNoteFilter('${k}')` : (k) => `setNoteFilter('${entityId}', '${k}', '${editUrl}')`;
const colorButtons = [
`<button class="palette-btn palette-btn-default ${color === "default" ? "is-active" : ""}" type="button" title="Par défaut" aria-label="Par défaut" aria-pressed="${color === "default"}" onclick="${onColorClick("default")}"><i class="mdi mdi-format-color-reset" aria-hidden="true"></i></button>`,
`<button class="palette-btn palette-btn-default ${color === "default" ? "is-active" : ""}" type="button" title="${window.ShaarliProI18n.t("Default")}" aria-label="${window.ShaarliProI18n.t("Default")}" aria-pressed="${color === "default"}" onclick="${onColorClick("default")}"><i class="mdi mdi-format-color-reset" aria-hidden="true"></i></button>`,
...NOTE_COLOR_OPTIONS.filter((opt) => opt.key !== "default").map((opt) => {
const swatchColor = getThemeColorValue(opt);
return `<button class="palette-btn note-color-${opt.key} ${color === opt.key ? "is-active" : ""}" type="button" title="${opt.label}" aria-label="${opt.label}" aria-pressed="${color === opt.key}" onclick="${onColorClick(opt.key)}" style="background-color:${swatchColor}"></button>`;
@@ -2055,9 +1989,9 @@ function initTodoView(linkList, container) {
applyNoteVisualState(inputContainer, { color: "default", filter: "none", background: "none", fontColor: "auto" });
inputContainer.innerHTML = `
<div class="note-input-collapsed">
<span class="note-input-placeholder">Créer une tâche...</span>
<span class="note-input-placeholder">${window.ShaarliProI18n.t("Create a task...")}</span>
<div class="note-input-actions">
<button type="button" class="todo-open-draft-btn" title="Créer une liste"><i class="mdi mdi-check-circle-outline"></i></button>
<button type="button" class="todo-open-draft-btn" title=window.ShaarliProI18n.t("Create a list")><i class="mdi mdi-check-circle-outline"></i></button>
</div>
</div>
`;
@@ -2069,12 +2003,12 @@ function initTodoView(linkList, container) {
inputContainer.classList.add("is-editing", "todo-draft-mode");
inputContainer.innerHTML = `
<div class="note-input-expanded todo-draft-expanded">
<input type="text" class="note-input-title todo-draft-title" placeholder="Titre de la liste" autocomplete="off" />
<input type="text" class="note-input-title todo-draft-title" placeholder=window.ShaarliProI18n.t("List title") autocomplete="off" />
<div class="todo-draft-list" role="list"></div>
<button type="button" class="todo-draft-add-btn"><i class="mdi mdi-plus"></i> Élément de liste</button>
<button type="button" class="todo-draft-add-btn"><i class="mdi mdi-plus"></i> ${window.ShaarliProI18n.t("Add list item")}</button>
<div class="note-input-expanded-actions">
<div class="note-input-actions-left">
<button type="button" class="note-input-palette-btn" title="Couleur"><i class="mdi mdi-palette-outline"></i></button>
<button type="button" class="note-input-palette-btn" title=window.ShaarliProI18n.t("Color")><i class="mdi mdi-palette-outline"></i></button>
</div>
<button type="button" class="note-input-close-btn">Fermer</button>
</div>
@@ -2097,12 +2031,12 @@ function initTodoView(linkList, container) {
const row = document.createElement("div");
row.className = "todo-draft-row";
row.innerHTML = `
<label class="todo-checklist-box" title="Marquer comme terminée">
<label class="todo-checklist-box" title=window.ShaarliProI18n.t("Mark as completed")>
<input type="checkbox" class="todo-item-checkbox" ${item.checked ? "checked" : ""} />
<span class="todo-checklist-box-ui"></span>
</label>
<input type="text" class="todo-item-text" placeholder="Élément de liste" value="${escapeHtml(item.text || "")}" />
<button type="button" class="todo-item-delete" title="Supprimer"><i class="mdi mdi-close"></i></button>
<input type="text" class="todo-item-text" placeholder=window.ShaarliProI18n.t("List item") value="${escapeHtml(item.text || "")}" />
<button type="button" class="todo-item-delete" title=window.ShaarliProI18n.t("Delete")><i class="mdi mdi-close"></i></button>
`;
listEl.appendChild(row);
@@ -2189,7 +2123,7 @@ function initTodoView(linkList, container) {
window.location.reload();
} catch (err) {
console.error("Error creating todo:", err);
alert("Erreur lors de la création de la tâche.");
alert(window.ShaarliProI18n.t("Error creating todo."));
isSaving = false;
}
};
@@ -2220,7 +2154,7 @@ function initTodoView(linkList, container) {
currentFilter: draftVisual.filter || "none",
currentBackground: draftVisual.background || "none",
currentFontColor: draftVisual.fontColor || "auto",
title: "Mes images & couleurs",
title: window.ShaarliProI18n.t("My images & colors"),
});
const panel = document.getElementById("shaarli-bg-studio");
@@ -2252,8 +2186,8 @@ function initTodoView(linkList, container) {
const tools = document.createElement("div");
tools.className = "notes-tools";
tools.innerHTML = `
<button class="icon-btn active" id="btn-view-grid" title="Vue grille"><i class="mdi mdi-view-dashboard-outline"></i></button>
<button class="icon-btn" id="btn-view-list" title="Vue liste"><i class="mdi mdi-view-agenda-outline"></i></button>
<button class="icon-btn active" id="btn-view-grid" title=window.ShaarliProI18n.t("Grid view")><i class="mdi mdi-view-dashboard-outline"></i></button>
<button class="icon-btn" id="btn-view-list" title=window.ShaarliProI18n.t("List view")><i class="mdi mdi-view-agenda-outline"></i></button>
`;
topBar.appendChild(tools);
wrapper.appendChild(topBar);
@@ -2281,23 +2215,23 @@ function initTodoView(linkList, container) {
<div class="note-modal note-color-default todo-modal">
<div class="note-modal-header">
<h2 class="note-title" id="todo-modal-title"></h2>
<button type="button" class="note-modal-pin-toggle" id="todo-modal-pin" title="Épingler">
<button type="button" class="note-modal-pin-toggle" id="todo-modal-pin" title=window.ShaarliProI18n.t("Pin")>
<i class="mdi mdi-pin-outline"></i>
</button>
</div>
<div class="note-modal-content">
<div class="todo-checklist" id="todo-modal-checklist"></div>
<button type="button" class="todo-add-item-btn" id="todo-modal-add-item"><i class="mdi mdi-plus"></i> Élément de liste</button>
<button type="button" class="todo-add-item-btn" id="todo-modal-add-item"><i class="mdi mdi-plus"></i> ${window.ShaarliProI18n.t("Add list item")}</button>
</div>
<div class="note-modal-tags is-empty" id="todo-modal-tags"></div>
<div class="note-modal-actions">
<div class="note-modal-actions-left">
<div class="note-modal-color-picker">
<button type="button" id="todo-modal-color-btn" title="Couleur"><i class="mdi mdi-palette-outline"></i></button>
<button type="button" id="todo-modal-color-btn" title=window.ShaarliProI18n.t("Color")><i class="mdi mdi-palette-outline"></i></button>
<div class="palette-popup note-modal-palette" id="todo-modal-color-popup"></div>
</div>
<a href="#" id="todo-modal-edit" title="Modifier"><i class="mdi mdi-pencil-outline"></i></a>
<button type="button" id="todo-modal-delete" title="Supprimer"><i class="mdi mdi-delete-outline"></i></button>
<a href="#" id="todo-modal-edit" title=window.ShaarliProI18n.t("Edit")><i class="mdi mdi-pencil-outline"></i></a>
<button type="button" id="todo-modal-delete" title=window.ShaarliProI18n.t("Delete")><i class="mdi mdi-delete-outline"></i></button>
</div>
<button type="button" class="note-modal-close-btn" id="todo-modal-close">Fermer</button>
</div>
@@ -2344,7 +2278,7 @@ function initTodoView(linkList, container) {
currentFilter: modalCard ? getElementVisualFilter(modalCard) : "none",
currentBackground: modalCard ? getElementVisualBackground(modalCard) : "none",
currentFontColor: modalCard ? getElementVisualFontColor(modalCard) : "auto",
title: "Mes images & couleurs",
title: window.ShaarliProI18n.t("My images & colors"),
});
});
@@ -2377,7 +2311,7 @@ function initTodoView(linkList, container) {
});
modalOverlay.querySelector("#todo-modal-delete").addEventListener("click", () => {
if (!confirm("Supprimer cette tâche ?")) return;
if (!confirm(window.ShaarliProI18n.t("Delete this task?"))) return;
const modalCard = modalOverlay.querySelector(".note-modal");
const deleteUrl = modalCard.dataset.deleteUrl;
const todoId = modalCard.dataset.todoId;
@@ -2388,7 +2322,7 @@ function initTodoView(linkList, container) {
})
.catch((err) => {
console.error("Delete failed:", err);
alert("Erreur lors de la suppression.");
alert(window.ShaarliProI18n.t("Error deleting."));
});
}
});
@@ -2791,15 +2725,6 @@ async function hydrateTodoFromEditForm(todo) {
todo._todoFooterLines = parsed.footerLines;
todo.items = parsed.items;
if (!todo._todoDebugLogged && rawMarkdown && String(rawMarkdown).trim() && (!parsed.items || parsed.items.length === 0)) {
todo._todoDebugLogged = true;
console.warn("Todo parsed 0 items from lf_description; sample follows.", {
id: todo && todo.id,
url: todo && todo.editUrl,
sample: String(rawMarkdown).slice(0, 240),
});
}
updateTodoCardPreview(todo);
todo._todoHydrated = true;
} catch (e) {
@@ -2880,25 +2805,25 @@ function renderTodos(container, todos, viewMode) {
actions.innerHTML = `
<div style="position:relative;">
<button title="Couleur" id="${paletteBtnId}"><i class="mdi mdi-palette-outline"></i></button>
<button title=window.ShaarliProI18n.t("Color") id="${paletteBtnId}"><i class="mdi mdi-palette-outline"></i></button>
</div>
<div class="spacer"></div>
<a href="${todo.editUrl}" title="Modifier"><i class="mdi mdi-pencil-outline"></i></a>
<button title="Supprimer" class="todo-delete-btn"><i class="mdi mdi-delete-outline"></i></button>
<a href="${todo.editUrl}" title=window.ShaarliProI18n.t("Edit")><i class="mdi mdi-pencil-outline"></i></a>
<button title=window.ShaarliProI18n.t("Delete") class="todo-delete-btn"><i class="mdi mdi-delete-outline"></i></button>
`;
const deleteBtn = actions.querySelector(".todo-delete-btn");
deleteBtn?.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
if (!confirm("Supprimer cette tâche ?")) return;
if (!confirm(window.ShaarliProI18n.t("Delete this task?"))) return;
deleteEntitySilently(todo.deleteUrl)
.then(() => {
window.location.href = "/?searchtags=todo";
})
.catch((err) => {
console.error("Delete failed:", err);
alert("Erreur lors de la suppression.");
alert(window.ShaarliProI18n.t("Error deleting."));
});
});
@@ -2914,7 +2839,7 @@ function renderTodos(container, todos, viewMode) {
currentColor: getElementVisualColor(card),
currentFilter: getElementVisualFilter(card),
currentBackground: getElementVisualBackground(card),
title: "Mes images & couleurs",
title: window.ShaarliProI18n.t("My images & colors"),
});
});
@@ -3050,13 +2975,13 @@ function renderTodoModalChecklist(modal, todo) {
row.setAttribute("draggable", "true");
row.classList.toggle("is-checked", !!item.checked);
row.innerHTML = `
<button type="button" class="todo-drag-handle" aria-label="Déplacer"><i class="mdi mdi-dots-grid"></i></button>
<button type="button" class="todo-drag-handle" aria-label=window.ShaarliProI18n.t("Move")><i class="mdi mdi-dots-grid"></i></button>
<label class="todo-checklist-box">
<input type="checkbox" class="todo-item-checkbox" ${item.checked ? "checked" : ""}>
<span class="todo-checklist-box-ui"></span>
</label>
<input type="text" class="todo-item-text" value="${escapeHtml(item.text || "")}" placeholder="">
<button type="button" class="todo-item-delete" aria-label="Supprimer"><i class="mdi mdi-close"></i></button>
<button type="button" class="todo-item-delete" aria-label=window.ShaarliProI18n.t("Delete")><i class="mdi mdi-close"></i></button>
`;
const cb = row.querySelector(".todo-item-checkbox");
@@ -3172,7 +3097,7 @@ async function openTodoModal(todo) {
const visibleTags = (todo.tags || []).filter((tag) => tag && !isTechnicalTag(tag));
modalCard.dataset.tags = visibleTags.join("||");
title.textContent = todo.title || "Sans titre";
title.textContent = todo.title || window.ShaarliProI18n.t("Untitled");
renderModalTags(tagsContainer, visibleTags);
if (editLink) {
@@ -3263,7 +3188,7 @@ function scheduleTodoSave(modal, todo) {
modal._todoSaveTimer = setTimeout(() => {
persistTodoChanges(todo).catch((err) => {
console.error("Error saving todo:", err);
alert("Erreur lors de la sauvegarde de la tâche. Veuillez rafraîchir la page.");
alert(window.ShaarliProI18n.t("Error saving todo.") + " " + window.ShaarliProI18n.t("Please refresh the page."));
});
}, 600);
}
@@ -3383,9 +3308,9 @@ function initNoteView(linkList, container) {
applyNoteVisualState(inputContainer, { color: "default", filter: "none", background: "none", fontColor: "auto" });
inputContainer.innerHTML = `
<div class="note-input-collapsed">
<span class="note-input-placeholder">Créer une note...</span>
<span class="note-input-placeholder">${window.ShaarliProI18n.t("Create a note...")}</span>
<div class="note-input-actions note-input-type-actions">
<button type="button" class="note-create-note-btn" title="Créer une note"><i class="mdi mdi-note-outline"></i></button>
<button type="button" class="note-create-note-btn" title=window.ShaarliProI18n.t("Create a note")><i class="mdi mdi-note-outline"></i></button>
</div>
</div>
`;
@@ -3398,12 +3323,12 @@ function initNoteView(linkList, container) {
inputContainer.classList.add("is-editing", "todo-draft-mode");
inputContainer.innerHTML = `
<div class="note-input-expanded todo-draft-expanded">
<input type="text" class="note-input-title todo-draft-title" placeholder="Titre de la liste" autocomplete="off" />
<input type="text" class="note-input-title todo-draft-title" placeholder=window.ShaarliProI18n.t("List title") autocomplete="off" />
<div class="todo-draft-list" role="list"></div>
<button type="button" class="todo-draft-add-btn"><i class="mdi mdi-plus"></i> Élément de liste</button>
<button type="button" class="todo-draft-add-btn"><i class="mdi mdi-plus"></i> ${window.ShaarliProI18n.t("Add list item")}</button>
<div class="note-input-expanded-actions">
<div class="note-input-actions-left">
<button type="button" class="note-input-palette-btn" title="Couleur"><i class="mdi mdi-palette-outline"></i></button>
<button type="button" class="note-input-palette-btn" title=window.ShaarliProI18n.t("Color")><i class="mdi mdi-palette-outline"></i></button>
</div>
<button type="button" class="note-input-close-btn">Fermer</button>
</div>
@@ -3426,12 +3351,12 @@ function initNoteView(linkList, container) {
const row = document.createElement("div");
row.className = "todo-draft-row";
row.innerHTML = `
<label class="todo-checklist-box" title="Marquer comme terminée">
<label class="todo-checklist-box" title=window.ShaarliProI18n.t("Mark as completed")>
<input type="checkbox" class="todo-item-checkbox" ${item.checked ? "checked" : ""} />
<span class="todo-checklist-box-ui"></span>
</label>
<input type="text" class="todo-item-text" placeholder="Élément de liste" value="${escapeHtml(item.text || "")}" />
<button type="button" class="todo-item-delete" title="Supprimer"><i class="mdi mdi-close"></i></button>
<input type="text" class="todo-item-text" placeholder=window.ShaarliProI18n.t("List item") value="${escapeHtml(item.text || "")}" />
<button type="button" class="todo-item-delete" title=window.ShaarliProI18n.t("Delete")><i class="mdi mdi-close"></i></button>
`;
listEl.appendChild(row);
@@ -3517,7 +3442,7 @@ function initNoteView(linkList, container) {
window.location.reload();
} catch (err) {
console.error("Error creating checklist note:", err);
alert("Erreur lors de la création de la note liste.");
alert(window.ShaarliProI18n.t("Error creating checklist note."));
isSaving = false;
}
};
@@ -3548,7 +3473,7 @@ function initNoteView(linkList, container) {
currentFilter: draftVisual.filter || "none",
currentBackground: draftVisual.background || "none",
currentFontColor: draftVisual.fontColor || "auto",
title: "Mes images & couleurs",
title: window.ShaarliProI18n.t("My images & colors"),
});
const panel = document.getElementById("shaarli-bg-studio");
@@ -3567,33 +3492,33 @@ function initNoteView(linkList, container) {
inputContainer.classList.add("is-editing");
inputContainer.innerHTML = `
<div class="note-input-expanded">
<input type="text" class="note-input-title" placeholder="Titre" />
<textarea class="note-input-description-source" rows="8" placeholder="Écrivez votre note en Markdown..."></textarea>
<input type="text" class="note-input-title" placeholder=window.ShaarliProI18n.t("Title") />
<textarea class="note-input-description-source" rows="8" placeholder=window.ShaarliProI18n.t("Write your note in Markdown...")></textarea>
<div class="note-formatting-bar" aria-hidden="true">
<button type="button" class="note-format-btn" data-note-format="h1" title="Titre 1">H1</button>
<button type="button" class="note-format-btn" data-note-format="h2" title="Titre 2">H2</button>
<button type="button" class="note-format-btn" data-note-format="h3" title="Titre 3">H3</button>
<button type="button" class="note-format-btn" data-note-format="p" title="Texte">Aa</button>
<button type="button" class="note-format-btn" data-note-format="h1" title=window.ShaarliProI18n.t("Heading 1")>H1</button>
<button type="button" class="note-format-btn" data-note-format="h2" title=window.ShaarliProI18n.t("Heading 2")>H2</button>
<button type="button" class="note-format-btn" data-note-format="h3" title=window.ShaarliProI18n.t("Heading 3")>H3</button>
<button type="button" class="note-format-btn" data-note-format="p" title=window.ShaarliProI18n.t("Text")>Aa</button>
<span class="note-format-sep" aria-hidden="true"></span>
<button type="button" class="note-format-btn" data-note-format="bold" title="Gras"><b>B</b></button>
<button type="button" class="note-format-btn" data-note-format="italic" title="Italique"><i>I</i></button>
<button type="button" class="note-format-btn" data-note-format="underline" title="Souligné"><u>U</u></button>
<button type="button" class="note-format-btn" data-note-format="strike" title="Barré"><s>S</s></button>
<button type="button" class="note-format-btn" data-note-format="code" title="Code inline"><i class="mdi mdi-code-tags"></i></button>
<button type="button" class="note-format-btn" data-note-format="bold" title=window.ShaarliProI18n.t("Bold")><b>B</b></button>
<button type="button" class="note-format-btn" data-note-format="italic" title=window.ShaarliProI18n.t("Italic")><i>I</i></button>
<button type="button" class="note-format-btn" data-note-format="underline" title=window.ShaarliProI18n.t("Underline")><u>U</u></button>
<button type="button" class="note-format-btn" data-note-format="strike" title=window.ShaarliProI18n.t("Strikethrough")><s>S</s></button>
<button type="button" class="note-format-btn" data-note-format="code" title=window.ShaarliProI18n.t("Inline code")><i class="mdi mdi-code-tags"></i></button>
<span class="note-format-sep" aria-hidden="true"></span>
<button type="button" class="note-format-btn" data-note-format="ul" title="Liste à puces"><i class="mdi mdi-format-list-bulleted"></i></button>
<button type="button" class="note-format-btn" data-note-format="ol" title="Liste numérotée"><i class="mdi mdi-format-list-numbered"></i></button>
<button type="button" class="note-format-btn" data-note-format="todo" title="Checklist"><i class="mdi mdi-format-list-checks"></i></button>
<button type="button" class="note-format-btn" data-note-format="quote" title="Citation"><i class="mdi mdi-format-quote-open"></i></button>
<button type="button" class="note-format-btn" data-note-format="link" title="Lien"><i class="mdi mdi-link-variant"></i></button>
<button type="button" class="note-format-btn" data-note-format="image" title="Image"><i class="mdi mdi-image-outline"></i></button>
<button type="button" class="note-format-btn" data-note-format="codeblock" title="Bloc de code"><i class="mdi mdi-code-braces"></i></button>
<button type="button" class="note-format-btn" data-note-format="clear" title="Effacer la mise en forme">X</button>
<button type="button" class="note-format-btn" data-note-format="ul" title=window.ShaarliProI18n.t("Bulleted list")><i class="mdi mdi-format-list-bulleted"></i></button>
<button type="button" class="note-format-btn" data-note-format="ol" title=window.ShaarliProI18n.t("Numbered list")><i class="mdi mdi-format-list-numbered"></i></button>
<button type="button" class="note-format-btn" data-note-format="todo" title=window.ShaarliProI18n.t("Checklist")><i class="mdi mdi-format-list-checks"></i></button>
<button type="button" class="note-format-btn" data-note-format="quote" title=window.ShaarliProI18n.t("Quote")><i class="mdi mdi-format-quote-open"></i></button>
<button type="button" class="note-format-btn" data-note-format="link" title=window.ShaarliProI18n.t("Link")><i class="mdi mdi-link-variant"></i></button>
<button type="button" class="note-format-btn" data-note-format="image" title=window.ShaarliProI18n.t("Image")><i class="mdi mdi-image-outline"></i></button>
<button type="button" class="note-format-btn" data-note-format="codeblock" title=window.ShaarliProI18n.t("Code block")><i class="mdi mdi-code-braces"></i></button>
<button type="button" class="note-format-btn" data-note-format="clear" title=window.ShaarliProI18n.t("Clear formatting")>X</button>
</div>
<div class="note-input-expanded-actions">
<div class="note-input-actions-left">
<button type="button" class="note-input-format-btn" title="Options de mise en forme"><i class="mdi mdi-format-text"></i></button>
<button type="button" class="note-input-palette-btn" title="Couleur"><i class="mdi mdi-palette-outline"></i></button>
<button type="button" class="note-input-format-btn" title=window.ShaarliProI18n.t("Formatting options")><i class="mdi mdi-format-text"></i></button>
<button type="button" class="note-input-palette-btn" title=window.ShaarliProI18n.t("Color")><i class="mdi mdi-palette-outline"></i></button>
</div>
<button type="button" class="note-input-close-btn">Fermer</button>
</div>
@@ -3676,7 +3601,7 @@ function initNoteView(linkList, container) {
window.location.reload();
} catch (err) {
console.error("Error creating note:", err);
alert("Erreur lors de la création de la note.");
alert(window.ShaarliProI18n.t("Error creating note."));
isSaving = false;
}
};
@@ -3708,7 +3633,7 @@ function initNoteView(linkList, container) {
currentFilter: draftVisual.filter || "none",
currentBackground: draftVisual.background || "none",
currentFontColor: draftVisual.fontColor || "auto",
title: "Mes images & couleurs",
title: window.ShaarliProI18n.t("My images & colors"),
});
const panel = document.getElementById("shaarli-bg-studio");
@@ -3742,8 +3667,8 @@ function initNoteView(linkList, container) {
const tools = document.createElement("div");
tools.className = "notes-tools";
tools.innerHTML = `
<button class="icon-btn active" id="btn-view-grid" title="Vue grille"><i class="mdi mdi-view-dashboard-outline"></i></button>
<button class="icon-btn" id="btn-view-list" title="Vue liste"><i class="mdi mdi-view-agenda-outline"></i></button>
<button class="icon-btn active" id="btn-view-grid" title=window.ShaarliProI18n.t("Grid view")><i class="mdi mdi-view-dashboard-outline"></i></button>
<button class="icon-btn" id="btn-view-list" title=window.ShaarliProI18n.t("List view")><i class="mdi mdi-view-agenda-outline"></i></button>
`;
topBar.appendChild(tools);
@@ -3790,50 +3715,50 @@ function initNoteView(linkList, container) {
modalOverlay.innerHTML = `
<div class="note-modal" role="dialog" aria-modal="true">
<div class="note-modal-header">
<input type="text" class="note-title note-modal-title-input" id="note-modal-title" placeholder="Titre" />
<button type="button" class="note-modal-pin-toggle" id="note-modal-pin" title="Épingler">
<input type="text" class="note-title note-modal-title-input" id="note-modal-title" placeholder=window.ShaarliProI18n.t("Title") />
<button type="button" class="note-modal-pin-toggle" id="note-modal-pin" title=window.ShaarliProI18n.t("Pin")>
<i class="mdi mdi-pin-outline"></i>
</button>
</div>
<div class="note-modal-content">
<div class="note-modal-description-preview note-body" id="note-modal-description-preview" role="button" tabindex="0" aria-label="Cliquer pour modifier"></div>
<div class="note-modal-description-preview note-body" id="note-modal-description-preview" role="button" tabindex="0" aria-label=window.ShaarliProI18n.t("Click to edit")></div>
<textarea class="note-modal-description-source" id="note-modal-description-source" rows="8"></textarea>
<div class="note-formatting-bar" id="note-modal-formatting" aria-hidden="true">
<button type="button" class="note-format-btn" data-note-format="h1" title="Titre 1">H1</button>
<button type="button" class="note-format-btn" data-note-format="h2" title="Titre 2">H2</button>
<button type="button" class="note-format-btn" data-note-format="h3" title="Titre 3">H3</button>
<button type="button" class="note-format-btn" data-note-format="p" title="Texte">Aa</button>
<button type="button" class="note-format-btn" data-note-format="h1" title=window.ShaarliProI18n.t("Heading 1")>H1</button>
<button type="button" class="note-format-btn" data-note-format="h2" title=window.ShaarliProI18n.t("Heading 2")>H2</button>
<button type="button" class="note-format-btn" data-note-format="h3" title=window.ShaarliProI18n.t("Heading 3")>H3</button>
<button type="button" class="note-format-btn" data-note-format="p" title=window.ShaarliProI18n.t("Text")>Aa</button>
<span class="note-format-sep" aria-hidden="true"></span>
<button type="button" class="note-format-btn" data-note-format="bold" title="Gras"><b>B</b></button>
<button type="button" class="note-format-btn" data-note-format="italic" title="Italique"><i>I</i></button>
<button type="button" class="note-format-btn" data-note-format="underline" title="Souligné"><u>U</u></button>
<button type="button" class="note-format-btn" data-note-format="strike" title="Barré"><s>S</s></button>
<button type="button" class="note-format-btn" data-note-format="code" title="Code inline"><i class="mdi mdi-code-tags"></i></button>
<button type="button" class="note-format-btn" data-note-format="bold" title=window.ShaarliProI18n.t("Bold")><b>B</b></button>
<button type="button" class="note-format-btn" data-note-format="italic" title=window.ShaarliProI18n.t("Italic")><i>I</i></button>
<button type="button" class="note-format-btn" data-note-format="underline" title=window.ShaarliProI18n.t("Underline")><u>U</u></button>
<button type="button" class="note-format-btn" data-note-format="strike" title=window.ShaarliProI18n.t("Strikethrough")><s>S</s></button>
<button type="button" class="note-format-btn" data-note-format="code" title=window.ShaarliProI18n.t("Inline code")><i class="mdi mdi-code-tags"></i></button>
<span class="note-format-sep" aria-hidden="true"></span>
<button type="button" class="note-format-btn" data-note-format="ul" title="Liste à puces"><i class="mdi mdi-format-list-bulleted"></i></button>
<button type="button" class="note-format-btn" data-note-format="ol" title="Liste numérotée"><i class="mdi mdi-format-list-numbered"></i></button>
<button type="button" class="note-format-btn" data-note-format="todo" title="Checklist"><i class="mdi mdi-format-list-checks"></i></button>
<button type="button" class="note-format-btn" data-note-format="quote" title="Citation"><i class="mdi mdi-format-quote-open"></i></button>
<button type="button" class="note-format-btn" data-note-format="link" title="Lien"><i class="mdi mdi-link-variant"></i></button>
<button type="button" class="note-format-btn" data-note-format="image" title="Image"><i class="mdi mdi-image-outline"></i></button>
<button type="button" class="note-format-btn" data-note-format="codeblock" title="Bloc de code"><i class="mdi mdi-code-braces"></i></button>
<button type="button" class="note-format-btn" data-note-format="clear" title="Effacer la mise en forme">X</button>
<button type="button" class="note-format-btn" data-note-format="ul" title=window.ShaarliProI18n.t("Bulleted list")><i class="mdi mdi-format-list-bulleted"></i></button>
<button type="button" class="note-format-btn" data-note-format="ol" title=window.ShaarliProI18n.t("Numbered list")><i class="mdi mdi-format-list-numbered"></i></button>
<button type="button" class="note-format-btn" data-note-format="todo" title=window.ShaarliProI18n.t("Checklist")><i class="mdi mdi-format-list-checks"></i></button>
<button type="button" class="note-format-btn" data-note-format="quote" title=window.ShaarliProI18n.t("Quote")><i class="mdi mdi-format-quote-open"></i></button>
<button type="button" class="note-format-btn" data-note-format="link" title=window.ShaarliProI18n.t("Link")><i class="mdi mdi-link-variant"></i></button>
<button type="button" class="note-format-btn" data-note-format="image" title=window.ShaarliProI18n.t("Image")><i class="mdi mdi-image-outline"></i></button>
<button type="button" class="note-format-btn" data-note-format="codeblock" title=window.ShaarliProI18n.t("Code block")><i class="mdi mdi-code-braces"></i></button>
<button type="button" class="note-format-btn" data-note-format="clear" title=window.ShaarliProI18n.t("Clear formatting")>X</button>
</div>
</div>
<div class="note-modal-tags is-empty" id="note-modal-tags"></div>
<div class="note-modal-actions">
<div class="note-modal-actions-left">
<div class="note-modal-color-picker">
<button type="button" id="note-modal-color-btn" title="Couleur"><i class="mdi mdi-palette-outline"></i></button>
<button type="button" id="note-modal-color-btn" title=window.ShaarliProI18n.t("Color")><i class="mdi mdi-palette-outline"></i></button>
<div class="palette-popup note-modal-palette" id="note-modal-color-popup"></div>
</div>
<button type="button" id="note-modal-format-btn" title="Options de mise en forme"><i class="mdi mdi-format-text"></i></button>
<button type="button" title="Rappel"><i class="mdi mdi-bell-outline"></i></button>
<button type="button" title="Collaborateur"><i class="mdi mdi-account-plus-outline"></i></button>
<button type="button" title="Image"><i class="mdi mdi-image-outline"></i></button>
<button type="button" id="note-modal-archive" title="Archiver"><i class="mdi mdi-archive-arrow-down-outline"></i></button>
<a href="#" id="note-modal-edit" title="Modifier"><i class="mdi mdi-pencil-outline"></i></a>
<button type="button" id="note-modal-delete" title="Supprimer"><i class="mdi mdi-delete-outline"></i></button>
<button type="button" id="note-modal-format-btn" title=window.ShaarliProI18n.t("Formatting options")><i class="mdi mdi-format-text"></i></button>
<button type="button" title=window.ShaarliProI18n.t("Reminder")><i class="mdi mdi-bell-outline"></i></button>
<button type="button" title=window.ShaarliProI18n.t("Collaborator")><i class="mdi mdi-account-plus-outline"></i></button>
<button type="button" title=window.ShaarliProI18n.t("Image")><i class="mdi mdi-image-outline"></i></button>
<button type="button" id="note-modal-archive" title=window.ShaarliProI18n.t("Archive")><i class="mdi mdi-archive-arrow-down-outline"></i></button>
<a href="#" id="note-modal-edit" title=window.ShaarliProI18n.t("Edit")><i class="mdi mdi-pencil-outline"></i></a>
<button type="button" id="note-modal-delete" title=window.ShaarliProI18n.t("Delete")><i class="mdi mdi-delete-outline"></i></button>
</div>
<button type="button" class="note-modal-close-btn" id="note-modal-close">Fermer</button>
</div>
@@ -3864,7 +3789,7 @@ function initNoteView(linkList, container) {
document.body.classList.remove("note-modal-open");
} catch (e) {
console.error("Error saving note from modal:", e);
alert("Erreur lors de la sauvegarde de la note.");
alert(window.ShaarliProI18n.t("Error saving note."));
}
};
@@ -3900,7 +3825,7 @@ function initNoteView(linkList, container) {
currentFilter: modalCard ? getElementVisualFilter(modalCard) : "none",
currentBackground: modalCard ? getElementVisualBackground(modalCard) : "none",
currentFontColor: modalCard ? getElementVisualFontColor(modalCard) : "auto",
title: "Mes images & couleurs",
title: window.ShaarliProI18n.t("My images & colors"),
});
});
@@ -3935,7 +3860,7 @@ function initNoteView(linkList, container) {
});
modalOverlay.querySelector("#note-modal-delete").addEventListener("click", () => {
if (!confirm("Supprimer cette note ?")) return;
if (!confirm(window.ShaarliProI18n.t("Delete this note?"))) return;
const modalCard = modalOverlay.querySelector(".note-modal");
const deleteUrl = modalCard.dataset.deleteUrl;
const noteId = modalCard.dataset.noteId;
@@ -3946,7 +3871,7 @@ function initNoteView(linkList, container) {
})
.catch((err) => {
console.error("Delete failed:", err);
alert("Erreur lors de la suppression.");
alert(window.ShaarliProI18n.t("Error deleting."));
});
}
});
@@ -3978,7 +3903,7 @@ function initNoteView(linkList, container) {
})
.catch((err) => {
console.error("Error archiving note:", err);
alert("Erreur lors de l'archivage de la note.");
alert(window.ShaarliProI18n.t("Error archiving note."));
});
});
@@ -4022,7 +3947,7 @@ function initArchiveView(linkList, container) {
titleContainer.className = "archive-title-container";
titleContainer.innerHTML = `
<h1 class="archive-title"><i class="mdi mdi-archive-arrow-down-outline"></i> Archive</h1>
<p class="archive-subtitle">Notes archivées</p>
<p class="archive-subtitle">${window.ShaarliProI18n.t("Archived notes")}</p>
`;
topBar.appendChild(titleContainer);
@@ -4030,8 +3955,8 @@ function initArchiveView(linkList, container) {
const tools = document.createElement("div");
tools.className = "notes-tools";
tools.innerHTML = `
<button class="icon-btn active" id="btn-view-grid" title="Vue grille"><i class="mdi mdi-view-dashboard-outline"></i></button>
<button class="icon-btn" id="btn-view-list" title="Vue liste"><i class="mdi mdi-view-agenda-outline"></i></button>
<button class="icon-btn active" id="btn-view-grid" title=window.ShaarliProI18n.t("Grid view")><i class="mdi mdi-view-dashboard-outline"></i></button>
<button class="icon-btn" id="btn-view-list" title=window.ShaarliProI18n.t("List view")><i class="mdi mdi-view-agenda-outline"></i></button>
`;
topBar.appendChild(tools);
@@ -4067,7 +3992,7 @@ function initArchiveView(linkList, container) {
<div class="note-modal note-color-default">
<div class="note-modal-header">
<h2 class="note-title" id="note-modal-title"></h2>
<button type="button" class="note-modal-pin-toggle" id="note-modal-pin" title="Épingler">
<button type="button" class="note-modal-pin-toggle" id="note-modal-pin" title=window.ShaarliProI18n.t("Pin")>
<i class="mdi mdi-pin-outline"></i>
</button>
</div>
@@ -4076,15 +4001,15 @@ function initArchiveView(linkList, container) {
<div class="note-modal-actions">
<div class="note-modal-actions-left">
<div class="note-modal-color-picker">
<button type="button" id="note-modal-color-btn" title="Couleur"><i class="mdi mdi-palette-outline"></i></button>
<button type="button" id="note-modal-color-btn" title=window.ShaarliProI18n.t("Color")><i class="mdi mdi-palette-outline"></i></button>
<div class="palette-popup note-modal-palette" id="note-modal-color-popup"></div>
</div>
<button type="button" title="Rappel"><i class="mdi mdi-bell-outline"></i></button>
<button type="button" title="Collaborateur"><i class="mdi mdi-account-plus-outline"></i></button>
<button type="button" title="Image"><i class="mdi mdi-image-outline"></i></button>
<button type="button" id="note-modal-unarchive" title="Désarchiver"><i class="mdi mdi-archive-arrow-up-outline"></i></button>
<a href="#" id="note-modal-edit" title="Modifier"><i class="mdi mdi-pencil-outline"></i></a>
<button type="button" id="note-modal-delete" title="Supprimer"><i class="mdi mdi-delete-outline"></i></button>
<button type="button" title=window.ShaarliProI18n.t("Reminder")><i class="mdi mdi-bell-outline"></i></button>
<button type="button" title=window.ShaarliProI18n.t("Collaborator")><i class="mdi mdi-account-plus-outline"></i></button>
<button type="button" title=window.ShaarliProI18n.t("Image")><i class="mdi mdi-image-outline"></i></button>
<button type="button" id="note-modal-unarchive" title=window.ShaarliProI18n.t("Unarchive")><i class="mdi mdi-archive-arrow-up-outline"></i></button>
<a href="#" id="note-modal-edit" title=window.ShaarliProI18n.t("Edit")><i class="mdi mdi-pencil-outline"></i></a>
<button type="button" id="note-modal-delete" title=window.ShaarliProI18n.t("Delete")><i class="mdi mdi-delete-outline"></i></button>
</div>
<button type="button" class="note-modal-close-btn" id="note-modal-close">Fermer</button>
</div>
@@ -4133,7 +4058,7 @@ function initArchiveView(linkList, container) {
currentFilter: modalCard ? getElementVisualFilter(modalCard) : "none",
currentBackground: modalCard ? getElementVisualBackground(modalCard) : "none",
currentFontColor: modalCard ? getElementVisualFontColor(modalCard) : "auto",
title: "Mes images & couleurs",
title: window.ShaarliProI18n.t("My images & colors"),
});
});
@@ -4168,7 +4093,7 @@ function initArchiveView(linkList, container) {
});
modalOverlay.querySelector("#note-modal-delete").addEventListener("click", () => {
if (!confirm("Supprimer cette note ?")) return;
if (!confirm(window.ShaarliProI18n.t("Delete this note?"))) return;
const modalCard = modalOverlay.querySelector(".note-modal");
const deleteUrl = modalCard.dataset.deleteUrl;
const noteId = modalCard.dataset.noteId;
@@ -4179,7 +4104,7 @@ function initArchiveView(linkList, container) {
})
.catch((err) => {
console.error("Delete failed:", err);
alert("Erreur lors de la suppression.");
alert(window.ShaarliProI18n.t("Error deleting."));
});
}
});
@@ -4209,7 +4134,7 @@ function initArchiveView(linkList, container) {
})
.catch((err) => {
console.error("Error unarchiving note:", err);
alert("Erreur lors du désarchivage de la note.");
alert(window.ShaarliProI18n.t("Error unarchiving note."));
});
});
@@ -4397,31 +4322,31 @@ function renderNotes(container, notes, viewMode, isArchiveMode = false) {
const archiveBtnId = `archive-${note.id}`;
actions.innerHTML = `
<button title="Rappel"><i class="mdi mdi-bell-outline"></i></button>
<button title="Collaborateur"><i class="mdi mdi-account-plus-outline"></i></button>
<button title=window.ShaarliProI18n.t("Reminder")><i class="mdi mdi-bell-outline"></i></button>
<button title=window.ShaarliProI18n.t("Collaborator")><i class="mdi mdi-account-plus-outline"></i></button>
<div style="position:relative;">
<button title="Couleur" id="${paletteBtnId}"><i class="mdi mdi-palette-outline"></i></button>
<button title=window.ShaarliProI18n.t("Color") id="${paletteBtnId}"><i class="mdi mdi-palette-outline"></i></button>
</div>
<button title="Image"><i class="mdi mdi-image-outline"></i></button>
<button title="Archiver" id="${archiveBtnId}"><i class="mdi mdi-archive-arrow-down-outline"></i></button>
<button title=window.ShaarliProI18n.t("Image")><i class="mdi mdi-image-outline"></i></button>
<button title=window.ShaarliProI18n.t("Archive") id="${archiveBtnId}"><i class="mdi mdi-archive-arrow-down-outline"></i></button>
<div class="spacer"></div>
<!-- Real Actions -->
<button type="button" class="note-open-editor-btn" title="Modifier"><i class="mdi mdi-pencil-outline"></i></button>
<button title="Supprimer" class="note-delete-btn"><i class="mdi mdi-delete-outline"></i></button>
<button type="button" class="note-open-editor-btn" title=window.ShaarliProI18n.t("Edit")><i class="mdi mdi-pencil-outline"></i></button>
<button title=window.ShaarliProI18n.t("Delete") class="note-delete-btn"><i class="mdi mdi-delete-outline"></i></button>
`;
const deleteBtn = actions.querySelector(".note-delete-btn");
deleteBtn?.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
if (!confirm("Supprimer cette note ?")) return;
if (!confirm(window.ShaarliProI18n.t("Delete this note?"))) return;
deleteEntitySilently(note.deleteUrl)
.then(() => {
window.location.href = isArchiveMode ? "/?searchtags=shaarli-archive" : "/?searchtags=note";
})
.catch((err) => {
console.error("Delete failed:", err);
alert("Erreur lors de la suppression.");
alert(window.ShaarliProI18n.t("Error deleting."));
});
});
@@ -4446,7 +4371,7 @@ function renderNotes(container, notes, viewMode, isArchiveMode = false) {
currentColor: getElementVisualColor(card),
currentFilter: getElementVisualFilter(card),
currentBackground: getElementVisualBackground(card),
title: "Mes images & couleurs",
title: window.ShaarliProI18n.t("My images & colors"),
});
});
@@ -4462,11 +4387,10 @@ function renderNotes(container, notes, viewMode, isArchiveMode = false) {
.then(() => {
// Remove the card from the view
card.remove();
console.log(`Note ${note.id} archived`);
})
.catch((err) => {
console.error("Error archiving note:", err);
alert("Erreur lors de l'archivage de la note.");
alert(window.ShaarliProI18n.t("Error archiving note."));
});
});
@@ -4481,7 +4405,7 @@ function setModalPinButtonState(button, isPinned) {
if (!button) return;
button.classList.toggle("active", isPinned);
button.title = isPinned ? "Désépingler" : "Épingler";
button.title = isPinned ? window.ShaarliProI18n.t("Unpin") : window.ShaarliProI18n.t("Pin");
const icon = button.querySelector("i");
if (icon) {
@@ -4893,14 +4817,13 @@ window.setNoteColor = function (noteId, color, editUrl) {
})
.then((response) => {
if (response.ok) {
console.log(`Color ${color} saved for note ${noteId}`);
} else {
throw new Error("Failed to save color");
}
})
.catch((err) => {
console.error("Error saving note color:", err);
alert("Erreur lors de la sauvegarde de la couleur. Veuillez rafraîchir la page.");
alert(window.ShaarliProI18n.t("Failed to save color.") + " " + window.ShaarliProI18n.t("Please refresh the page."));
});
};
@@ -4989,7 +4912,7 @@ window.setNoteFilter = function (noteId, filterKey, editUrl) {
})
.catch((err) => {
console.error("Error saving note filter:", err);
alert("Erreur lors de la sauvegarde du filtre. Veuillez rafraîchir la page.");
alert(window.ShaarliProI18n.t("Failed to save filter.") + " " + window.ShaarliProI18n.t("Please refresh the page."));
});
};
@@ -5074,7 +4997,7 @@ window.setNoteBackground = function (noteId, backgroundKey, editUrl) {
})
.catch((err) => {
console.error("Error saving note background:", err);
alert("Erreur lors de la sauvegarde du fond. Veuillez rafraîchir la page.");
alert(window.ShaarliProI18n.t("Failed to save background.") + " " + window.ShaarliProI18n.t("Please refresh the page."));
});
};
@@ -5419,7 +5342,6 @@ function togglePinTag(id, editUrl, btn) {
})
.then((res) => {
if (res.ok) {
console.log("Pin toggled successfully");
if (typeof organizePinnedBookmarks === "function") {
window.requestAnimationFrame(organizePinnedBookmarks);
}
@@ -5571,8 +5493,8 @@ function openColorPickerPanel({ mode, entityId, editUrl, type }) {
panel.innerHTML = `
<div class="color-picker-header">
<div class="color-picker-title">${type === "font" ? "Couleur du texte" : "Couleur personnalisée"}</div>
<button class="color-picker-close" type="button" data-color-picker-action="close" title="Fermer" aria-label="Fermer"><i class="mdi mdi-close" aria-hidden="true"></i></button>
<div class="color-picker-title">${type === "font" ? "Couleur du texte" : window.ShaarliProI18n.t("Custom color")}</div>
<button class="color-picker-close" type="button" data-color-picker-action="close" title=window.ShaarliProI18n.t("Close") aria-label=window.ShaarliProI18n.t("Close")><i class="mdi mdi-close" aria-hidden="true"></i></button>
</div>
<div class="color-picker-body">
<div class="color-picker-gradient">
@@ -6044,7 +5966,6 @@ function setNoteFontColor(noteId, fontColorKey, editUrl) {
})
.then((response) => {
if (response.ok) {
console.log(`Font color ${fontColorKey} saved for note ${noteId}`);
} else {
throw new Error("Failed to save font color");
}
@@ -6154,7 +6075,6 @@ function setModalCustomNoteColor(color) {
})
.then((response) => {
if (!response.ok) throw new Error("Failed to save custom color");
console.log(`Custom color ${color} saved for note ${entity.id}`);
})
.catch((err) => {
console.error("Error saving custom color:", err);
+190
View File
@@ -0,0 +1,190 @@
// Minimal internationalisation helper for Shaarli Pro theme JS.
// Strings are kept in English so the file acts as a fallback when a translation
// key is missing from Shaarli's language files.
(function (root) {
'use strict';
var translations = {
'Dark Mode': 'Dark Mode',
'Light Mode': 'Light Mode',
'Search in tags...': 'Search in tags...',
'Search in notes...': 'Search in notes...',
'Search in bookmarks and notes...': 'Search in bookmarks and notes...',
'Nothing found.': 'Nothing found.',
'result': 'result',
'results': 'results',
'with status': 'with status',
'Clear filters': 'Clear filters',
'Clear': 'Clear',
'The following links will be irretrievably deleted:': 'The following links will be irretrievably deleted:',
'The following links will be set as public:': 'The following links will be set as public:',
'The following links will be set as private:': 'The following links will be set as private:',
'Are you sure to delete {count} link{plural}?': 'Are you sure to delete {count} link{plural}?',
'Are you sure to set {count} link{plural} public?': 'Are you sure to set {count} link{plural} public?',
'Are you sure to set {count} link{plural} private?': 'Are you sure to set {count} link{plural} private?',
'DELETE {count} LINK{plural}': 'DELETE {count} LINK{plural}',
'SET {count} LINK{plural} PUBLIC': 'SET {count} LINK{plural} PUBLIC',
'SET {count} LINK{plural} PRIVATE': 'SET {count} LINK{plural} PRIVATE',
'Untitled link': 'Untitled link',
'Missing security token. Please refresh the page and try again.': 'Missing security token. Please refresh the page and try again.',
'Processing...': 'Processing...',
'Current thumbnail': 'Current thumbnail',
'Thumbnail update done!': 'Thumbnail update done!',
'Error updating thumbnail #{index}': 'Error updating thumbnail #{index}',
'No description available.': 'No description available.',
'Generating QR Code...': 'Generating QR Code...',
'Failed to generate QR Code': 'Failed to generate QR Code',
'QR Code': 'QR Code',
'Unable to load bookmark edit form.': 'Unable to load bookmark edit form.',
'Bookmark edit form not found.': 'Bookmark edit form not found.',
'Unable to save bookmark tags.': 'Unable to save bookmark tags.',
'Delete tag {tag}': 'Delete tag {tag}',
'Delete': 'Delete',
'Read Later': 'Read Later',
'Remove from Read It Later': 'Remove from Read It Later',
'Add to Read It Later': 'Add to Read It Later',
'Failed to toggle readitlater tag.': 'Failed to toggle readitlater tag.',
'Play media': 'Play media',
'Open Link': 'Open Link',
'LIVE': 'LIVE',
'Error loading media': 'Error loading media',
'No media': 'No media',
'Stop & Close': 'Stop & Close',
'Play/Pause': 'Play/Pause',
'Mute/Unmute': 'Mute/Unmute',
// custom_views.js
'Default': 'Default',
'Red': 'Red',
'Orange': 'Orange',
'Yellow': 'Yellow',
'Green': 'Green',
'Mint': 'Mint',
'Light blue': 'Light blue',
'Blue': 'Blue',
'Purple': 'Purple',
'Pink': 'Pink',
'Beige': 'Beige',
'Gray': 'Gray',
'Custom': 'Custom',
'None': 'None',
'Glass': 'Glass',
'Vignette': 'Vignette',
'Lined': 'Lined',
'Grid': 'Grid',
'Noise': 'Noise',
'Dots': 'Dots',
'Stripes': 'Stripes',
'Auto': 'Auto',
'Light': 'Light',
'Dark': 'Dark',
'White': 'White',
'Black': 'Black',
'My images & colors': 'My images & colors',
'Solid color': 'Solid color',
'Custom color': 'Custom color',
'Custom font color': 'Custom font color',
'Close': 'Close',
'Reset': 'Reset',
'Search my images… (filter by color or filter)': 'Search my images… (filter by color or filter)',
'Clear search': 'Clear search',
'Color': 'Color',
'Edit': 'Edit',
'Create a list': 'Create a list',
'List title': 'List title',
'Mark as completed': 'Mark as completed',
'List item': 'List item',
'Create a note': 'Create a note',
'Title': 'Title',
'Heading 1': 'Heading 1',
'Heading 2': 'Heading 2',
'Heading 3': 'Heading 3',
'Text': 'Text',
'Bold': 'Bold',
'Italic': 'Italic',
'Underline': 'Underline',
'Strikethrough': 'Strikethrough',
'Inline code': 'Inline code',
'Bulleted list': 'Bulleted list',
'Numbered list': 'Numbered list',
'Checklist': 'Checklist',
'Quote': 'Quote',
'Link': 'Link',
'Image': 'Image',
'Code block': 'Code block',
'Clear formatting': 'Clear formatting',
'Formatting options': 'Formatting options',
'Reminder': 'Reminder',
'Collaborator': 'Collaborator',
'Archive': 'Archive',
'Unarchive': 'Unarchive',
'Pin': 'Pin',
'Unpin': 'Unpin',
'Untitled': 'Untitled',
'Delete this task?': 'Delete this task?',
'Delete this note?': 'Delete this note?',
'Write your note in Markdown...': 'Write your note in Markdown...',
'Click to edit': 'Click to edit',
'Move': 'Move',
'Error initializing view.': 'Error initializing view.',
'Error loading backgrounds.': 'Error loading backgrounds.',
'Could not find edit form.': 'Could not find edit form.',
'Failed to update tags.': 'Failed to update tags.',
'Delete request failed.': 'Delete request failed.',
'Invalid delete URL.': 'Invalid delete URL.',
'Error removing tag.': 'Error removing tag.',
'Error creating todo.': 'Error creating todo.',
'Error deleting.': 'Error deleting.',
'Error saving todo.': 'Error saving todo.',
'Error creating checklist note.': 'Error creating checklist note.',
'Error creating note.': 'Error creating note.',
'Error saving note.': 'Error saving note.',
'Error archiving note.': 'Error archiving note.',
'Error unarchiving note.': 'Error unarchiving note.',
'Failed to save color.': 'Failed to save color.',
'Failed to save filter.': 'Failed to save filter.',
'Failed to save background.': 'Failed to save background.',
'Failed to save font color.': 'Failed to save font color.',
'Failed to save custom color.': 'Failed to save custom color.',
'Please refresh the page.': 'Please refresh the page.',
'Create a task...': 'Create a task...',
'Add list item': 'Add list item',
'Create a note...': 'Create a note...',
'Archived notes': 'Archived notes',
'Unpin': 'Unpin',
'Coffee': 'Coffee',
'Forest': 'Forest',
'Vegetables': 'Vegetables',
'Ocean': 'Ocean',
'January': 'January',
'February': 'February',
'March': 'March',
'April': 'April',
'May': 'May',
'June': 'June',
'July': 'July',
'August': 'August',
'September': 'September',
'October': 'October',
'November': 'November',
'December': 'December',
'Mon': 'Mon',
'Tue': 'Tue',
'Wed': 'Wed',
'Thu': 'Thu',
'Fri': 'Fri',
'Sat': 'Sat',
'Sun': 'Sun'
};
function t(key, vars) {
var str = translations[key] || key;
if (vars) {
Object.keys(vars).forEach(function (name) {
str = str.split('{' + name + '}').join(String(vars[name]));
});
}
return str;
}
root.ShaarliProI18n = { t: t, translations: translations };
}(window));
+66
View File
@@ -0,0 +1,66 @@
// =====================================================================
// Accessibility helpers for Shaarli Pro modals and overlays.
// =====================================================================
(function (root) {
'use strict';
const FOCUSABLE_SELECTORS = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(', ');
/**
* Return a focusable element inside a container.
*/
function getFocusable(container, first) {
if (!container) return null;
const elements = Array.prototype.slice.call(container.querySelectorAll(FOCUSABLE_SELECTORS));
return first ? elements[0] : elements[elements.length - 1];
}
/**
* Move focus to the first focusable element inside a container.
*/
function focusFirst(container) {
const el = getFocusable(container, true);
if (el) el.focus();
}
/**
* Keep keyboard focus inside a container while it is open.
* Returns a cleanup function that removes the listener.
*/
function trapFocus(container) {
if (!container) return function () {};
function handleKey(e) {
if (e.key !== 'Tab') return;
const first = getFocusable(container, true);
const last = getFocusable(container, false);
if (!first || !last) return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
container.addEventListener('keydown', handleKey);
return function () {
container.removeEventListener('keydown', handleKey);
};
}
root.ShaarliProA11y = {
focusFirst: focusFirst,
trapFocus: trapFocus,
getFocusable: getFocusable
};
}(window));
+275
View File
@@ -0,0 +1,275 @@
// =====================================================================
// Bulk actions (multi-select) for Shaarli Pro.
// =====================================================================
document.addEventListener('DOMContentLoaded', () => {
'use strict';
const i18n = window.ShaarliProI18n || { t: function (k) { return k; } };
const selectModeBtn = document.getElementById('select-mode-btn');
const bulkActionsBar = document.getElementById('bulk-actions-bar');
const bulkCount = document.getElementById('bulk-count');
const bulkSelectAll = document.getElementById('bulk-select-all');
const bulkCancel = document.getElementById('bulk-cancel');
const bulkDelete = document.getElementById('bulk-delete');
const bulkPublic = document.getElementById('bulk-public');
const bulkPrivate = document.getElementById('bulk-private');
const bulkModal = document.getElementById('bulk-confirm-modal');
const bulkModalTitle = document.getElementById('bulk-modal-title');
const bulkModalSubtitle = document.getElementById('bulk-modal-subtitle');
const bulkModalList = document.getElementById('bulk-modal-list');
const bulkModalConfirm = document.getElementById('bulk-modal-confirm');
const bulkModalCancel = document.getElementById('bulk-modal-cancel');
const bulkModalClose = document.getElementById('bulk-modal-close');
let selectionMode = false;
let selectedIds = new Set();
let pendingBulkAction = null;
let bulkFocusCleanup = null;
const a11y = window.ShaarliProA11y || { focusFirst: function () {}, trapFocus: function () { return function () {}; } };
const BULK_ACTION_COPY = {
delete: {
title: (count) => i18n.t('Are you sure to delete {count} link{plural}?', { count: count, plural: count > 1 ? 's' : '' }),
subtitle: i18n.t('The following links will be irretrievably deleted:'),
confirm: (count) => i18n.t('DELETE {count} LINK{plural}', { count: count, plural: count > 1 ? 'S' : '' }).toUpperCase(),
confirmClass: 'bulk-confirm-delete'
},
public: {
title: (count) => i18n.t('Are you sure to set {count} link{plural} public?', { count: count, plural: count > 1 ? 's' : '' }),
subtitle: i18n.t('The following links will be set as public:'),
confirm: (count) => i18n.t('SET {count} LINK{plural} PUBLIC', { count: count, plural: count > 1 ? 'S' : '' }).toUpperCase(),
confirmClass: 'bulk-confirm-public'
},
private: {
title: (count) => i18n.t('Are you sure to set {count} link{plural} private?', { count: count, plural: count > 1 ? 's' : '' }),
subtitle: i18n.t('The following links will be set as private:'),
confirm: (count) => i18n.t('SET {count} LINK{plural} PRIVATE', { count: count, plural: count > 1 ? 'S' : '' }).toUpperCase(),
confirmClass: 'bulk-confirm-private'
}
};
function updateBulkUI() {
if (bulkCount) {
bulkCount.textContent = selectedIds.size;
}
document.querySelectorAll('.link-outer').forEach(card => {
const id = card.dataset.id;
if (selectedIds.has(id)) {
card.classList.add('selected');
} else {
card.classList.remove('selected');
}
});
document.querySelectorAll('.link-checkbox').forEach(cb => {
cb.checked = selectedIds.has(cb.dataset.id);
});
}
function enterSelectionMode() {
selectionMode = true;
document.body.classList.add('selection-mode');
bulkActionsBar?.classList.add('show');
selectModeBtn?.classList.add('active');
}
function exitSelectionMode() {
selectionMode = false;
selectedIds.clear();
document.body.classList.remove('selection-mode');
bulkActionsBar?.classList.remove('show');
selectModeBtn?.classList.remove('active');
updateBulkUI();
}
function toggleSelection(id) {
if (selectedIds.has(id)) {
selectedIds.delete(id);
} else {
selectedIds.add(id);
}
if (selectedIds.size > 0 && !selectionMode) {
enterSelectionMode();
} else if (selectedIds.size === 0 && selectionMode) {
exitSelectionMode();
}
updateBulkUI();
}
selectModeBtn?.addEventListener('click', () => {
if (selectionMode) {
exitSelectionMode();
} else {
enterSelectionMode();
}
});
bulkCancel?.addEventListener('click', exitSelectionMode);
bulkSelectAll?.addEventListener('click', () => {
document.querySelectorAll('.link-outer').forEach(card => {
if (card.dataset.id) {
selectedIds.add(card.dataset.id);
}
});
if (!selectionMode && selectedIds.size > 0) {
enterSelectionMode();
}
updateBulkUI();
});
document.addEventListener('change', (e) => {
if (e.target.classList.contains('link-checkbox')) {
toggleSelection(e.target.dataset.id);
}
});
document.addEventListener('click', (e) => {
if (!selectionMode) return;
const card = e.target.closest('.link-outer');
if (card && card.dataset.id) {
if (e.target.closest('.link-actions') ||
e.target.closest('.link-hover-actions') ||
e.target.tagName === 'A' ||
e.target.tagName === 'INPUT' ||
e.target.closest('label')) {
return;
}
toggleSelection(card.dataset.id);
}
});
function getSelectedIds() {
return Array.from(selectedIds);
}
function populateBulkModalList(ids) {
if (!bulkModalList) return;
bulkModalList.innerHTML = '';
ids.forEach((id) => {
const card = document.querySelector(`.link-outer[data-id="${id}"]`);
const title = card?.querySelector('.link-title')?.textContent?.trim() ||
card?.querySelector('.link-url')?.textContent?.trim() ||
i18n.t('Untitled link');
const item = document.createElement('div');
item.className = 'bulk-modal-item';
const idSpan = document.createElement('span');
idSpan.className = 'bulk-modal-item-id';
idSpan.textContent = `#${id}`;
const titleSpan = document.createElement('span');
titleSpan.className = 'bulk-modal-item-title';
titleSpan.textContent = title;
item.appendChild(idSpan);
item.appendChild(titleSpan);
bulkModalList.appendChild(item);
});
}
function openBulkModal(actionType) {
if (!bulkModal || selectedIds.size === 0) return;
const meta = BULK_ACTION_COPY[actionType];
if (!meta) return;
const ids = getSelectedIds();
pendingBulkAction = actionType;
if (bulkModalTitle) {
bulkModalTitle.textContent = meta.title(ids.length);
}
if (bulkModalSubtitle) {
bulkModalSubtitle.textContent = meta.subtitle;
}
if (bulkModalConfirm) {
bulkModalConfirm.textContent = meta.confirm(ids.length);
bulkModalConfirm.className = `bulk-modal-btn bulk-modal-confirm ${meta.confirmClass}`;
}
populateBulkModalList(ids);
bulkModal.classList.add('show');
bulkModal.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
a11y.focusFirst(bulkModal);
bulkFocusCleanup = a11y.trapFocus(bulkModal);
}
function closeBulkModal() {
if (!bulkModal) return;
pendingBulkAction = null;
bulkModal.classList.remove('show');
bulkModal.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
if (typeof bulkFocusCleanup === 'function') {
bulkFocusCleanup();
bulkFocusCleanup = null;
}
}
function buildIdsParam(ids) {
return ids.map(id => encodeURIComponent(id)).join('+');
}
function executeBulkAction() {
if (!pendingBulkAction || selectedIds.size === 0) return;
const ids = getSelectedIds();
const token = (window.shaarli?.token || '').trim() ||
document.querySelector('input[name="token"]')?.value || '';
if (!token) {
alert(i18n.t('Missing security token. Please refresh the page and try again.'));
return;
}
const basePath = window.shaarli?.basePath || '';
const idsParam = buildIdsParam(ids);
let targetUrl = '';
if (pendingBulkAction === 'delete') {
targetUrl = `${basePath}/admin/shaare/delete?id=${idsParam}&token=${encodeURIComponent(token)}`;
} else {
const visibility = pendingBulkAction === 'public' ? 'public' : 'private';
targetUrl = `${basePath}/admin/shaare/visibility?token=${encodeURIComponent(token)}&newVisibility=${visibility}&id=${idsParam}`;
}
closeBulkModal();
window.location.href = targetUrl;
}
bulkDelete?.addEventListener('click', () => {
if (selectedIds.size === 0) return;
openBulkModal('delete');
});
bulkPublic?.addEventListener('click', () => {
if (selectedIds.size === 0) return;
openBulkModal('public');
});
bulkPrivate?.addEventListener('click', () => {
if (selectedIds.size === 0) return;
openBulkModal('private');
});
bulkModalConfirm?.addEventListener('click', executeBulkAction);
bulkModalCancel?.addEventListener('click', closeBulkModal);
bulkModalClose?.addEventListener('click', closeBulkModal);
bulkModal?.addEventListener('click', (event) => {
if (event.target === bulkModal) {
closeBulkModal();
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && bulkModal?.classList.contains('show')) {
closeBulkModal();
}
});
});
+95
View File
@@ -0,0 +1,95 @@
// =====================================================================
// Navigation helpers for Shaarli Pro:
// - Add note/todo shortcut buttons in the sidebar.
// - Mobile sidebar toggle.
// =====================================================================
document.addEventListener('DOMContentLoaded', () => {
'use strict';
// ===== Add Note Button Handler (Android convention) =====
const addNoteBtn = document.querySelector('.sidebar-add-note');
if (addNoteBtn) {
addNoteBtn.addEventListener('click', (e) => {
e.preventDefault();
if (!window.ShaarItRules) {
// eslint-disable-next-line no-console
console.warn('[shaarit] ShaarItRules not available, cannot generate note URL');
return;
}
const basePath = addNoteBtn.getAttribute('data-base-path') || '';
const noteUrl = window.ShaarItRules.generateNoteUrl();
const redirectUrl = `${basePath}/admin/shaare?post=${encodeURIComponent(noteUrl)}&tags=note`;
window.location.href = redirectUrl;
});
}
// ===== Add Todo Button Handler (Android convention) =====
const addTodoBtn = document.querySelector('.sidebar-add-todo');
if (addTodoBtn) {
addTodoBtn.addEventListener('click', (e) => {
e.preventDefault();
if (!window.ShaarItRules) {
// eslint-disable-next-line no-console
console.warn('[shaarit] ShaarItRules not available, cannot generate todo URL');
return;
}
const basePath = addTodoBtn.getAttribute('data-base-path') || '';
const todoUrl = window.ShaarItRules.generateTodoUrl();
const redirectUrl = `${basePath}/admin/shaare?post=${encodeURIComponent(todoUrl)}&tags=todo&title=%E2%9C%85%20`;
window.location.href = redirectUrl;
});
}
// ===== Mobile Sidebar Toggle =====
const sidebar = document.getElementById('sidebar');
const sidebarOverlay = document.getElementById('sidebar-overlay');
const mobileMenuBtn = document.getElementById('mobile-menu-btn');
function isMobileViewport() {
return window.matchMedia('(max-width: 1100px)').matches;
}
function openSidebar() {
if (!sidebar || !sidebarOverlay) return;
sidebar.classList.add('show');
sidebarOverlay.classList.add('show');
mobileMenuBtn?.setAttribute('aria-expanded', 'true');
sidebarOverlay.setAttribute('aria-hidden', 'false');
if (isMobileViewport()) {
document.body.style.overflow = 'hidden';
}
}
function closeSidebar() {
if (!sidebar || !sidebarOverlay) return;
sidebar.classList.remove('show');
sidebarOverlay.classList.remove('show');
mobileMenuBtn?.setAttribute('aria-expanded', 'false');
sidebarOverlay.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
}
function toggleSidebar() {
if (sidebar?.classList.contains('show')) {
closeSidebar();
return;
}
openSidebar();
}
mobileMenuBtn?.addEventListener('click', toggleSidebar);
sidebarOverlay?.addEventListener('click', closeSidebar);
sidebar?.querySelectorAll('a').forEach((link) => {
link.addEventListener('click', () => {
if (isMobileViewport()) {
closeSidebar();
}
});
});
window.addEventListener('resize', () => {
if (!isMobileViewport()) {
closeSidebar();
}
});
});
+766
View File
@@ -0,0 +1,766 @@
// =====================================================================
// Search overlay and filter panel for Shaarli Pro.
// =====================================================================
document.addEventListener('DOMContentLoaded', () => {
'use strict';
const utils = window.ShaarliProUtils || {
escapeHtml: function (text) {
if (text === null || text === undefined) return '';
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
},
isTagHidden: function () { return false; },
filterHiddenTags: function (tags) { return tags || []; }
};
const i18n = window.ShaarliProI18n || { t: function (k) { return k; } };
const a11y = window.ShaarliProA11y || { focusFirst: function () {}, trapFocus: function () { return function () {}; } };
let searchFocusCleanup = null;
let filterFocusCleanup = null;
// ===== Search Overlay (Spotlight Style) =====
try {
const searchOverlay = document.getElementById('search-overlay');
const searchToggleBtns = document.querySelectorAll('[data-search-toggle]');
const searchModalInput = document.getElementById('search-modal-input');
const searchResults = document.getElementById('search-results');
const searchTagsBtn = document.getElementById('search-tags-btn');
const searchAllBtn = document.getElementById('search-all-btn');
const searchNotesBtn = document.getElementById('search-notes-btn');
const searchForm = document.getElementById('search-form');
let searchMode = 'search'; // 'search' | 'tags' | 'notes'
let selectedIndex = -1;
let searchTimeout = null;
let cachedBookmarks = null;
let cachedTags = null;
// Highlight matching text with <mark> tags
function highlightMatch(text, query) {
if (!query || query.length === 0) return utils.escapeHtml(text);
// Escape HTML first to prevent XSS
const escapedText = utils.escapeHtml(text);
// Escape special regex characters in query
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escapedQuery})`, 'gi');
return escapedText.replace(regex, '<mark>$1</mark>');
}
// Fuzzy search - matches substring anywhere in text
function fuzzyMatch(text, query) {
if (!query || query.length === 0) return true;
return text.toLowerCase().includes(query.toLowerCase());
}
// Fetch all unique tags from the page (excluding hidden tags)
function fetchTags() {
if (cachedTags) return cachedTags;
try {
const tagsSet = new Set();
const addTag = (value) => {
if (typeof value !== 'string') return;
const cleaned = value.trim();
if (!cleaned || cleaned.includes('•')) return;
// Skip hidden tags
if (utils.isTagHidden(cleaned)) return;
tagsSet.add(cleaned);
};
// Prefer explicit tag data attributes to avoid polluted text values (e.g. remove buttons)
document.querySelectorAll('.link-tag[data-tag], .tag-item[data-tag], [data-tag-name]').forEach((el) => {
addTag(el.getAttribute('data-tag') || el.getAttribute('data-tag-name'));
});
// Fallback selectors with reliable text content
document.querySelectorAll('.link-tag-link, .tag-link, .cloud-tag').forEach((el) => {
addTag(el.textContent);
});
// Convert to array and sort alphabetically
cachedTags = Array.from(tagsSet).sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
return cachedTags;
} catch (e) {
console.error('Failed to fetch tags:', e);
return [];
}
}
// Fetch bookmarks for live search
async function fetchBookmarks() {
if (cachedBookmarks) return cachedBookmarks;
try {
// Try to get bookmarks from page (if already loaded)
const linkElements = document.querySelectorAll('.link-outer');
if (linkElements.length > 0) {
cachedBookmarks = Array.from(linkElements).map(el => ({
id: el.dataset.id,
isNote: (el.querySelector('.link-title')?.getAttribute('title') || '').toLowerCase().includes('note'),
title: el.querySelector('.link-title a')?.textContent || el.querySelector('.link-title')?.textContent || '',
url: el.querySelector('.link-url a')?.href || el.querySelector('.link-title a')?.href || '',
// Filter out hidden tags
tags: utils.filterHiddenTags(
Array.from(el.querySelectorAll('.link-tag[data-tag]')).map(t => (t.getAttribute('data-tag') || '').trim()).filter(Boolean)
),
description: el.querySelector('.link-description')?.textContent || ''
}));
return cachedBookmarks;
}
} catch (e) {
console.error('Failed to fetch bookmarks:', e);
}
return [];
}
function getSearchHint(mode) {
var i18n = window.ShaarliProI18n || { t: function (k) { return k; } };
if (mode === 'tags') {
return i18n.t('Search in tags...');
}
if (mode === 'notes') {
return i18n.t('Search in notes...');
}
return i18n.t('Search in bookmarks and notes...');
}
// Render search results (tags, global bookmarks, or notes)
function renderResults(results, query, mode = 'search') {
if (!searchResults) return;
if (results.length === 0) {
if (query && query.length > 0) {
const scopeLabel = mode === 'tags' ? 'tags' : (mode === 'notes' ? 'notes' : 'bookmarks/notes');
searchResults.innerHTML = `
<div class="search-no-results">
No ${scopeLabel} found for "<strong>${utils.escapeHtml(query)}</strong>"
</div>
`;
} else {
searchResults.innerHTML = `
<div class="search-results-empty">
<span class="search-results-hint">${getSearchHint(mode)}</span>
</div>
`;
}
return;
}
let html;
if (mode === 'tags') {
// Render tags
html = results.slice(0, 15).map((tag, index) => {
const highlightedTag = highlightMatch(utils.escapeHtml(tag), query);
return `
<div class="search-result-item${index === selectedIndex ? ' selected' : ''}"
data-index="${index}"
data-tag="${utils.escapeHtml(tag)}">
<div class="search-result-item-content">
<i class="mdi mdi-tag-outline search-result-icon"></i>
<span class="search-result-text">${highlightedTag}</span>
</div>
</div>
`;
}).join('');
} else {
// Render bookmarks or notes
html = results.slice(0, 12).map((item, index) => {
const highlightedTitle = highlightMatch(utils.escapeHtml(item.title), query);
const highlightedDescription = item.description ? highlightMatch(utils.escapeHtml(item.description), query) : '';
const typeLabel = item.isNote ? 'note' : 'bookmark';
const typeIcon = item.isNote ? 'mdi-note-text-outline' : 'mdi-bookmark-outline';
const highlightedTags = (item.tags || []).slice(0, 4).map((tag) => {
const tagValue = utils.escapeHtml(tag);
return `<span class="search-result-tag">${highlightMatch(tagValue, query)}</span>`;
}).join('');
return `
<div class="search-result-item${index === selectedIndex ? ' selected' : ''}"
data-index="${index}"
data-url="${utils.escapeHtml(item.url)}"
data-id="${item.id}">
<div class="search-result-item-content">
<i class="mdi ${typeIcon} search-result-icon"></i>
<div class="search-result-main">
<span class="search-result-text">${highlightedTitle}</span>
<span class="search-result-kind">${typeLabel}</span>
</div>
</div>
${highlightedDescription ? `<div class="search-result-sub">${highlightedDescription}</div>` : ''}
${highlightedTags ? `<div class="search-result-tags">${highlightedTags}</div>` : ''}
</div>
`;
}).join('');
}
searchResults.innerHTML = html;
// Add click handlers to results
searchResults.querySelectorAll('.search-result-item').forEach(item => {
item.addEventListener('click', () => {
if (mode === 'tags') {
const tag = item.dataset.tag;
if (tag) {
// Navigate to tag search
window.location.href = shaarli.basePath + '/?searchtags=' + encodeURIComponent(tag);
}
} else {
const url = item.dataset.url;
if (url) {
window.location.href = url;
}
}
});
});
}
// Perform live search
async function performSearch(query) {
const normalizedQuery = (query || '').trim();
if (searchMode === 'tags') {
const tags = fetchTags();
if (!normalizedQuery) {
renderResults(tags.slice(0, 15), '', 'tags');
return;
}
const tagResults = tags.filter(tag => fuzzyMatch(tag, normalizedQuery));
renderResults(tagResults, normalizedQuery, 'tags');
return;
}
const bookmarks = await fetchBookmarks();
const searchPool = searchMode === 'notes' ? bookmarks.filter(item => item.isNote) : bookmarks;
if (!normalizedQuery) {
renderResults(searchPool.slice(0, 12), '', searchMode);
return;
}
const results = searchPool.filter((item) => {
const haystack = [
item.title,
item.url,
item.description,
(item.tags || []).join(' ')
].join(' ').toLowerCase();
return haystack.includes(normalizedQuery.toLowerCase());
});
renderResults(results, normalizedQuery, searchMode);
}
// Update selected result
function updateSelection(newIndex) {
const items = searchResults?.querySelectorAll('.search-result-item');
if (!items || items.length === 0) return;
// Clamp index
if (newIndex < 0) newIndex = items.length - 1;
if (newIndex >= items.length) newIndex = 0;
selectedIndex = newIndex;
items.forEach((item, index) => {
item.classList.toggle('selected', index === selectedIndex);
});
// Scroll into view
items[selectedIndex]?.scrollIntoView({ block: 'nearest' });
}
// Navigate to selected result
function navigateToSelected() {
const selected = searchResults?.querySelector('.search-result-item.selected');
if (selected) {
// Check if it's a tag
const tag = selected.dataset.tag;
if (tag) {
window.location.href = shaarli.basePath + '/?searchtags=' + encodeURIComponent(tag);
return true;
}
// Otherwise check for URL
const url = selected.dataset.url;
if (url) {
window.location.href = url;
return true;
}
}
return false;
}
function openSearch(mode = 'search') {
searchOverlay?.classList.add('show');
selectedIndex = -1;
setTimeout(() => {
setSearchMode(mode);
searchModalInput?.focus();
searchFocusCleanup = a11y.trapFocus(searchOverlay);
}, 100);
}
function closeSearch() {
searchOverlay?.classList.remove('show');
selectedIndex = -1;
if (typeof searchFocusCleanup === 'function') {
searchFocusCleanup();
searchFocusCleanup = null;
}
}
function submitSearchByMode() {
const basePath = (typeof shaarli !== 'undefined' && shaarli.basePath) ? shaarli.basePath : '';
const query = (searchModalInput?.value || '').trim();
if (selectedIndex >= 0 && navigateToSelected()) {
return;
}
if (!query) {
if (searchMode === 'tags') {
window.location.href = `${basePath}/tags/cloud`;
return;
}
window.location.href = `${basePath}/`;
return;
}
if (searchMode === 'tags') {
window.location.href = `${basePath}/?searchtags=${encodeURIComponent(query)}`;
return;
}
if (searchMode === 'notes') {
window.location.href = `${basePath}/?searchterm=${encodeURIComponent(query)}&searchtags=note`;
return;
}
window.location.href = `${basePath}/?searchterm=${encodeURIComponent(query)}`;
}
// Toggle search mode (tags vs search)
function setSearchMode(mode) {
if (!['search', 'tags', 'notes'].includes(mode)) {
mode = 'search';
}
searchMode = mode;
searchAllBtn?.classList.toggle('active', mode === 'search');
searchTagsBtn?.classList.toggle('active', mode === 'tags');
searchNotesBtn?.classList.toggle('active', mode === 'notes');
searchAllBtn?.setAttribute('aria-pressed', String(mode === 'search'));
searchTagsBtn?.setAttribute('aria-pressed', String(mode === 'tags'));
searchNotesBtn?.setAttribute('aria-pressed', String(mode === 'notes'));
searchAllBtn?.setAttribute('aria-selected', String(mode === 'search'));
searchTagsBtn?.setAttribute('aria-selected', String(mode === 'tags'));
searchNotesBtn?.setAttribute('aria-selected', String(mode === 'notes'));
if (searchModalInput) {
searchModalInput.name = mode === 'tags' ? 'searchtags' : 'searchterm';
searchModalInput.placeholder = getSearchHint(mode);
// Re-run search with new mode
performSearch(searchModalInput.value);
}
}
searchToggleBtns.forEach((btn) => {
btn.addEventListener('click', () => openSearch('search'));
});
// Close search on overlay click
searchOverlay?.addEventListener('click', (e) => {
if (e.target === searchOverlay) {
closeSearch();
}
});
// Search mode toggle buttons
searchTagsBtn?.addEventListener('click', (e) => {
e.preventDefault();
setSearchMode('tags');
});
searchAllBtn?.addEventListener('click', (e) => {
e.preventDefault();
setSearchMode('search');
});
searchNotesBtn?.addEventListener('click', (e) => {
e.preventDefault();
setSearchMode('notes');
});
searchForm?.addEventListener('submit', (e) => {
e.preventDefault();
submitSearchByMode();
});
// Live search on input
searchModalInput?.addEventListener('input', (e) => {
const query = e.target.value;
// Debounce search
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
selectedIndex = -1;
performSearch(query);
}, 150);
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
const isSearchOpen = searchOverlay?.classList.contains('show');
const target = e.target;
const isTyping = Boolean(
target && (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable ||
(typeof target.closest === 'function' && target.closest('.toastui-editor-defaultUI, .toastui-editor-main, .toastui-editor-contents, .CodeMirror'))
)
);
// Handle ESC - always close search/filter
if (e.key === 'Escape') {
if (isSearchOpen) {
closeSearch();
e.preventDefault();
}
closeFilterPanel();
return;
}
// If search is open, handle navigation
if (isSearchOpen && e.target === searchModalInput) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
updateSelection(selectedIndex + 1);
break;
case 'ArrowUp':
e.preventDefault();
updateSelection(selectedIndex - 1);
break;
case 'Enter':
e.preventDefault();
submitSearchByMode();
break;
}
return;
}
// S to open search (when not typing)
if (!isTyping && !e.ctrlKey && !e.metaKey && !e.altKey && (e.key === 's' || e.key === 'S')) {
e.preventDefault();
openSearch('search');
}
// T to open search directly in tags mode (when not typing)
if (!isTyping && !e.ctrlKey && !e.metaKey && !e.altKey && (e.key === 't' || e.key === 'T')) {
e.preventDefault();
openSearch('tags');
}
// N to open search directly in notes mode (when not typing)
if (!isTyping && !e.ctrlKey && !e.metaKey && !e.altKey && (e.key === 'n' || e.key === 'N')) {
e.preventDefault();
openSearch('notes');
}
});
} catch (error) {
console.error('Search overlay initialization failed:', error);
}
// ===== Filter Panel =====
const filterToggleBtn = document.getElementById('filter-toggle-btn');
const filterPanel = document.getElementById('filter-panel');
const filterCloseBtn = document.getElementById('filter-close-btn');
const filterPrivate = document.getElementById('filter-private');
const filterPublic = document.getElementById('filter-public');
const filterUntagged = document.getElementById('filter-untagged');
function toggleFilterPanel() {
if (!filterPanel) return;
const isOpen = filterPanel.classList.toggle('show');
filterPanel.setAttribute('aria-hidden', String(!isOpen));
filterToggleBtn?.setAttribute('aria-expanded', String(isOpen));
if (isOpen) {
a11y.focusFirst(filterPanel);
filterFocusCleanup = a11y.trapFocus(filterPanel);
} else {
if (typeof filterFocusCleanup === 'function') {
filterFocusCleanup();
filterFocusCleanup = null;
}
}
}
function closeFilterPanel() {
if (!filterPanel) return;
filterPanel.classList.remove('show');
filterPanel.setAttribute('aria-hidden', 'true');
filterToggleBtn?.setAttribute('aria-expanded', 'false');
if (typeof filterFocusCleanup === 'function') {
filterFocusCleanup();
filterFocusCleanup = null;
}
}
filterToggleBtn?.addEventListener('click', (e) => {
e.stopPropagation();
toggleFilterPanel();
});
filterCloseBtn?.addEventListener('click', closeFilterPanel);
// Close filter when clicking outside
document.addEventListener('click', (e) => {
if (filterPanel?.classList.contains('show')) {
const clickedToggle = filterToggleBtn && (e.target === filterToggleBtn || filterToggleBtn.contains(e.target));
if (!filterPanel.contains(e.target) && !clickedToggle) {
closeFilterPanel();
}
}
});
// Handle filter toggle switches
// Logic:
// - Visibility: all (neither checked), private (only private checked), public (only public checked)
// - Untagged: can be combined with any visibility
function applyFilters() {
const isPrivate = filterPrivate?.checked || false;
const isPublic = filterPublic?.checked || false;
const isUntagged = filterUntagged?.checked || false;
let basePath = (typeof shaarli !== 'undefined' && shaarli.basePath) ? shaarli.basePath : '';
// Get current filter state from server-side rendered variables
const currentVisibility = (typeof shaarli !== 'undefined' && shaarli.visibility) ? shaarli.visibility : '';
const currentUntagged = (typeof shaarli !== 'undefined' && shaarli.untaggedonly) || false;
let url = basePath + '/';
// Determine desired visibility
let desiredVisibility = 'all';
if (isPrivate) {
desiredVisibility = 'private';
} else if (isPublic) {
desiredVisibility = 'public';
}
// Build URL based on desired state
if (desiredVisibility === 'private') {
url = basePath + '/admin/visibility/private';
} else if (desiredVisibility === 'public') {
url = basePath + '/admin/visibility/public';
} else {
// visibility = all - need to clear visibility if it was set
if (currentVisibility && currentVisibility !== '') {
url = basePath + '/admin/visibility/all';
} else if (isUntagged !== currentUntagged) {
// Untagged state changed - /untagged-only works as toggle
url = basePath + '/untagged-only';
} else {
url = basePath + '/';
}
}
window.location.href = url;
}
// Clear all filters - go back to showing all bookmarks
function clearAllFilters() {
const basePath = (typeof shaarli !== 'undefined' && shaarli.basePath) ? shaarli.basePath : '';
const currentVisibility = (typeof shaarli !== 'undefined' && shaarli.visibility) ? shaarli.visibility : '';
const currentUntagged = (typeof shaarli !== 'undefined' && shaarli.untaggedonly) || false;
// If visibility is set, clear it first
if (currentVisibility && currentVisibility !== '') {
window.location.href = basePath + '/admin/visibility/all';
} else if (currentUntagged) {
// Toggle untagged off using /untagged-only
window.location.href = basePath + '/untagged-only';
} else {
window.location.href = basePath + '/';
}
}
// Private and public are mutually exclusive
filterPrivate?.addEventListener('change', (e) => {
if (e.target.checked && filterPublic) {
filterPublic.checked = false;
}
applyFilters();
});
filterPublic?.addEventListener('change', (e) => {
if (e.target.checked && filterPrivate) {
filterPrivate.checked = false;
}
applyFilters();
});
filterUntagged?.addEventListener('change', () => {
applyFilters();
});
// Initialize filter states from server-side variables (set in includes.html)
(function initFilterStates() {
// Get filter state from server-side rendered variables
const visibility = (typeof shaarli !== 'undefined' && shaarli.visibility) ? shaarli.visibility : '';
const untaggedonly = (typeof shaarli !== 'undefined' && shaarli.untaggedonly) ? shaarli.untaggedonly : false;
// Detect active filters from server variables
let isPrivateActive = visibility === 'private';
let isPublicActive = visibility === 'public';
let isUntaggedActive = untaggedonly === true;
// Set checkbox states
if (filterPrivate && isPrivateActive) {
filterPrivate.checked = true;
}
if (filterPublic && isPublicActive) {
filterPublic.checked = true;
}
if (filterUntagged && isUntaggedActive) {
filterUntagged.checked = true;
}
const hasActiveFilter = isPrivateActive || isPublicActive || isUntaggedActive;
// Add/update filter indicator badge on the filter button
if (filterToggleBtn && hasActiveFilter) {
filterToggleBtn.classList.add('has-active-filter');
// Add badge indicator if not exists
if (!filterToggleBtn.querySelector('.filter-badge')) {
const badge = document.createElement('span');
badge.className = 'filter-badge';
filterToggleBtn.appendChild(badge);
}
}
// Create and display the filter info banner
if (hasActiveFilter && !document.getElementById('filter-info-banner')) {
// Get result count from page - try multiple sources
let resultCount = 0;
const pagingStats = document.querySelector('.paging-stats strong:last-child');
const linkCount = document.querySelectorAll('.link-outer').length;
if (pagingStats) {
resultCount = parseInt(pagingStats.textContent) || 0;
} else if (linkCount > 0) {
resultCount = linkCount;
}
// Check if no results (empty state)
const emptyState = document.querySelector('.empty-state');
const isEmptyResults = emptyState !== null || resultCount === 0;
// Build the message like the examples:
// "5 results without any tag"
// "5 results with status private without any tag"
// "1 result with status public without any tag"
let statusPart = '';
let untaggedPart = '';
if (isPrivateActive) {
statusPart = '<strong>private</strong>';
} else if (isPublicActive) {
statusPart = '<strong>public</strong>';
}
if (isUntaggedActive) {
untaggedPart = '<strong>without any tag</strong>';
}
// Build the message
let message = '';
var i18n = window.ShaarliProI18n || { t: function (k) { return k; } };
if (isEmptyResults) {
message = i18n.t('Nothing found.');
} else {
const resultWord = resultCount === 1 ? i18n.t('result') : i18n.t('results');
const withStatus = i18n.t('with status');
if (statusPart && untaggedPart) {
message = `${resultCount} ${resultWord} ${withStatus} ${statusPart} ${untaggedPart}`;
} else if (statusPart) {
message = `${resultCount} ${resultWord} ${withStatus} ${statusPart}`;
} else if (untaggedPart) {
message = `${resultCount} ${resultWord} ${untaggedPart}`;
}
}
if (message) {
// Get base path safely
const basePath = (typeof shaarli !== 'undefined' && shaarli.basePath) ? shaarli.basePath : '';
const banner = document.createElement('div');
banner.id = 'filter-info-banner';
banner.className = 'filter-info-banner' + (isEmptyResults ? ' empty-results' : '');
banner.innerHTML = `
<div class="filter-info-content">
<span>${message}</span>
</div>
<button type="button" class="filter-clear-btn" id="filter-clear-btn" title="${i18n.t('Clear filters')}">
<i class="mdi mdi-close"></i>
<span>${i18n.t('Clear')}</span>
</button>
`;
// Try to find the best insertion point
const contentToolbar = document.querySelector('.content-toolbar');
const linklist = document.getElementById('linklist');
const emptyStateDiv = document.querySelector('.empty-state');
// Insert after the content-toolbar (pagination)
if (contentToolbar && contentToolbar.parentNode) {
contentToolbar.parentNode.insertBefore(banner, contentToolbar.nextSibling);
} else if (emptyStateDiv && emptyStateDiv.parentNode) {
emptyStateDiv.parentNode.insertBefore(banner, emptyStateDiv);
} else if (linklist) {
linklist.insertBefore(banner, linklist.firstChild);
} else {
}
// Add click handler to clear button
document.getElementById('filter-clear-btn')?.addEventListener('click', () => {
// Clear based on what was active
if (isPrivateActive || isPublicActive) {
window.location.href = basePath + '/admin/visibility/all';
} else if (isUntaggedActive) {
// Toggle untagged off
window.location.href = basePath + '/untagged-only';
} else {
window.location.href = basePath + '/';
}
});
}
}
})();
// Handle links per page options
// Handle custom value form submission
const filterInput = document.querySelector('.filter-input');
if (filterInput) {
filterInput.closest('form')?.addEventListener('submit', (e) => {
e.preventDefault();
const value = filterInput.value;
if (value && value > 0) {
window.location.href = shaarli.basePath + '/links-per-page?nb=' + value;
}
});
}
});
+122
View File
@@ -0,0 +1,122 @@
// =====================================================================
// Theme configuration and dark/light toggle for Shaarli Pro.
// Keeps theme/mode preferences client-side without creating bookmarks.
// Used by the dark/light switch AND the theme picker (tools.html).
// =====================================================================
(function () {
'use strict';
const STORAGE_KEY = 'shaarit_themes_config';
function readConfig() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
return (parsed && typeof parsed === 'object') ? parsed : null;
} catch (e) {
return null;
}
}
function writeConfig(config) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
return true;
} catch (e) {
return false;
}
}
// Merge partial updates into the persisted config and save to localStorage.
function save(partial) {
const base = readConfig() || {};
const config = Object.assign(
{ version: 2, themes: [], default: 'DEFAULT', mode: 'dark' },
base,
partial || {}
);
const ok = writeConfig(config);
return Promise.resolve({ ok, config });
}
// Reads the current persisted config from localStorage (no write).
function load() {
return Promise.resolve(readConfig());
}
// Kept for backward compatibility with older callers.
function findCandidates() {
return Promise.resolve([]);
}
window.ShaaritThemeConfig = { save, load, findCandidates };
})();
document.addEventListener('DOMContentLoaded', () => {
'use strict';
const themeCheckbox = document.getElementById('theme-toggle-checkbox');
const themeIconLight = document.getElementById('theme-icon-light');
const themeLabelSpan = document.querySelector('.theme-toggle-label span');
const i18n = window.ShaarliProI18n || { t: function (k) { return k; } };
function checkThemeRestrictions() {
const themeId = localStorage.getItem('shaarit_theme_id') || 'DEFAULT';
const darkOnlyThemes = ['LINEAR', 'SPOTIFY', 'NOTION', 'DISCORD', 'DRACULA', 'ONE_DARK_PRO', 'TOKYO_NIGHT', 'NORD', 'NIGHT_OWL', 'ANTHRACITE', 'CYBERPUNK', 'NAVY_ELEGANCE', 'EARTHY'];
return darkOnlyThemes.indexOf(themeId) !== -1;
}
function updateTheme(theme) {
// Enforce dark mode if the theme only supports dark
if (checkThemeRestrictions()) {
theme = 'dark';
}
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
if (themeCheckbox) {
themeCheckbox.checked = theme === 'dark';
// Disable the checkbox if restricted to dark mode
themeCheckbox.disabled = checkThemeRestrictions();
themeCheckbox.closest('.theme-switch').style.opacity = themeCheckbox.disabled ? '0.5' : '1';
}
if (themeIconLight) {
themeIconLight.className = theme === 'dark' ? 'mdi mdi-weather-night' : 'mdi mdi-weather-sunny';
}
if (themeLabelSpan) {
themeLabelSpan.textContent = theme === 'dark' ? i18n.t('Dark Mode') : i18n.t('Light Mode');
}
}
async function syncModeToBookmark(mode) {
try {
if (!window.ShaaritThemeConfig) return;
await window.ShaaritThemeConfig.save({ mode });
} catch (e) {
// eslint-disable-next-line no-console
console.error('[shaarit] Failed to sync mode to bookmark', e);
}
}
// Handle themeChanged event fired by tools.html
window.addEventListener('themeChanged', () => {
updateTheme(localStorage.getItem('theme') || 'dark');
});
// Init Theme
const savedTheme = localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
updateTheme(savedTheme);
if (themeCheckbox) {
themeCheckbox.addEventListener('change', () => {
if (checkThemeRestrictions()) return; // Extra safety
const next = themeCheckbox.checked ? 'dark' : 'light';
updateTheme(next);
// Sync mode to bookmark in background
syncModeToBookmark(next);
});
}
});
+74 -1342
View File
File diff suppressed because it is too large Load Diff
+34 -34
View File
@@ -20,12 +20,12 @@
<div class="content-toolbar">
<div class="toolbar-left">{include="linklist.paging"}</div>
<div class="toolbar-right">
<div class="view-toggle" role="group" aria-label="Changer de vue">
<button class="view-toggle-btn active" id="view-grid-btn" aria-label="Vue grille"
<div class="view-toggle" role="group" aria-label="{'Change view'|t}">
<button class="view-toggle-btn active" id="view-grid-btn" aria-label="{'Grid view'|t}"
aria-pressed="true"><i class="mdi mdi-view-grid" aria-hidden="true"></i></button>
<button class="view-toggle-btn" id="view-list-btn" aria-label="Vue liste"
<button class="view-toggle-btn" id="view-list-btn" aria-label="{'List view'|t}"
aria-pressed="false"><i class="mdi mdi-view-list" aria-hidden="true"></i></button>
<button class="view-toggle-btn" id="view-compact-btn" aria-label="Vue compacte"
<button class="view-toggle-btn" id="view-compact-btn" aria-label="{'Compact view'|t}"
aria-pressed="false"><i class="mdi mdi-view-headline"
aria-hidden="true"></i></button>
</div>
@@ -35,11 +35,11 @@
<!-- {* ----- tag search results ----- *} -->
{if="!empty($search_tags)"}
<div class="search-results-header">
<span class="search-count">{$result_count} résultat(s) tagué(s)</span>
<span class="search-count">{$result_count} {'tagged result(s)'|t}</span>
{loop="$active_search_tags"}
<span class="search-tag-chip">{$value} <a href="{$base_path}/remove-tag/{function="
urlencode($value)"}" class="search-tag-close" title="Retirer le tag"
aria-label="Retirer le tag {$value}"><i class="mdi mdi-close"
urlencode($value)"}" class="search-tag-close" title="{'Remove tag'|t}"
aria-label="{'Remove tag'|t} {$value}"><i class="mdi mdi-close"
aria-hidden="true"></i></a></span>
{/loop}
</div>
@@ -53,39 +53,39 @@
{else}
<div class="empty-state" role="status" aria-live="polite">
<div class="empty-state-icon" aria-hidden="true"><i class="mdi mdi-bookmark-off-outline"></i></div>
<h2 class="empty-state-title">Aucun bookmark trouvé</h2>
<p class="empty-state-text">{if="!empty($search_term)"}Aucun résultat pour : <strong>{$search_term}</strong>{else}Commencez à ajouter des bookmarks pour les voir apparaître ici.{/if}</p>
<h2 class="empty-state-title">{'No bookmarks found'|t}</h2>
<p class="empty-state-text">{if="!empty($search_term)"}{'No result for:'|t} <strong>{$search_term}</strong>{else}{'Start adding bookmarks to see them appear here.'|t}{/if}</p>
</div>
{/if}
{else}
<!-- {* ----- at least one link ----- *} -->
<div class="links-list view-grid" id="links-list">
<div class="pinned-section-head" aria-hidden="true">📌 Bookmarks épinglés</div>
<div class="normal-section-head" aria-hidden="true">Bookmarks</div>
<div class="pinned-section-head" aria-hidden="true"><i class="mdi mdi-pin" aria-hidden="true"></i> {'Pinned bookmarks'|t}</div>
<div class="normal-section-head" aria-hidden="true">{'Bookmarks'|t}</div>
<div class="compact-table-head" aria-hidden="true">
<span class="compact-head-title">Titre &amp; URL</span>
<span class="compact-head-tags">Tags</span>
<span class="compact-head-date">Date</span>
<span class="compact-head-actions">Actions</span>
<span class="compact-head-title">{'Title & URL'|t}</span>
<span class="compact-head-tags">{'Tags'|t}</span>
<span class="compact-head-date">{'Date'|t}</span>
<span class="compact-head-actions">{'Actions'|t}</span>
</div>
{loop="$links"}
<div id="{$value.id}" class="link-outer{if="isset($value.sticky) && $value.sticky"} is-sticky{/if}{if="$value.class"} {$value.class}{/if}{if="$value.private"} private{else} public{/if}" data-id="{$value.id}">
{if="$is_logged_in"}
<div class="link-select-checkbox"><input type="checkbox" class="link-checkbox"
data-id="{$value.id}" aria-label="Sélectionner ce bookmark"></div>
data-id="{$value.id}" aria-label="{'Select this bookmark'|t}"></div>
{/if}
{if="$value.thumbnail !== false"}<div class="link-thumbnail"><img
src="{$root_path}/{$value.thumbnail}#" loading="lazy" alt=""
style="width:100%;height:100%;object-fit:cover;"
onerror="this.parentElement.style.display='none'"></div>{/if}
<div class="link-visibility-badge">{if="$value.private"}<i class="mdi mdi-lock" title="Privé"
aria-label="Bookmark privé"></i>{else}<i class="mdi mdi-lock-open-variant"
title="Public" aria-label="Bookmark public"></i>{/if}</div>
<div class="link-visibility-badge">{if="$value.private"}<i class="mdi mdi-lock" title="{'Private'|t}"
aria-label="{'Private bookmark'|t}"></i>{else}<i class="mdi mdi-lock-open-variant"
title="{'Public'|t}" aria-label="{'Public bookmark'|t}"></i>{/if}</div>
<div class="link-content">
<div class="link-header">
<a href="{$value.real_url}" class="link-title"
{if="strpos($value.url, $value.shorturl) !== false"} title="Note" {/if}>
{if="strpos($value.url, $value.shorturl) !== false"} title="{'Note'|t}" {/if}>
{if="isset($value.sticky) && $value.sticky"}<i class="mdi mdi-pin"
style="color:var(--primary);"
aria-hidden="true"></i>{/if}{$value.title_html}
@@ -94,7 +94,7 @@
<div class="link-meta">
<span class="link-date">{$value.created|format_date}</span>
<a href="{$base_path}/shaare/{$value.shorturl}" class="link-permalink"
title="Lien permanent" aria-label="Lien permanent"><i
title="{'Permalink'|t}" aria-label="{'Permalink'|t}"><i
class="mdi mdi-link-variant" aria-hidden="true"></i></a>
</div>
</div>
@@ -103,33 +103,33 @@
<div class="link-footer">
<div class="link-tag-list">
{loop="$value.taglist"}<span class="link-tag{if="in_array($value, $active_search_tags)"} is-search-match{/if}" data-tag="{$value}"><a
class="link-tag-link" href="{$base_path}/add-tag/{$value|urlencode}">{$value}</a>{if="$is_logged_in"}<button type="button" class="tag-remove-btn" data-tag="{$value}" aria-label="Supprimer le tag {$value}" title="Supprimer">&times;</button>{/if}</span>{/loop}
class="link-tag-link" href="{$base_path}/add-tag/{$value|urlencode}">{$value}</a>{if="$is_logged_in"}<button type="button" class="tag-remove-btn" data-tag="{$value}" aria-label="{'Delete tag'|t} {$value}" title="{'Delete'|t}">&times;</button>{/if}</span>{/loop}
</div>
<div class="link-actions">
{if="$is_logged_in"}
<a href="{$base_path}/admin/shaare/{$value.id}" title="Modifier"
aria-label="Modifier ce bookmark"><i class="mdi mdi-pencil"
<a href="{$base_path}/admin/shaare/{$value.id}" title="{'Edit'|t}"
aria-label="{'Edit this bookmark'|t}"><i class="mdi mdi-pencil"
aria-hidden="true"></i></a>
<a href="{$base_path}/admin/shaare/{$value.id}/pin?token={$token}"
title="Épingler/Désépingler" aria-label="Épingler ou désépingler"><i
title="{'Pin/Unpin'|t}" aria-label="{'Pin or unpin'|t}"><i
class="mdi mdi-pin" aria-hidden="true"></i></a>
<a href="{$base_path}/admin/shaare/delete?id={$value.id}&token={$token}"
title="Supprimer" aria-label="Supprimer ce bookmark"
onclick="return confirm('Supprimer ce bookmark ?');"><i
title="{'Delete'|t}" aria-label="{'Delete this bookmark'|t}"
onclick="return confirm('{'Delete this bookmark?'|t}');"><i
class="mdi mdi-delete" aria-hidden="true"></i></a>
<button type="button" class="readitlater-toggle-btn" data-tag="readitlater" data-active="0" title="Ajouter à Read It Later" aria-label="Ajouter à Read It Later">
<button type="button" class="readitlater-toggle-btn" data-tag="readitlater" data-active="0" title="{'Add to Read It Later'|t}" aria-label="{'Add to Read It Later'|t}">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"/></svg>
</button>
{/if}
<!-- fullscreen button (in all 3 views) -->
<a href="#" class="view-desc-btn" data-id="{$value.id}"
title="Voir la description" aria-label="Voir la description complète"><i
title="{'View description'|t}" aria-label="{'View full description'|t}"><i
class="mdi mdi-fullscreen" aria-hidden="true"></i></a>
{loop="$value.link_plugin"}
<span class="link-plugin">{$value}</span>
{/loop}
<a href="{$value.real_url}" target="_blank" rel="noopener"
title="Ouvrir le lien" aria-label="Ouvrir le lien dans un nouvel onglet"><i
title="{'Open link'|t}" aria-label="{'Open link in new tab'|t}"><i
class="mdi mdi-open-in-new" aria-hidden="true"></i></a>
</div>
</div>
@@ -146,18 +146,18 @@
<!-- {* ----- description modal ----- *} -->
<div id="desc-modal" class="modal-overlay" role="dialog" aria-modal="true"
aria-label="Description du bookmark" aria-hidden="true">
aria-label="{'Bookmark description'|t}" aria-hidden="true">
<div class="modal-content">
<button class="modal-close" id="desc-modal-close" aria-label="Fermer">&times;</button>
<button class="modal-close" id="desc-modal-close" aria-label="{'Close'|t}">&times;</button>
<div class="modal-body" id="desc-modal-body"></div>
</div>
</div>
<!-- {* ----- QR Code Modal ----- *} -->
<div id="qrcode-modal" class="qrcode-modal-overlay" role="dialog" aria-modal="true" aria-label="Code QR"
<div id="qrcode-modal" class="qrcode-modal-overlay" role="dialog" aria-modal="true" aria-label="{'QR Code'|t}"
aria-hidden="true">
<div class="qrcode-modal-content">
<button class="qrcode-modal-close" id="qrcode-modal-close" aria-label="Fermer">&times;</button>
<button class="qrcode-modal-close" id="qrcode-modal-close" aria-label="{'Close'|t}">&times;</button>
<div id="qrcode-modal-body"></div>
</div>
</div>
+5 -5
View File
@@ -14,16 +14,16 @@
{$from=($page_current - 1) * $links_per_page + 1}
{$to=min($total, ($page_current - 1) * $links_per_page + $links_per_page)}
{if="!empty($search_tags) && stripos($search_tags, 'note') !== false"}
{$paging_label="Notes"}
{$paging_label="{'Notes'|t}"}
{else}
{$paging_label="Bookmarks"}
{$paging_label="{'Bookmarks'|t}"}
{/if}
<div class="paging{if="$page_max <=1"} single-page{/if}">
{if="$page_max > 1"}
<div class="paging-links">
{if="$next_page_url"}<a href="{$next_page_url}" class="paging-newer" title="Newer"><i class="mdi mdi-chevron-left"></i></a>{/if}
<div class="paging-current">page <strong>{$page_current}</strong> / {$page_max}</div>
{if="$previous_page_url"}<a href="{$previous_page_url}" class="paging-older" title="Older"><i class="mdi mdi-chevron-right"></i></a>{/if}
{if="$next_page_url"}<a href="{$next_page_url}" class="paging-newer" title="{'Newer'|t}"><i class="mdi mdi-chevron-left"></i></a>{/if}
<div class="paging-current">{'page'|t} <strong>{$page_current}</strong> / {$page_max}</div>
{if="$previous_page_url"}<a href="{$previous_page_url}" class="paging-older" title="{'Older'|t}"><i class="mdi mdi-chevron-right"></i></a>{/if}
</div>
{/if}
<div class="paging-stats">
+25
View File
@@ -0,0 +1,25 @@
{
"name": "Shaarli Pro",
"short_name": "Shaarli Pro",
"description": "Professional theme for Shaarli with advanced UI features",
"start_url": "/",
"display": "standalone",
"background_color": "#f8fafc",
"theme_color": "#2563eb",
"orientation": "portrait-primary",
"scope": "/",
"icons": [
{
"src": "img/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "img/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
-104
View File
@@ -1,104 +0,0 @@
<?php
/**
* migrate-todos.php
*
* Script de migration batch : remplace tous les todos legacy (shaarli-todo)
* par la norme Android (tag `todo` + URL `https://shaarit.app/todo/{uuid}`).
*
* À exécuter une seule fois via CLI ou interface web (si accessible).
* Usage: php migrate-todos.php
*/
// Déterminer le chemin racine de Shaarli
$rootDir = dirname(dirname(dirname(__FILE__)));
require_once $rootDir . '/index.php';
// Vérifier que Shaarli est chargé
if (!isset($GLOBALS['shaarli'])) {
die("Erreur : Shaarli non chargé.\n");
}
$linkDb = $GLOBALS['shaarli']->getContainer()->get('db');
if (!$linkDb) {
die("Erreur : base de données non accessible.\n");
}
// Récupérer tous les liens avec le tag shaarli-todo
$allLinks = $linkDb->getLinks();
$todosToMigrate = [];
foreach ($allLinks as $link) {
$tags = explode(' ', $link->getTags());
$hasTodoTag = in_array('shaarli-todo', $tags, true);
if ($hasTodoTag) {
$todosToMigrate[] = $link;
}
}
if (empty($todosToMigrate)) {
echo "Aucun todo à migrer.\n";
exit(0);
}
echo "Trouvé " . count($todosToMigrate) . " todo(s) à migrer.\n";
// Fonction pour générer un UUID court (style Android)
function generateTodoUuid() {
if (function_exists('random_bytes')) {
$bytes = random_bytes(9);
$out = '';
for ($i = 0; $i < 9; $i++) {
$out .= str_pad(base_convert(ord($bytes[$i]), 10, 36), 2, '0', STR_PAD_LEFT);
}
return substr($out, 0, 12);
}
return substr(base_convert(time(), 10, 36) . base_convert(mt_rand(), 10, 36), 0, 12);
}
// Migrer chaque todo
$migrated = 0;
$errors = 0;
foreach ($todosToMigrate as $link) {
try {
$tags = explode(' ', $link->getTags());
// 1. Remplacer shaarli-todo par todo
$tags = array_filter($tags, function ($t) {
return $t !== 'shaarli-todo';
});
$tags = array_values($tags);
if (!in_array('todo', $tags, true)) {
$tags[] = 'todo';
}
$link->setTags($tags);
// 2. Générer URL Android si vide ou legacy
$url = $link->getUrl();
if (empty($url) || strpos($url, 'http://shaarli-todo') === 0 || strpos($url, 'https://shaarli-todo') === 0) {
$uuid = generateTodoUuid();
$link->setUrl('https://shaarit.app/todo/' . $uuid);
}
// Sauvegarder
$linkDb->save($link);
$migrated++;
echo "✓ Migré : {$link->getTitle()} ({$link->getId()})\n";
} catch (Exception $e) {
$errors++;
echo "✗ Erreur : {$link->getTitle()} - {$e->getMessage()}\n";
}
}
echo "\n=== Résumé ===\n";
echo "Migrés : $migrated\n";
echo "Erreurs : $errors\n";
echo "Total : " . count($todosToMigrate) . "\n";
if ($errors === 0) {
echo "\n✓ Migration terminée avec succès !\n";
exit(0);
} else {
echo "\n⚠ Migration terminée avec $errors erreur(s).\n";
exit(1);
}
+3 -3
View File
@@ -1,12 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
<ShortName>Shaarli search - {$pagetitle}</ShortName>
<Description>Shaarli search - {$pagetitle}</Description>
<ShortName>{'Shaarli search'|t} - {$pagetitle}</ShortName>
<Description>{'Shaarli search'|t} - {$pagetitle}</Description>
<Url type="text/html" template="{$serverurl}?searchterm={searchTerms}" />
<Url type="application/atom+xml" template="{$serverurl}feed/atom?searchterm={searchTerms}"/>
<Url type="application/rss+xml" template="{$serverurl}feed/rss?searchterm={searchTerms}"/>
<InputEncoding>UTF-8</InputEncoding>
<Developer>Shaarli Community - https://github.com/shaarli/Shaarli/</Developer>
<Developer>{'Shaarli Community'|t} - https://github.com/shaarli/Shaarli/</Developer>
<Image width="16" height="16">data:image/x-icon;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAHRklE
QVRIx5WWaWxU5xWG3++7986dfYYZb+MN2xiMDRiDFePUiQsNoiwpUNpAmhInJVEqpa0oQUlbJVKq
olaiqpLKUtOKhAJRm1BKRVWctuykpFjAgPcFx/uMl5mxPTOeuXPv3O3rjyiV0lIpfX+dc36c55xf
+1254
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "shaarli-pro-theme",
"version": "1.1.0",
"description": "Professional theme for Shaarli with advanced UI features",
"private": true,
"scripts": {
"build": "node build.js",
"build:css": "node build.js --css-only",
"build:js": "node build.js --js-only",
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
"test:e2e": "playwright test",
"test:e2e:install": "playwright install --with-deps"
},
"devDependencies": {
"@playwright/test": "^1.62.1",
"cssnano": "^7.0.6",
"postcss": "^8.4.41",
"terser": "^5.31.6"
},
"engines": {
"node": ">=18"
}
}
+16 -16
View File
@@ -1,16 +1,16 @@
<footer class="footer-main">
<p>
Powered by <a href="https://github.com/shaarli/Shaarli" target="_blank" rel="noopener">Shaarli</a>.
{'Powered by'|t} <a href="https://github.com/shaarli/Shaarli" target="_blank" rel="noopener">Shaarli</a>.
{if="$is_logged_in"}
<span class="footer-separator">|</span> <a href="{$base_path}/admin/tools">Administration</a>
<span class="footer-separator">|</span> <a href="{$base_path}/admin/tools">{'Administration'|t}</a>
{/if}
</p>
<div class="footer-feeds">
<a href="{$base_path}/feed/rss" title="RSS Feed"><i class="mdi mdi-rss"></i> RSS</a>
<a href="{$base_path}/feed/rss" title="{'RSS Feed'|t}"><i class="mdi mdi-rss"></i> RSS</a>
<span class="footer-separator">•</span>
<a href="{$base_path}/feed/atom" title="Atom Feed">Atom</a>
<a href="{$base_path}/feed/atom" title="{'Atom Feed'|t}">Atom</a>
<span class="footer-separator">•</span>
<a href="{$base_path}/daily" title="Daily">Daily</a>
<a href="{$base_path}/daily" title="{'Daily'|t}">Daily</a>
</div>
{loop="$plugins_footer.js"}
@@ -46,27 +46,27 @@
<!-- Bulk Actions Bar (for multi-select) -->
<div class="bulk-actions-bar" id="bulk-actions-bar">
<div class="bulk-info">
<span id="bulk-count">0</span> links selected - <a class="bulk-select-all" id="bulk-select-all">select all</a>
<span id="bulk-count">0</span> {'links selected'|t} - <a class="bulk-select-all" id="bulk-select-all">{'select all'|t}</a>
</div>
<div class="bulk-buttons">
<button class="bulk-btn bulk-btn-cancel" id="bulk-cancel">CANCEL</button>
<button class="bulk-btn bulk-btn-delete" id="bulk-delete">DELETE</button>
<button class="bulk-btn bulk-btn-public" id="bulk-public">SET PUBLIC</button>
<button class="bulk-btn bulk-btn-private" id="bulk-private">SET PRIVATE</button>
<button class="bulk-btn bulk-btn-cancel" id="bulk-cancel">{'CANCEL'|t}</button>
<button class="bulk-btn bulk-btn-delete" id="bulk-delete">{'DELETE'|t}</button>
<button class="bulk-btn bulk-btn-public" id="bulk-public">{'SET PUBLIC'|t}</button>
<button class="bulk-btn bulk-btn-private" id="bulk-private">{'SET PRIVATE'|t}</button>
</div>
</div>
<!-- Bulk confirmation modal -->
<div class="modal-overlay" id="bulk-confirm-modal" aria-hidden="true">
<div class="modal-content bulk-modal" role="dialog" aria-modal="true" aria-labelledby="bulk-modal-title">
<button class="modal-close" id="bulk-modal-close" type="button" aria-label="Close confirmation">&times;</button>
<button class="modal-close" id="bulk-modal-close" type="button" aria-label="{'Close confirmation'|t}">&times;</button>
<div class="bulk-modal-header">
<h3 id="bulk-modal-title"></h3>
<p id="bulk-modal-subtitle"></p>
</div>
<div class="bulk-modal-list" id="bulk-modal-list"></div>
<div class="bulk-modal-actions">
<button class="bulk-modal-btn bulk-modal-cancel" id="bulk-modal-cancel" type="button">Cancel</button>
<button class="bulk-modal-btn bulk-modal-cancel" id="bulk-modal-cancel" type="button">{'Cancel'|t}</button>
<button class="bulk-modal-btn bulk-modal-confirm" id="bulk-modal-confirm" type="button"></button>
</div>
</div>
@@ -76,23 +76,23 @@
<div class="media-player-bar" id="media-player-bar">
<audio id="media-player-audio" preload="metadata"></audio>
<div class="media-player-inner">
<button class="media-player-btn" id="media-player-play" title="Play/Pause">
<button class="media-player-btn" id="media-player-play" title="{'Play/Pause'|t}">
<i class="mdi mdi-play" id="media-player-play-icon"></i>
</button>
<div class="media-player-info">
<div class="media-player-title" id="media-player-title">No media</div>
<div class="media-player-title" id="media-player-title">{'No media'|t}</div>
<div class="media-player-progress-wrap">
<input type="range" class="media-player-progress" id="media-player-progress" min="0" max="100" value="0" step="0.1">
</div>
</div>
<div class="media-player-time" id="media-player-time">0:00 / 0:00</div>
<div class="media-player-volume-wrap">
<button class="media-player-btn media-player-btn-sm" id="media-player-vol-btn" title="Mute/Unmute">
<button class="media-player-btn media-player-btn-sm" id="media-player-vol-btn" title="{'Mute/Unmute'|t}">
<i class="mdi mdi-volume-high" id="media-player-vol-icon"></i>
</button>
<input type="range" class="media-player-volume" id="media-player-volume" min="0" max="1" value="0.8" step="0.01">
</div>
<button class="media-player-btn media-player-btn-sm media-player-close" id="media-player-close" title="Close player">
<button class="media-player-btn media-player-btn-sm media-player-close" id="media-player-close" title="{'Close player'|t}">
<i class="mdi mdi-close"></i>
</button>
</div>
+98 -96
View File
@@ -5,8 +5,10 @@ Bookmarklet detection logic
{ignore} When called as a popup from bookmarklet, do not display menu. {/ignore}
{else}
<a href="#content-container" class="skip-link">{'Skip to content'|t}</a>
<!-- Sidebar -->
<aside class="sidebar" id="sidebar" aria-label="Navigation principale">
<aside class="sidebar" id="sidebar" aria-label="{'Main navigation'|t}">
<div class="sidebar-header">
<div class="sidebar-logo" aria-hidden="true">
<i class="mdi mdi-bookmark-multiple"></i>
@@ -14,27 +16,27 @@ Bookmarklet detection logic
<a href="{$titleLink}" class="sidebar-brand">{$shaarlititle}</a>
</div>
<nav class="sidebar-nav" aria-label="Menu latéral">
<nav class="sidebar-nav" aria-label="{'Main navigation'|t}">
<div class="sidebar-section">
<div class="sidebar-section-title">Recherche</div>
<div class="sidebar-section-title">{'Search'|t}</div>
<button type="button" class="sidebar-link sidebar-search-trigger" data-search-toggle
aria-label="Ouvrir la recherche">
aria-label="{'Open search'|t}">
<i class="mdi mdi-magnify" aria-hidden="true"></i>
<span>Rechercher</span>
<span>{'Search'|t}</span>
</button>
</div>
{if="$is_logged_in"}
<div class="sidebar-section sidebar-add-section">
<div class="sidebar-section-title">Création rapide</div>
<div class="sidebar-section-title">{'Quick create'|t}</div>
<div class="sidebar-add-segmented">
<a href="{$base_path}/admin/add-shaare" class="sidebar-add-segment" title="Bookmark">
<a href="{$base_path}/admin/add-shaare" class="sidebar-add-segment" title="{'Bookmark'|t}">
<i class="mdi mdi-bookmark-outline"></i>
</a>
<a href="#" class="sidebar-add-segment sidebar-add-note" title="Note" data-base-path="{$base_path}">
<a href="#" class="sidebar-add-segment sidebar-add-note" title="{'Note'|t}" data-base-path="{$base_path}">
<i class="mdi mdi-note-text-outline"></i>
</a>
<a href="#" class="sidebar-add-segment sidebar-add-todo" title="Todo" data-base-path="{$base_path}">
<a href="#" class="sidebar-add-segment sidebar-add-todo" title="{'Todo'|t}" data-base-path="{$base_path}">
<i class="mdi mdi-check-circle-outline"></i>
</a>
</div>
@@ -42,60 +44,60 @@ Bookmarklet detection logic
{/if}
<div class="sidebar-section">
<div class="sidebar-section-title">Navigation</div>
<div class="sidebar-section-title">{'Navigation'|t}</div>
<a href="{$base_path}/" class="sidebar-link{if="$pageName=='linklist' && empty($search_tags)"} active{/if}"
aria-label="Tous les bookmarks">
aria-label="{'All bookmarks'|t}">
<i class="mdi mdi-bookmark-multiple-outline" aria-hidden="true"></i>
<span>Tous les bookmarks</span>
<span>{'All bookmarks'|t}</span>
</a>
<a href="{$base_path}/?searchtags=shaarli-pin" class="sidebar-link{if="isset($search_tags) && preg_match('/(^|[\s,])shaarli-pin([\s,]|$)/i', (string) $search_tags)"} active{/if}" aria-label="Épinglés">
<a href="{$base_path}/?searchtags=shaarli-pin" class="sidebar-link{if="isset($search_tags) && preg_match('/(^|[\s,])shaarli-pin([\s,]|$)/i', (string) $search_tags)"} active{/if}" aria-label="{'Pinned'|t}">
<i class="mdi mdi-pin-outline" aria-hidden="true"></i>
<span>Épinglés</span>
<span>{'Pinned'|t}</span>
</a>
<a href="{$base_path}/tags/cloud" class="sidebar-link{if="$pageName=='tag.cloud' || $pageName=='tag.list'"} active{/if}"
aria-label="Nuage de tags">
aria-label="{'Tag cloud'|t}">
<i class="mdi mdi-tag-multiple-outline" aria-hidden="true"></i>
<span>Nuage de tags</span>
<span>{'Tag cloud'|t}</span>
</a>
<a href="{$base_path}/picture-wall?{function="ltrim($searchcrits, '&' )"}" class="sidebar-link{if="$pageName=='picwall'"} active{/if}" aria-label="Mur d'images">
<a href="{$base_path}/picture-wall?{function="ltrim($searchcrits, '&' )"}" class="sidebar-link{if="$pageName=='picwall'"} active{/if}" aria-label="{'Picture wall'|t}">
<i class="mdi mdi-image-multiple-outline" aria-hidden="true"></i>
<span>Mur d'images</span>
<span>{'Picture wall'|t}</span>
</a>
<a href="{$base_path}/daily" class="sidebar-link{if="$pageName=='daily'"} active{/if}"
aria-label="Vue quotidienne">
aria-label="{'Daily'|t}">
<i class="mdi mdi-calendar-today" aria-hidden="true"></i>
<span>Quotidien</span>
<span>{'Daily'|t}</span>
</a>
<a href="{$base_path}/?searchtags=todo" class="sidebar-link{if="isset($search_tags) && preg_match('/(^|[\s,])todo([\s,]|$)/i', (string) $search_tags)"} active{/if}" aria-label="Mes tâches">
<a href="{$base_path}/?searchtags=todo" class="sidebar-link{if="isset($search_tags) && preg_match('/(^|[\s,])todo([\s,]|$)/i', (string) $search_tags)"} active{/if}" aria-label="{'My tasks'|t}">
<i class="mdi mdi-check-circle-outline" aria-hidden="true"></i>
<span>Mes tâches</span>
<span>{'My tasks'|t}</span>
</a>
<a href="{$base_path}/?searchtags=note" class="sidebar-link{if="isset($search_tags) && preg_match('/(^|[\s,])note([\s,]|$)/i', (string) $search_tags)"} active{/if}" aria-label="Notes">
<a href="{$base_path}/?searchtags=note" class="sidebar-link{if="isset($search_tags) && preg_match('/(^|[\s,])note([\s,]|$)/i', (string) $search_tags)"} active{/if}" aria-label="{'Notes'|t}">
<i class="mdi mdi-note-text-outline" aria-hidden="true"></i>
<span>Notes</span>
<span>{'Notes'|t}</span>
</a>
<a href="{$base_path}/?searchtags=shaarli-archive" class="sidebar-link{if="isset($search_tags) && preg_match('/(^|[\s,])shaarli-archive([\s,]|$)/i', (string) $search_tags)"} active{/if}" aria-label="Archive">
<a href="{$base_path}/?searchtags=shaarli-archive" class="sidebar-link{if="isset($search_tags) && preg_match('/(^|[\s,])shaarli-archive([\s,]|$)/i', (string) $search_tags)"} active{/if}" aria-label="{'Archive'|t}">
<i class="mdi mdi-archive-arrow-down-outline" aria-hidden="true"></i>
<span>Archive</span>
<span>{'Archive'|t}</span>
</a>
<a href="{$base_path}/?searchtags=readitlater" class="sidebar-link{if="isset($search_tags) && preg_match('/(^|[\s,])readitlater([\s,]|$)/i', (string) $search_tags)"} active{/if}" aria-label="Read It Later">
<a href="{$base_path}/?searchtags=readitlater" class="sidebar-link{if="isset($search_tags) && preg_match('/(^|[\s,])readitlater([\s,]|$)/i', (string) $search_tags)"} active{/if}" aria-label="{'Read It Later'|t}">
<i class="mdi mdi-bookmark-outline" aria-hidden="true"></i>
<span>Read It Later</span>
<span>{'Read It Later'|t}</span>
</a>
</div>
{if="$is_logged_in"}
<div class="sidebar-section">
<div class="sidebar-section-title">Administration</div>
<div class="sidebar-section-title">{'Administration'|t}</div>
<a href="{$base_path}/admin/tools" class="sidebar-link{if="$pageName=='tools'"} active{/if}"
aria-label="Outils">
aria-label="{'Tools'|t}">
<i class="mdi mdi-tools" aria-hidden="true"></i>
<span>Outils</span>
<span>{'Tools'|t}</span>
</a>
<a href="{$base_path}/admin/configure" class="sidebar-link{if="$pageName=='configure'"} active{/if}"
aria-label="Paramètres">
aria-label="{'Settings'|t}">
<i class="mdi mdi-cog-outline" aria-hidden="true"></i>
<span>Paramètres</span>
<span>{'Settings'|t}</span>
</a>
</div>
{/if}
@@ -105,10 +107,10 @@ Bookmarklet detection logic
<div class="theme-toggle-wrapper">
<span class="theme-toggle-label">
<i class="mdi mdi-weather-sunny" id="theme-icon-light" aria-hidden="true"></i>
<span>Mode clair</span>
<span>{'Light mode'|t}</span>
</span>
<label class="theme-switch" aria-label="Basculer le mode sombre">
<input type="checkbox" id="theme-toggle-checkbox" aria-label="Activer/désactiver le mode sombre">
<label class="theme-switch" aria-label="{'Toggle dark mode'|t}">
<input type="checkbox" id="theme-toggle-checkbox" aria-label="{'Toggle dark mode'|t}">
<span class="theme-slider"></span>
</label>
</div>
@@ -124,7 +126,7 @@ Bookmarklet detection logic
<header class="header-main">
<div class="header-inner">
<div class="header-left">
<button class="mobile-menu-btn" id="mobile-menu-btn" aria-label="Ouvrir le menu" aria-expanded="false"
<button class="mobile-menu-btn" id="mobile-menu-btn" aria-label="{'Open menu'|t}" aria-expanded="false"
aria-controls="sidebar">
<i class="mdi mdi-menu" aria-hidden="true"></i>
</button>
@@ -132,50 +134,50 @@ Bookmarklet detection logic
</div>
<!-- Main Navigation -->
<nav class="header-nav" aria-label="Navigation principale">
<nav class="header-nav" aria-label="{'Main navigation'|t}">
<a href="{$base_path}/" class="header-nav-link{if="$pageName=='linklist' && empty($search_tags)"} active{/if}"
aria-label="Accueil">
aria-label="{'Home'|t}">
<i class="mdi mdi-home-outline" aria-hidden="true"></i>
<span>ACCUEIL</span>
<span>{'Home'|t}</span>
</a>
<a href="{$base_path}/?searchtags=shaarli-pin" class="header-nav-link{if="isset($search_tags) && preg_match('/(^|[\s,])shaarli-pin([\s,]|$)/i', (string) $search_tags)"} active{/if}"
aria-label="Épinglés">
aria-label="{'Pinned'|t}">
<i class="mdi mdi-pin-outline" aria-hidden="true"></i>
<span>ÉPINGLÉS</span>
<span>{'Pinned'|t}</span>
</a>
<a href="{$base_path}/tags/cloud" class="header-nav-link{if="$pageName=='tag.cloud' || $pageName=='tag.list'"} active{/if}"
aria-label="Nuage de tags">
aria-label="{'Tag cloud'|t}">
<i class="mdi mdi-tag-multiple" aria-hidden="true"></i>
<span>TAGS</span>
<span>{'Tag cloud'|t}</span>
</a>
<a href="{$base_path}/picture-wall?{function="ltrim($searchcrits, '&' )"}" class="header-nav-link{if="$pageName=='picwall'"} active{/if}" aria-label="Mur d'images">
<a href="{$base_path}/picture-wall?{function="ltrim($searchcrits, '&' )"}" class="header-nav-link{if="$pageName=='picwall'"} active{/if}" aria-label="{'Picture wall'|t}">
<i class="mdi mdi-image-multiple" aria-hidden="true"></i>
<span>IMAGES</span>
<span>{'Picture wall'|t}</span>
</a>
<a href="{$base_path}/daily" class="header-nav-link{if="$pageName=='daily'"} active{/if}"
aria-label="Vue quotidienne">
aria-label="{'Daily'|t}">
<i class="mdi mdi-calendar" aria-hidden="true"></i>
<span>QUOTIDIEN</span>
<span>{'Daily'|t}</span>
</a>
<a href="{$base_path}/?searchtags=todo" class="header-nav-link{if="isset($search_tags) && preg_match('/(^|[\s,])todo([\s,]|$)/i', (string) $search_tags)"} active{/if}" aria-label="Mes tâches">
<a href="{$base_path}/?searchtags=todo" class="header-nav-link{if="isset($search_tags) && preg_match('/(^|[\s,])todo([\s,]|$)/i', (string) $search_tags)"} active{/if}" aria-label="{'My tasks'|t}">
<i class="mdi mdi-check-circle-outline" aria-hidden="true"></i>
<span>TÂCHES</span>
<span>{'My tasks'|t}</span>
</a>
<a href="{$base_path}/?searchtags=note" class="header-nav-link{if="isset($search_tags) && preg_match('/(^|[\s,])note([\s,]|$)/i', (string) $search_tags)"} active{/if}" aria-label="Notes">
<a href="{$base_path}/?searchtags=note" class="header-nav-link{if="isset($search_tags) && preg_match('/(^|[\s,])note([\s,]|$)/i', (string) $search_tags)"} active{/if}" aria-label="{'Notes'|t}">
<i class="mdi mdi-note-text-outline" aria-hidden="true"></i>
<span>NOTES</span>
<span>{'Notes'|t}</span>
</a>
<a href="{$base_path}/?searchtags=shaarli-archive" class="header-nav-link{if="isset($search_tags) && preg_match('/(^|[\s,])shaarli-archive([\s,]|$)/i', (string) $search_tags)"} active{/if}" aria-label="Archive">
<a href="{$base_path}/?searchtags=shaarli-archive" class="header-nav-link{if="isset($search_tags) && preg_match('/(^|[\s,])shaarli-archive([\s,]|$)/i', (string) $search_tags)"} active{/if}" aria-label="{'Archive'|t}">
<i class="mdi mdi-archive-arrow-down-outline" aria-hidden="true"></i>
<span>ARCHIVE</span>
<span>{'Archive'|t}</span>
</a>
<a href="{$base_path}/?searchtags=readitlater" class="header-nav-link{if="isset($search_tags) && preg_match('/(^|[\s,])readitlater([\s,]|$)/i', (string) $search_tags)"} active{/if}" aria-label="Read It Later">
<a href="{$base_path}/?searchtags=readitlater" class="header-nav-link{if="isset($search_tags) && preg_match('/(^|[\s,])readitlater([\s,]|$)/i', (string) $search_tags)"} active{/if}" aria-label="{'Read It Later'|t}">
<i class="mdi mdi-bookmark-outline" aria-hidden="true"></i>
<span>READ IT LATER</span>
<span>{'Read It Later'|t}</span>
</a>
<button class="header-nav-link" id="search-toggle-btn" data-search-toggle aria-label="Rechercher (raccourci S)">
<button class="header-nav-link" id="search-toggle-btn" data-search-toggle aria-label="{'Search (S shortcut)'|t}">
<i class="mdi mdi-magnify" aria-hidden="true"></i>
<span>RECHERCHE</span>
<span>{'Search'|t}</span>
</button>
</nav>
@@ -189,49 +191,49 @@ Bookmarklet detection logic
</button>
<!-- Filter Panel -->
<div class="filter-panel" id="filter-panel" role="dialog" aria-label="Panneau de filtres"
<div class="filter-panel" id="filter-panel" role="dialog" aria-label="{'Filters'|t}"
aria-hidden="true">
<div class="filter-header">
<span class="filter-title">Filtres</span>
<button class="filter-close" id="filter-close-btn" aria-label="Fermer les filtres">
<span class="filter-title">{'Filters'|t}</span>
<button class="filter-close" id="filter-close-btn" aria-label="{'Close filters'|t}">
<i class="mdi mdi-close" aria-hidden="true"></i>
</button>
</div>
<div class="filter-body">
<div class="filter-section">
<div class="filter-section-title">Liens par page</div>
<a href="{$base_path}/links-per-page?nb=20" class="filter-option">20 liens</a>
<a href="{$base_path}/links-per-page?nb=50" class="filter-option">50 liens</a>
<a href="{$base_path}/links-per-page?nb=100" class="filter-option">100 liens</a>
<div class="filter-section-title">{'Links per page'|t}</div>
<a href="{$base_path}/links-per-page?nb=20" class="filter-option">{'20 links'|t}</a>
<a href="{$base_path}/links-per-page?nb=50" class="filter-option">{'50 links'|t}</a>
<a href="{$base_path}/links-per-page?nb=100" class="filter-option">{'100 links'|t}</a>
</div>
<div class="filter-section">
<div class="filter-section-title">Valeur personnalisée</div>
<div class="filter-section-title">{'Custom value'|t}</div>
<form method="GET" action="{$base_path}/">
<input type="number" name="nb" class="filter-input" placeholder="20" min="1"
max="500" aria-label="Nombre de liens par page">
max="500" aria-label="{'Number of links per page'|t}">
</form>
</div>
{if="$is_logged_in"}
<div class="filter-section">
<div class="filter-section-title">Filtres de visibilité</div>
<div class="filter-section-title">{'Visibility filters'|t}</div>
<div class="filter-toggle-row">
<span class="filter-toggle-label">Liens privés uniquement</span>
<label class="toggle-switch" aria-label="Filtrer les liens privés">
<input type="checkbox" id="filter-private" aria-label="Liens privés uniquement">
<span class="filter-toggle-label">{'Private links only'|t}</span>
<label class="toggle-switch" aria-label="{'Filter private links'|t}">
<input type="checkbox" id="filter-private" aria-label="{'Private links only'|t}">
<span class="toggle-slider"></span>
</label>
</div>
<div class="filter-toggle-row">
<span class="filter-toggle-label">Liens publics uniquement</span>
<label class="toggle-switch" aria-label="Filtrer les liens publics">
<input type="checkbox" id="filter-public" aria-label="Liens publics uniquement">
<span class="filter-toggle-label">{'Public links only'|t}</span>
<label class="toggle-switch" aria-label="{'Filter public links'|t}">
<input type="checkbox" id="filter-public" aria-label="{'Public links only'|t}">
<span class="toggle-slider"></span>
</label>
</div>
<div class="filter-toggle-row">
<span class="filter-toggle-label">Liens sans tag</span>
<label class="toggle-switch" aria-label="Filtrer les liens sans tag">
<input type="checkbox" id="filter-untagged" aria-label="Liens sans tag">
<span class="filter-toggle-label">{'Untagged links'|t}</span>
<label class="toggle-switch" aria-label="{'Filter untagged links'|t}">
<input type="checkbox" id="filter-untagged" aria-label="{'Untagged links'|t}">
<span class="toggle-slider"></span>
</label>
</div>
@@ -243,14 +245,14 @@ Bookmarklet detection logic
{if="$is_logged_in"}
<!-- Multi-select Toggle -->
<button class="header-action-btn" id="select-mode-btn" aria-label="Sélection multiple"
<button class="header-action-btn" id="select-mode-btn" aria-label="{'Multi-select'|t}"
aria-pressed="false">
<i class="mdi mdi-checkbox-multiple-marked-outline" aria-hidden="true"></i>
</button>
{/if}
<!-- RSS Feed -->
<a href="{$base_path}/feed/rss" class="header-action-btn" aria-label="Flux RSS">
<a href="{$base_path}/feed/rss" class="header-action-btn" aria-label="{'RSS feed'|t}">
<i class="mdi mdi-rss" aria-hidden="true"></i>
</a>
@@ -262,14 +264,14 @@ Bookmarklet detection logic
{/if}
{if="$is_logged_in"}
<a href="{$base_path}/admin/tools" class="header-action-btn" aria-label="Outils">
<a href="{$base_path}/admin/tools" class="header-action-btn" aria-label="{'Tools'|t}">
<i class="mdi mdi-cog-outline" aria-hidden="true"></i>
</a>
<a href="{$base_path}/admin/logout" class="header-action-btn" aria-label="Déconnexion">
<a href="{$base_path}/admin/logout" class="header-action-btn" aria-label="{'Log out'|t}">
<i class="mdi mdi-logout-variant" aria-hidden="true"></i>
</a>
{else}
<a href="{$base_path}/login" class="header-action-btn" aria-label="Connexion">
<a href="{$base_path}/login" class="header-action-btn" aria-label="{'Log in'|t}">
<i class="mdi mdi-login-variant" aria-hidden="true"></i>
</a>
{/if}
@@ -285,26 +287,26 @@ Bookmarklet detection logic
<div class="search-input-row">
<i class="mdi mdi-magnify search-input-icon" aria-hidden="true"></i>
<input type="text" name="searchterm" class="search-modal-input" id="search-modal-input"
placeholder="Rechercher des notes, tags..." autocomplete="off" value="{if="isset($search_term)"}{$search_term}{/if}">
<button type="submit" class="search-submit-btn" id="search-submit-btn" aria-label="Lancer la recherche">
placeholder="{'Search notes, tags...'|t}" autocomplete="off" value="{if="isset($search_term)"}{$search_term}{/if}">
<button type="submit" class="search-submit-btn" id="search-submit-btn" aria-label="{'Run search'|t}">
<i class="mdi mdi-arrow-right"></i>
</button>
</div>
<div class="search-mode-bar" role="tablist" aria-label="Modes de recherche">
<div class="search-mode-bar" role="tablist" aria-label="{'Search modes'|t}">
<button type="button" class="search-mode-btn active" id="search-all-btn" aria-pressed="true" role="tab">
<i class="mdi mdi-magnify"></i>
<span>Globale</span>
<span>{'Global'|t}</span>
<kbd class="search-mode-kbd">S</kbd>
</button>
<button type="button" class="search-mode-btn" id="search-tags-btn" aria-pressed="false" role="tab">
<i class="mdi mdi-pound"></i>
<span>Tags</span>
<span>{'Tags'|t}</span>
<kbd class="search-mode-kbd">T</kbd>
</button>
<button type="button" class="search-mode-btn" id="search-notes-btn" aria-pressed="false" role="tab">
<i class="mdi mdi-note-text-outline"></i>
<span>Notes</span>
<span>{'Notes'|t}</span>
<kbd class="search-mode-kbd">N</kbd>
</button>
</div>
@@ -313,24 +315,24 @@ Bookmarklet detection logic
<div class="search-separator"></div>
<div class="search-results" id="search-results">
<div class="search-results-empty">
<span class="search-results-hint">Tapez pour voir des suggestions...</span>
<span class="search-results-hint">{'Type to see suggestions...'|t}</span>
</div>
</div>
<div class="search-footer">
<span class="search-footer-hint">
<kbd class="search-kbd">S</kbd> global
<kbd class="search-kbd">T</kbd> tags
<kbd class="search-kbd">N</kbd> notes
<kbd class="search-kbd">↑</kbd><kbd class="search-kbd">↓</kbd> naviguer
<kbd class="search-kbd">Enter</kbd> sélectionner
<kbd class="search-kbd">Esc</kbd> fermer
<kbd class="search-kbd">S</kbd> {'global'|t}
<kbd class="search-kbd">T</kbd> {'tags'|t}
<kbd class="search-kbd">N</kbd> {'notes'|t}
<kbd class="search-kbd">↑</kbd><kbd class="search-kbd">↓</kbd> {'navigate'|t}
<kbd class="search-kbd">Enter</kbd> {'select'|t}
<kbd class="search-kbd">Esc</kbd> {'close'|t}
</span>
</div>
</div>
</div>
<!-- Content Container -->
<div class="content-container">
<div class="content-container" id="content-container">
<!-- Feedback Messages -->
{if="!empty($plugin_errors) && $is_logged_in"}
{loop="plugin_errors"}
+5 -5
View File
@@ -219,9 +219,9 @@ body {
<div class="player-card" id="player-card">
<div class="player-artwork" id="artwork">
<i class="mdi mdi-music-note" id="artwork-icon"></i>
<span class="live-badge" id="live-badge">LIVE</span>
<span class="live-badge" id="live-badge">{'LIVE'|t}</span>
</div>
<div class="player-title" id="title">Loading...</div>
<div class="player-title" id="title">{'Loading...'|t}</div>
<div class="player-progress-wrap" id="progress-wrap">
<input type="range" class="player-progress" id="progress" min="0" max="100" value="0" step="0.1">
<div class="player-time">
@@ -230,13 +230,13 @@ body {
</div>
</div>
<div class="player-controls">
<button class="ctrl-btn sm" id="vol-btn" title="Mute/Unmute">
<button class="ctrl-btn sm" id="vol-btn" title="{'Mute/Unmute'|t}">
<i class="mdi mdi-volume-high" id="vol-icon"></i>
</button>
<button class="ctrl-btn lg" id="play-btn" title="Play/Pause">
<button class="ctrl-btn lg" id="play-btn" title="{'Play/Pause'|t}">
<i class="mdi mdi-play" id="play-icon"></i>
</button>
<button class="ctrl-btn sm close" id="close-btn" title="Stop & Close">
<button class="ctrl-btn sm close" id="close-btn" title="{'Stop & Close'|t}">
<i class="mdi mdi-close"></i>
</button>
</div>
+33
View File
@@ -0,0 +1,33 @@
/**
* Playwright configuration for Shaarli Pro theme smoke tests.
*
* These tests assume a running Shaarli instance with the shaarli-pro theme
* activated. Set the SHAARLI_URL environment variable to point to it.
*
* Example:
* SHAARLI_URL=http://localhost:8080 npx playwright test
*/
'use strict';
const { defineConfig, devices } = require('@playwright/test');
const baseURL = process.env.SHAARLI_URL || 'http://localhost:8080';
module.exports = defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'list',
use: {
baseURL,
trace: 'on-first-retry',
screenshot: 'only-on-failure'
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } }
]
});
+1 -1
View File
@@ -14,7 +14,7 @@
</noscript>
<h1>{'Plugin administration'|t}</h1>
<p>Drag and drop your plugin to change the order in which they'll be called. Uncheck enabled plugin to disable them and vice-versa.</p>
<p>{'Drag and drop your plugin to change the order in which they will be called. Uncheck an enabled plugin to disable it and vice-versa.'|t}</p>
<form action="{$base_path}/admin/plugins" method="post">
<input type="hidden" name="token" value="{$token}">
+3
View File
@@ -0,0 +1,3 @@
{loop="$plugin_content"}
{$value}
{/loop}
+1 -1
View File
@@ -20,7 +20,7 @@
<div class="col-md-10 col-md-offset-1">
<div class="card">
<div class="card-header">PHP</div>
<div class="card-header">{'PHP'|t}</div>
<div class="card-body">
<p style="margin-bottom: 1.5rem;">
<strong>{'Running PHP'|t} {$php_version}</strong>
+99
View File
@@ -0,0 +1,99 @@
/**
* Service Worker for Shaarli Pro theme.
*
* Caches static theme assets (CSS, JS, icons, fonts) so the UI stays available
* when the network is unreliable. Dynamic Shaarli pages are fetched from the
* network first and only cached as a fallback.
*/
'use strict';
const CACHE_NAME = 'shaarli-pro-v1.1.0';
const STATIC_ASSETS = [
'dist/style.min.css',
'dist/themes.min.css',
'dist/custom_views.min.css',
'dist/awesomplete.min.css',
'dist/i18n.min.js',
'dist/a11y.min.js',
'dist/shaarit-rules.min.js',
'dist/theme.min.js',
'dist/navigation.min.js',
'dist/search.min.js',
'dist/bulk-actions.min.js',
'dist/script.min.js',
'dist/custom_views.min.js',
'dist/awesomplete.min.js',
'dist/metadata.min.js',
'img/favicon.png',
'img/icons/icon-192x192.png',
'img/icons/icon-512x512.png'
];
const isStaticAsset = function (url) {
const pathname = url.pathname;
return STATIC_ASSETS.some(function (asset) {
return pathname.endsWith('/' + asset) || pathname === '/' + asset;
});
};
self.addEventListener('install', function (event) {
event.waitUntil(
caches.open(CACHE_NAME).then(function (cache) {
return cache.addAll(STATIC_ASSETS);
}).catch(function () {
// Silent failure: the SW still installs and will fetch on demand.
})
);
self.skipWaiting();
});
self.addEventListener('activate', function (event) {
event.waitUntil(
caches.keys().then(function (cacheNames) {
return Promise.all(
cacheNames.map(function (cacheName) {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
})
);
self.clients.claim();
});
self.addEventListener('fetch', function (event) {
const request = event.request;
const url = new URL(request.url);
// Only handle GET requests.
if (request.method !== 'GET') {
return;
}
if (isStaticAsset(url)) {
// Cache-first for theme assets.
event.respondWith(
caches.match(request).then(function (cached) {
return cached || fetch(request).then(function (response) {
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
const clone = response.clone();
caches.open(CACHE_NAME).then(function (cache) {
cache.put(request, clone);
});
return response;
});
})
);
} else {
// Network-first for everything else (Shaarli pages, feeds, etc.).
event.respondWith(
fetch(request).catch(function () {
return caches.match(request);
})
);
}
});
+2 -2
View File
@@ -23,7 +23,7 @@
<div class="card-body">
<!-- Alphabet Filter Bar -->
<div id="alphabet-filter" class="alphabet-filter">
<button type="button" class="alpha-btn active" data-letter="ALL">ALL</button>
<button type="button" class="alpha-btn active" data-letter="ALL">{'ALL'|t}</button>
<button type="button" class="alpha-btn" data-letter="#">#</button>
<button type="button" class="alpha-btn" data-letter="A">A</button>
<button type="button" class="alpha-btn" data-letter="B">B</button>
@@ -69,7 +69,7 @@
<!-- No results message -->
<div id="no-tags-message" class="no-tags-message" style="display: none;">
<i class="mdi mdi-tag-off-outline"></i>
<span>No tags found for this letter</span>
<span>{'No tags found for this letter'|t}</span>
</div>
</div>
</div>
+57
View File
@@ -0,0 +1,57 @@
/**
* Smoke tests for the Shaarli Pro theme.
*
* These tests require a running Shaarli instance with the shaarli-pro theme
* activated. Set SHAARLI_URL before running.
*/
'use strict';
const { test, expect } = require('@playwright/test');
test.describe('Shaarli Pro theme smoke tests', () => {
test('homepage loads and shows the main layout', async ({ page }) => {
await page.goto('/');
await expect(page.locator('body')).toBeVisible();
// Sidebar or header should be present.
await expect(
page.locator('.shaarli-sidebar, .sidebar, header, .header')
).toHaveCount(1);
});
test('search overlay opens with the S shortcut', async ({ page }) => {
await page.goto('/');
const search = page.locator('.search-overlay, #search-overlay');
await expect(search).not.toBeVisible();
await page.keyboard.press('s');
await expect(search).toBeVisible();
await page.keyboard.press('Escape');
await expect(search).not.toBeVisible();
});
test('theme toggle changes data-theme attribute', async ({ page }) => {
await page.goto('/');
const toggle = page.locator('[data-theme-toggle], .theme-toggle, #theme-toggle');
if (await toggle.count() === 0) {
test.skip('No theme toggle found on this page');
}
const initialTheme = await page.locator('html').getAttribute('data-theme');
await toggle.click();
await page.waitForTimeout(200);
const newTheme = await page.locator('html').getAttribute('data-theme');
expect(newTheme).not.toEqual(initialTheme);
});
test('skip-link is present for keyboard users', async ({ page }) => {
await page.goto('/');
await expect(page.locator('.skip-link')).toHaveAttribute('href');
});
test('bulk selection button is present on link list', async ({ page }) => {
await page.goto('/');
const bulkButton = page.locator('[data-bulk-select], .bulk-select-toggle, #bulk-select');
if (await bulkButton.count() === 0) {
test.skip('No bulk selection button found');
}
await expect(bulkButton).toBeVisible();
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
<?php
$theme_info = array(
'name' => 'Professional',
'version' => '1.0.0',
'version' => '1.1.0',
'author' => 'Antigravity',
'description' => 'A professional, modern theme with specific light and dark modes.',
'copyright' => '2026'
+87 -45
View File
@@ -124,7 +124,7 @@
<div class="card-body">
<div class="themes-info">
<i class="mdi mdi-information-outline"></i>
<span>{'Choose your preferred visual theme. The theme configuration will be synchronized across your devices via the shaarit_config bookmark.'|t}</span>
<span>{'Choose your preferred visual theme. The theme configuration is saved locally in your browser.'|t}</span>
</div>
<div class="themes-grid" id="themes-grid">
@@ -200,11 +200,11 @@
<div class="card-body">
<div class="key-value-list">
<div class="key-value-item">
<div class="key-value-label">Total Links</div>
<div class="key-value-label">{'Total Links'|t}</div>
<div class="key-value-data">{$linkcount}</div>
</div>
<div class="key-value-item">
<div class="key-value-label">Private Links</div>
<div class="key-value-label">{'Private Links'|t}</div>
<div class="key-value-data">{$privateLinkcount}</div>
</div>
</div>
@@ -218,7 +218,7 @@
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="card">
<div class="card-header">Plugin settings</div>
<div class="card-header">{'Plugin settings'|t}</div>
<div class="card-body">
{loop="$tools_plugin"}
{$value}
@@ -232,10 +232,10 @@
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="card">
<div class="card-header">Bookmarklet</div>
<div class="card-header">{'Bookmarklet'|t}</div>
<div class="card-body">
<p class="text-muted" style="margin-bottom: 1.5rem;">
Drag these buttons to your bookmarks bar to quickly add links from any page.
{'Drag these buttons to your bookmarks bar to quickly add links from any page.'|t}
</p>
<div class="bookmarklet-actions">
<a class="btn btn-primary" href="javascript:(function(){var%20url%20=%20location.href;var%20title%20=%20document.title%20||%20url;var%20desc=document.getSelection().toString();if(desc.length>4000){desc=desc.substr(0,4000)+'...';alert('{function="str_replace(' ', '%20', t('The selected text is too long, it will be truncated.'))"}');}window.open('{$pageabsaddr}admin/shaare?post='%20+%20encodeURIComponent(url)+'&amp;title='%20+%20encodeURIComponent(title)+'&amp;description='%20+%20encodeURIComponent(desc)+'&amp;source=bookmarklet','_blank','menubar=no,height=800,width=600,toolbar=no,scrollbars=yes,status=no,dialog=1');})();">
@@ -280,9 +280,6 @@
</div>
</div>
{loop="$tools_plugin"}
{$value}
{/loop}
</div>
{include="page.footer"}
@@ -606,19 +603,34 @@
if (!container) return;
const activeTheme = getActiveTheme();
container.innerHTML = THEMES.map(theme => `
<div class="theme-item ${activeTheme === theme.id ? 'active' : ''}" data-theme-id="${theme.id}" data-has-light="${theme.hasLight}">
<div class="theme-preview" style="--preview-primary: ${theme.color}; --preview-bg: ${theme.bg}; --preview-surface: ${theme.surface};">
<div class="theme-preview-top"></div>
<div class="theme-preview-bottom">
<div class="theme-preview-card"></div>
</div>
</div>
<div class="theme-name">${theme.name}</div>
<div class="theme-desc">${theme.desc}</div>
</div>
`).join('');
container.innerHTML = '';
THEMES.forEach(theme => {
const item = document.createElement('div');
item.className = 'theme-item' + (activeTheme === theme.id ? ' active' : '');
item.dataset.themeId = theme.id;
item.dataset.hasLight = String(theme.hasLight);
const preview = document.createElement('div');
preview.className = 'theme-preview';
preview.style.setProperty('--preview-primary', theme.color);
preview.style.setProperty('--preview-bg', theme.bg);
preview.style.setProperty('--preview-surface', theme.surface);
preview.innerHTML = '<div class="theme-preview-top"></div><div class="theme-preview-bottom"><div class="theme-preview-card"></div></div>';
const name = document.createElement('div');
name.className = 'theme-name';
name.textContent = theme.name;
const desc = document.createElement('div');
desc.className = 'theme-desc';
desc.textContent = theme.desc;
item.appendChild(preview);
item.appendChild(name);
item.appendChild(desc);
container.appendChild(item);
});
container.querySelectorAll('.theme-item').forEach(item => {
item.addEventListener('click', function() {
@@ -739,7 +751,7 @@
function showSaveStatus() {
const status = document.getElementById('save-status');
if (status) {
status.textContent = 'Saved!';
status.textContent = '{'Saved!'|t}';
status.classList.add('show');
setTimeout(() => status.classList.remove('show'), 2000);
}
@@ -759,21 +771,37 @@
if (!container) return;
const hiddenTags = getHiddenTags();
container.innerHTML = PRESET_TAGS.map(tag => {
container.innerHTML = '';
PRESET_TAGS.forEach(tag => {
const isChecked = hiddenTags.some(ht => ht.toLowerCase() === tag.name.toLowerCase());
return `
<div class="preset-tag-item">
<input type="checkbox" class="preset-tag-checkbox"
data-tag="${tag.name}" ${isChecked ? 'checked' : ''}>
<div class="preset-tag-info">
<div class="preset-tag-name">${tag.name}</div>
<div class="preset-tag-desc">${tag.desc}</div>
</div>
</div>
`;
}).join('');
const item = document.createElement('div');
item.className = 'preset-tag-item';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'preset-tag-checkbox';
checkbox.dataset.tag = tag.name;
checkbox.checked = isChecked;
const info = document.createElement('div');
info.className = 'preset-tag-info';
const name = document.createElement('div');
name.className = 'preset-tag-name';
name.textContent = tag.name;
const desc = document.createElement('div');
desc.className = 'preset-tag-desc';
desc.textContent = tag.desc;
info.appendChild(name);
info.appendChild(desc);
item.appendChild(checkbox);
item.appendChild(info);
container.appendChild(item);
});
// Add event listeners
container.querySelectorAll('.preset-tag-checkbox').forEach(cb => {
cb.addEventListener('change', function() {
const tagName = this.dataset.tag;
@@ -794,23 +822,37 @@
if (!container) return;
const hiddenTags = getHiddenTags();
const customTags = hiddenTags.filter(tag =>
const customTags = hiddenTags.filter(tag =>
!PRESET_TAGS.some(pt => pt.name.toLowerCase() === tag.toLowerCase())
);
container.innerHTML = '';
if (customTags.length === 0) {
container.innerHTML = '<div class="no-custom-tags">No custom hidden tags</div>';
const empty = document.createElement('div');
empty.className = 'no-custom-tags';
empty.textContent = '{'No custom hidden tags'|t}';
container.appendChild(empty);
return;
}
container.innerHTML = customTags.map(tag => `
<div class="custom-tag-chip">
<span>${tag}</span>
<span class="remove-tag" data-tag="${tag}">&times;</span>
</div>
`).join('');
customTags.forEach(tag => {
const chip = document.createElement('div');
chip.className = 'custom-tag-chip';
const label = document.createElement('span');
label.textContent = tag;
const remove = document.createElement('span');
remove.className = 'remove-tag';
remove.dataset.tag = tag;
remove.textContent = '\u00D7';
chip.appendChild(label);
chip.appendChild(remove);
container.appendChild(chip);
});
// Add remove listeners
container.querySelectorAll('.remove-tag').forEach(btn => {
btn.addEventListener('click', function() {
const tagName = this.dataset.tag;