A new about dialog (#9151)

This commit is contained in:
Adorian Doran
2026-04-19 18:44:44 +03:00
committed by GitHub
18 changed files with 1021 additions and 76 deletions

118
.mailmap
View File

@@ -1,2 +1,116 @@
zadam <adam.zivner@gmail.com>
zadam <zadam.apps@gmail.com>
# Format: Canonical Name <canonical-email> <commit-email>
# Merges aliases so `git shortlog`, `git log --use-mailmap`, etc. group commits per person.
# Core maintainers
zadam <zadam.apps@gmail.com>
zadam <zadam.apps@gmail.com> <adam.zivner@gmail.com>
zadam <zadam.apps@gmail.com> <adam.zivner@gemalto.com>
Elian Doran <contact@eliandoran.me>
Elian Doran <contact@eliandoran.me> <online@eliandoran.me>
Adorian Doran <adorian@esevo.ro>
Adorian Doran <adorian@esevo.ro> <adoriandoran@gmail.com>
# Contributors with multiple emails / name variants
Panagiotis Papadopoulos <pano_90@gmx.net> <102623907+pano9000@users.noreply.github.com>
Jon Fuller <jonfuller2012@gmail.com>
SiriusXT <1160925501@qq.com>
SiriusXT <1160925501@qq.com> <11609255001@qq.com>
SiriusXT <1160925501@qq.com> <37627919+SiriusXT@users.noreply.github.com>
JYC333 <22962980+JYC333@users.noreply.github.com>
JYC333 <22962980+JYC333@users.noreply.github.com> <yuchuanjin333@gmail.com>
Nriver <6752679+Nriver@users.noreply.github.com>
Francis C. <normitomf@gmail.com>
Francis C. <normitomf@gmail.com> <francistw@users.noreply.github.com>
Thomas Frei <7283497+thfrei@users.noreply.github.com>
hasecilu <hasecilu@tuta.io>
meinzzzz <lukas.geiselhart35@gmail.com>
FliegendeWurst <arne.keller@posteo.de>
FliegendeWurst <arne.keller@posteo.de> <2012gdwu@web.de>
FliegendeWurst <arne.keller@posteo.de> <2012gdwu+github@posteo.de>
MeIchthys <github.com@meichthys.com>
MeIchthys <github.com@meichthys.com> <10717998+meichthys@users.noreply.github.com>
Marcel Wiechmann <marcel.wiechmann@gmail.com>
Marcel Wiechmann <marcel.wiechmann@gmail.com> <github.y3y0w@sl.wiechmann.at>
Tomas Adamek <ad.tomik@seznam.cz>
Tomas Adamek <ad.tomik@seznam.cz> <50672285+Kureii@users.noreply.github.com>
soulsands <407221377@qq.com>
chesspro13 <chesspro13@gmail.com>
sigaloid <69441971+sigaloid@users.noreply.github.com>
Marek Lewandowski <m.lewandowski@cksource.com>
Marek Lewandowski <m.lewandowski@cksource.com> <code@mlewandowski.com>
Marek Lewandowski <m.lewandowski@cksource.com> <mlewand@users.noreply.github.com>
lzinga <lucas.elzinga@outlook.com>
lzinga <lucas.elzinga@outlook.com> <lzinga@users.noreply.github.com>
Sukant Gujar <sukantgujar@yahoo.com>
Matt Wilkie <maphew@gmail.com>
Matt Wilkie <maphew@gmail.com> <matt.wilkie@yukon.ca>
Andreas Haan <andreas.mobil1@googlemail.com>
Potjoe-97 <42873357+Potjoe-97@users.noreply.github.com>
Potjoe-97 <42873357+Potjoe-97@users.noreply.github.com> <giann@LAPTOPT490-GF>
Alex Pietsch <54153428+alexpietsch@users.noreply.github.com>
Laurent Cozic <laurent@cozic.net>
Laurent Cozic <laurent@cozic.net> <laurent22@users.noreply.github.com>
Zexin Yuan <git@yzx9.xyz>
Zexin Yuan <git@yzx9.xyz> <yuan.zx@outlook.com>
hulmgulm <hulmgulm@users.noreply.github.com>
hulmgulm <hulmgulm@users.noreply.github.com> <12165268+hulmgulm@users.noreply.github.com>
hulmgulm <hulmgulm@users.noreply.github.com> <github@hulmgulm.de>
Jules Bertholet <jules.bertholet@gmail.com>
Charles Dagenais <dagenais.charles@gmail.com>
Giulia Ye <yg97.cs@gmail.com>
baddate <37013819+baddate@users.noreply.github.com>
DerVogel101 <128903814+DerVogel101@users.noreply.github.com>
DerVogel101 <128903814+DerVogel101@users.noreply.github.com> <jan.irmer@outlook.de>
Marcello Fuschi <marcellofuschi1@gmail.com>
Jiahao Lee <lijiahao34@live.com>
Dmitry Matveyev <dev@greenfork.me>
Dmitry Matveyev <dev@greenfork.me> <info@greenfork.me>
Grant Zhu <a1065135230@gmail.com>
Sylvain Pasche <sylvain.pasche@gmail.com>
Sylvain Pasche <sylvain.pasche@gmail.com> <spasche@spasche.net>
mm21 <8033134+mm21@users.noreply.github.com>
mm21 <8033134+mm21@users.noreply.github.com> <mm21.dev@gmail.com>
BeatLink <git@beatlink.simplelogin.com>
BeatLink <git@beatlink.simplelogin.com> <github@beatlink.simplelogin.com>
Florian Meißner <161936+Mystler@users.noreply.github.com>
Florian Meißner <161936+Mystler@users.noreply.github.com> <developer@mystler.eu>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="0 0 100 100" enable-background="new 0 0 100 100" xml:space="preserve"><path d="M63.966,45.043c0.008-0.009,0.021-0.021,0.027-0.029c0.938-1.156-0.823-13.453-5.063-20.125 c-1.389-2.186-2.239-3.423-3.219-4.719c-3.907-5.166-6-6.125-6-6.125S35.732,24.78,36.149,44.315 c-1.754,0.065-11.218,7.528-14.826,14.388c-1.206,2.291-1.856,3.645-2.493,5.141c-2.539,5.957-2.33,8.25-2.33,8.25 s16.271,6.79,33.014-3.294c0.007,0.021,0.013,0.046,0.02,0.063c0.537,1.389,12.08,5.979,19.976,5.621 c2.587-0.116,4.084-0.238,5.696-0.444c6.424-0.818,8.298-2.157,8.298-2.157S81.144,54.396,63.966,45.043z M50.787,65.343 c1.059-1.183,4.648-5.853,0.995-11.315c-0.253-0.377-0.496-0.236-0.496-0.236s0.063,10.822-5.162,12.359 c-5.225,1.537-13.886,4.4-20.427,0.455C25,66.186,26.924,53.606,38.544,47.229c0.546,1.599,2.836,6.854,9.292,6.409 c0.453-0.031,0.453-0.313,0.453-0.313s-9.422-5.328-8.156-10.625s3.089-14.236,9.766-17.948c0.714-0.397,10.746,7.593,10.417,20.94 c-1.606-0.319-7.377-1.004-10.226,4.864c-0.198,0.409,0.046,0.549,0.046,0.549s9.31-5.521,13.275-1.789 c3.965,3.733,10.813,9.763,10.71,17.4C74.111,67.533,62.197,72.258,50.787,65.343z M35.613,35.145c0,0-0.991,3.241-0.603,7.524 l-13.393-7.524C21.618,35.145,27.838,30.931,35.613,35.145z M21.193,36.03l13.344,7.612c-3.872,1.872-6.142,4.388-6.142,4.388 C20.78,43.531,21.193,36.03,21.193,36.03z M72.287,49.064c0,0-2.321-2.471-6.23-4.263l13.187-7.881 C79.243,36.92,79.808,44.413,72.287,49.064z M78.687,36.113l-13.237,7.794c0.3-4.291-0.754-7.511-0.754-7.511 C72.383,32.025,78.687,36.113,78.687,36.113z M42.076,73.778c0,0,3.309-0.737,6.845-3.185l0.056,15.361 C48.977,85.955,42.244,82.621,42.076,73.778z M49.956,85.888L50,70.526c3.539,2.445,6.846,3.181,6.846,3.181 C56.686,82.551,49.956,85.888,49.956,85.888z"></path></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg enable-background="new 0 0 256 256" version="1.1" viewBox="0 0 256 256" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<title>Trilium Notes</title>
<g>
<path d="m202.9 112.7c-22.5 16.1-54.5 12.8-74.9 6.3l14.8-11.8 14.1-11.3 49.1-39.3-51.2 35.9-14.3 10-14.9 10.5c0.7-21.2 7-49.9 28.6-65.4 1.8-1.3 3.9-2.6 6.1-3.8 2.7-1.5 5.7-2.9 8.8-4.1 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.9 65.9-2.4 2.8-4.9 5.4-7.4 7.8-3.4 3.5-6.8 6.4-10.1 8.8z" fill="#ab60e3"/>
<path d="m213.1 104c-22.2 12.6-51.4 9.3-70.3 3.2l14.1-11.3 49.1-39.3-51.2 35.9-14.3 10c0.5-18.1 4.9-42.1 19.7-58.6 2.7-1.5 5.7-2.9 8.8-4.1 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.9 65.9-2.3 2.8-4.8 5.4-7.2 7.8z" fill="#8038b8"/>
<path d="m220.5 96.2c-21.1 8.6-46.6 5.3-63.7-0.2l49.2-39.4-51.2 35.9c0.3-15.8 3.5-36.6 14.3-52.8 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.8 66z" fill="#560a8f"/>
<path d="m106.7 179c-5.8-21 5.2-43.8 15.5-57.2l4.8 14.2 4.5 13.4 15.9 47-12.8-47.6-3.6-13.2-3.7-13.9c15.5 6.2 35.1 18.6 40.7 38.8 0.5 1.7 0.9 3.6 1.2 5.5 0.4 2.4 0.6 5 0.7 7.7 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8-1.4-2.6-2.7-5.1-3.8-7.6-1.6-3.5-2.9-6.8-3.8-10z" fill="#bb9dd2"/>
<path d="m110.4 188.9c-3.4-19.8 6.9-40.5 16.6-52.9l4.5 13.4 15.9 47-12.8-47.6-3.6-13.2c13.3 5.2 29.9 15 38.1 30.4 0.4 2.4 0.6 5 0.7 7.7 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8-1.4-2.6-2.7-5.2-3.8-7.7z" fill="#9a6cbc"/>
<path d="m114.2 196.5c-0.7-18 8.6-35.9 17.3-47.1l15.9 47-12.8-47.6c11.6 4.4 26.1 12.4 35.2 24.8 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8z" fill="#783ba5"/>
<path d="m86.3 59.1c21.7 10.9 32.4 36.6 35.8 54.9l-15.2-6.6-14.5-6.3-50.6-22 48.8 24.9 13.6 6.9 14.3 7.3c-16.6 7.9-41.3 14.5-62.1 4.1-1.8-0.9-3.6-1.9-5.4-3.2-2.3-1.5-4.5-3.2-6.8-5.1-19.9-16.4-40.3-46.4-42.7-61.5 12.4-6.5 41.5-5.8 64.8-0.3 3.2 0.8 6.2 1.6 9.1 2.5 4 1.3 7.6 2.8 10.9 4.4z" fill="#ab60e3"/>
<path d="m75.4 54.8c18.9 12 28.4 35.6 31.6 52.6l-14.5-6.3-50.6-22 48.7 24.9 13.6 6.9c-14.1 6.8-34.5 13-53.3 8.2-2.3-1.5-4.5-3.2-6.8-5.1-19.8-16.4-40.2-46.4-42.6-61.5 12.4-6.5 41.5-5.8 64.8-0.3 3.1 0.8 6.2 1.6 9.1 2.6z" fill="#8038b8"/>
<path d="m66.3 52.2c15.3 12.8 23.3 33.6 26.1 48.9l-50.6-22 48.8 24.9c-12.2 6-29.6 11.8-46.5 10-19.8-16.4-40.2-46.4-42.6-61.5 12.4-6.5 41.5-5.8 64.8-0.3z" fill="#6f2796"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg enable-background="new 0 0 256 256" version="1.1" viewBox="0 0 256 256" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<title>Trilium Notes</title>
<style type="text/css">
.st0{fill:#95C980;}
.st1{fill:#72B755;}
.st2{fill:#4FA52B;}
.st3{fill:#EE8C89;}
.st4{fill:#E96562;}
.st5{fill:#E33F3B;}
.st6{fill:#EFB075;}
.st7{fill:#E99547;}
.st8{fill:#E47B19;}
</style>
<g>
<path class="st0" d="m202.9 112.7c-22.5 16.1-54.5 12.8-74.9 6.3l14.8-11.8 14.1-11.3 49.1-39.3-51.2 35.9-14.3 10-14.9 10.5c0.7-21.2 7-49.9 28.6-65.4 1.8-1.3 3.9-2.6 6.1-3.8 2.7-1.5 5.7-2.9 8.8-4.1 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.9 65.9-2.4 2.8-4.9 5.4-7.4 7.8-3.4 3.5-6.8 6.4-10.1 8.8z"/>
<path class="st1" d="m213.1 104c-22.2 12.6-51.4 9.3-70.3 3.2l14.1-11.3 49.1-39.3-51.2 35.9-14.3 10c0.5-18.1 4.9-42.1 19.7-58.6 2.7-1.5 5.7-2.9 8.8-4.1 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.9 65.9-2.3 2.8-4.8 5.4-7.2 7.8z"/>
<path class="st2" d="m220.5 96.2c-21.1 8.6-46.6 5.3-63.7-0.2l49.2-39.4-51.2 35.9c0.3-15.8 3.5-36.6 14.3-52.8 27.1-11.1 68.5-15.3 85.2-9.5 0.1 16.2-15.9 45.4-33.8 66z"/>
<path class="st3" d="m106.7 179c-5.8-21 5.2-43.8 15.5-57.2l4.8 14.2 4.5 13.4 15.9 47-12.8-47.6-3.6-13.2-3.7-13.9c15.5 6.2 35.1 18.6 40.7 38.8 0.5 1.7 0.9 3.6 1.2 5.5 0.4 2.4 0.6 5 0.7 7.7 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8-1.4-2.6-2.7-5.1-3.8-7.6-1.6-3.5-2.9-6.8-3.8-10z"/>
<path class="st4" d="m110.4 188.9c-3.4-19.8 6.9-40.5 16.6-52.9l4.5 13.4 15.9 47-12.8-47.6-3.6-13.2c13.3 5.2 29.9 15 38.1 30.4 0.4 2.4 0.6 5 0.7 7.7 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8-1.4-2.6-2.7-5.2-3.8-7.7z"/>
<path class="st5" d="m114.2 196.5c-0.7-18 8.6-35.9 17.3-47.1l15.9 47-12.8-47.6c11.6 4.4 26.1 12.4 35.2 24.8 0.9 23.1-7.1 54.9-15.9 65.7-12-4.3-29.3-24-39.7-42.8z"/>
<path class="st6" d="m86.3 59.1c21.7 10.9 32.4 36.6 35.8 54.9l-15.2-6.6-14.5-6.3-50.6-22 48.8 24.9 13.6 6.9 14.3 7.3c-16.6 7.9-41.3 14.5-62.1 4.1-1.8-0.9-3.6-1.9-5.4-3.2-2.3-1.5-4.5-3.2-6.8-5.1-19.9-16.4-40.3-46.4-42.7-61.5 12.4-6.5 41.5-5.8 64.8-0.3 3.2 0.8 6.2 1.6 9.1 2.5 4 1.3 7.6 2.8 10.9 4.4z"/>
<path class="st7" d="m75.4 54.8c18.9 12 28.4 35.6 31.6 52.6l-14.5-6.3-50.6-22 48.7 24.9 13.6 6.9c-14.1 6.8-34.5 13-53.3 8.2-2.3-1.5-4.5-3.2-6.8-5.1-19.8-16.4-40.2-46.4-42.6-61.5 12.4-6.5 41.5-5.8 64.8-0.3 3.1 0.8 6.2 1.6 9.1 2.6z"/>
<path class="st8" d="m66.3 52.2c15.3 12.8 23.3 33.6 26.1 48.9l-50.6-22 48.8 24.9c-12.2 6-29.6 11.8-46.5 10-19.8-16.4-40.2-46.4-42.6-61.5 12.4-6.5 41.5-5.8 64.8-0.3z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -1750,6 +1750,22 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
display: flex;
}
body.mobile .modal-dialog.modal-dialog-full-page-on-mobile {
width: 100%;
height: 100%;
max-height: unset;
max-width: unset;
.modal-content {
border-radius: 0;
border: 0;
.modal-body {
overflow: scroll;
}
}
}
body.mobile .modal-content {
overflow-y: auto;
border-radius: var(--bs-modal-border-radius) var(--bs-modal-border-radius) 0 0;

View File

@@ -269,9 +269,9 @@
--timeline-connector-active-color: #ddd;
--timeline-connector-hover-blend-mode: multiply;
--tooltip-background-color: rgba(255, 255, 255, 0.85);
--tooltip-foreground-color: #000000ba;
--tooltip-shadow-color: rgba(0, 0, 0, 0.15);
--tooltip-background-color: rgba(0, 0, 0, 0.818);
--tooltip-foreground-color: #ffffffeb;
--tooltip-shadow-color: rgba(0, 0, 0, 0.2);
--help-background-color: #fffc;
--help-card-background: var(--card-background-color);

View File

@@ -1,13 +1,23 @@
{
"about": {
"title": "About Trilium Notes",
"homepage": "Homepage:",
"app_version": "App version:",
"db_version": "DB version:",
"sync_version": "Sync version:",
"build_date": "Build date:",
"build_revision": "Build revision:",
"data_directory": "Data directory:"
"about": {
"version_label": "Version:",
"version": "{{appVersion}} (database: {{dbVersion}}, sync protocol: {{syncVersion}})",
"build_info": "Build: {{buildDate}}, revision: <buildRevision />",
"contributors_label": "Contributors:",
"contributor_roles": {
"lead-dev": "lead developer",
"original-dev": "original developer"
},
"role_brief_history": {
"lead-dev": "Elian Doran founded TriliumNext in 2024, a community fork created after Zadam stepped back from the project. Zadam later transferred the original repository to the TriliumNext team, merging both projects back into one.",
"original-dev": "On 25th December 2017, Zadam released the first beta of Trilium (written with a single \"l\", unlike the flower). Dissatisfied with existing note organizers, he built a powerful self-hosted hierarchical knowledge base that gathered over 22,000 GitHub stars. In 2024, as life got busier, he placed the project into maintenance mode."
},
"contributor_full_list": "See the entire community",
"data_directory": "Data directory:",
"github_tooltip": "Report bugs, suggest features, or contribute on GitHub",
"license_tooltip": "View license",
"donate": "Donate",
"donate_tooltip": "Donate to support this project"
},
"toast": {
"critical-error": {

View File

@@ -0,0 +1,179 @@
.about-dialog {
:where(body.light-theme &) {
--donate-button-color: #e33f3b;
&.nightly {
--modal-background-color: #f2e1ff;
}
}
:where(body.dark-theme &) {
--donate-button-color: #fba6a5;
&.nightly {
--modal-background-color: #23182b;
}
}
--bs-modal-width: 680px;
.icon {
width: 160px;
height: 160px;
&[data-icon="default"] {
background-image: url(../../assets/icon.svg);
}
&[data-icon="nightly"] {
background-image: url(../../assets/icon-nightly.svg);
}
&[data-icon="default"],
&[data-icon="nightly"] {
animation: icon-intro 500ms ease-out;
will-change: opacity, transform;
}
&[data-icon="classic"] {
mask-image: url(../../assets/icon-classic.svg);
background-color: var(--muted-text-color);
animation: icon-classic-intro 300ms ease-in-out;
will-change: opacity, transform;
}
}
h2 {
all: unset;
font-size: 2em;
font-weight: 300;
letter-spacing: 1pt;
.channel-name {
opacity: .75;
}
}
.about-dialog-content {
display: flex;
flex-direction: column;
align-items: center;
}
.about-dialog-property-sheet {
margin-block: 30px;
&.wide {
font-size: .85em;
margin-inline: 20px;
}
}
.build-info {
color: var(--muted-text-color);
font-size: .9em;
}
.contributor-list {
a, span {
white-space: nowrap;
}
.contributor-role {
text-decoration: underline dotted var(--main-text-color);
text-underline-offset: 3px;
text-decoration-color: var(--muted-text-color);
cursor: help;
}
.about-dialog-property-sheet.narrow & {
line-height: 1.75;
}
}
footer {
display: flex;
justify-content: center;
gap: 10px;
margin-bottom: 30px;
a {
--_icon-size: 28px;
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 10px;
border-radius: 6px;
font-size: .9rem;
color: var(--main-text-color);
&:hover {
background: var(--icon-button-hover-background);
}
&::after {
display: none;
}
i {
font-size: var(--_icon-size);
}
svg {
fill: currentColor;
height: var(--_icon-size);
}
&.donate-link {
color: var(--donate-button-color);
&:hover i {
animation: heartbeat 600ms ease-in-out;
animation-iteration-count: 3;
}
}
}
}
}
.about-dialog-brief-history-tooltip {
--main-font-size: .9em;
padding-inline: 30px;
.tooltip-inner {
max-width: 600px;
}
}
@keyframes icon-intro {
from {
opacity: 0;
transform: scale(.5);
} to {
opacity: 1;
transform: scale(1);
}
}
@keyframes icon-classic-intro {
from {
opacity: 0;
transform: rotate(50deg) scale(.5);
} to {
opacity: 1;
transform: rotate(0deg) scale(1.25);
}
}
@keyframes heartbeat {
0% {
transform: scale(1);
} 50% {
transform: scale(1.15);
} 75% {
transform: scale(1);
} 100% {
transform: scale(1);
}
}

View File

@@ -4,80 +4,233 @@ import { formatDateTime } from "../../utils/formatters.js";
import server from "../../services/server.js";
import utils from "../../services/utils.js";
import openService from "../../services/open.js";
import { useState } from "preact/hooks";
import type { CSSProperties } from "preact/compat";
import type { AppInfo } from "@triliumnext/commons";
import { useTriliumEvent } from "../react/hooks.jsx";
import { useState, useCallback, useRef } from "preact/hooks";
import type { AppInfo, Contributor, ContributorList } from "@triliumnext/commons";
import { useTooltip, useTriliumEvent } from "../react/hooks.jsx";
import { PropertySheet, PropertySheetItem } from "../react/PropertySheet.js";
import "./about.css";
import { Trans } from "react-i18next";
import type React from "react";
import contributors from "../../../../../contributors.json";
import { Fragment } from "preact/jsx-runtime";
import type { ComponentChildren } from "preact";
import { useMemo, memo } from "preact/compat";
import clsx from "clsx";
export default function AboutDialog() {
const [appInfo, setAppInfo] = useState<AppInfo | null>(null);
const [shown, setShown] = useState(false);
const forceWordBreak: CSSProperties = { wordBreak: "break-all" };
const [isShown, setIsShown] = useState(false);
const [isNightly, setNightly] = useState(false);
const [icon, setIcon] = useState("default");
const [altIcon, setAltIcon] = useState<string | null>(null);
useTriliumEvent("openAboutDialog", () => setShown(true));
const hasLoaded = useRef(false);
const onLoad = useCallback(async () => {
if (!hasLoaded.current) {
const info = await server.get<AppInfo>("app-info");
if (info.appVersion.includes("test")) {
setNightly(true);
setIcon("nightly");
}
setAppInfo(info);
hasLoaded.current = true;
}
setIsShown(true);
}, []);
useTriliumEvent("openAboutDialog", onLoad);
const createContributorHoverHandler = () => {
let timeoutID: ReturnType<typeof setTimeout>;
return (contributor: Contributor, isHovering: boolean, part: "name" | "role") => {
if (part === "role" && contributor.role === "original-dev") {
if (isHovering) {
timeoutID = setTimeout(() => {
setAltIcon("classic");
}, 500);
} else {
clearTimeout(timeoutID);
setAltIcon(null);
}
}
}
};
/* Cache the contributor list to prevent its rerendering.
* When the icon changes, it triggers a rerender of the dialog. If this happens while an
* element with a tooltip is hovered, its tooltip will break. */
const CachedContributors = useMemo(() => memo(function CachedContributors() {
return <Contributors
data={contributors as ContributorList}
onHover={createContributorHoverHandler()}
/>
}), []);
return (
<Modal className="about-dialog"
size="lg"
title={t("about.title")}
show={shown}
onShown={async () => {
const appInfo = await server.get<AppInfo>("app-info");
setAppInfo(appInfo);
}}
onHidden={() => setShown(false)}
<Modal
className={clsx(["about-dialog", {"nightly": isNightly}])}
size="md"
isFullPageOnMobile
show={isShown}
onHidden={() => setIsShown(false)}
>
<table className="table table-borderless">
<tbody>
<tr>
<th>{t("about.homepage")}</th>
<td className="selectable-text"><a className="tn-link external" href="https://github.com/TriliumNext/Trilium" style={forceWordBreak}>https://github.com/TriliumNext/Trilium</a></td>
</tr>
<tr>
<th>{t("about.app_version")}</th>
<td className="app-version selectable-text">{appInfo?.appVersion}</td>
</tr>
<tr>
<th>{t("about.db_version")}</th>
<td className="db-version selectable-text">{appInfo?.dbVersion}</td>
</tr>
<tr>
<th>{t("about.sync_version")}</th>
<td className="sync-version selectable-text">{appInfo?.syncVersion}</td>
</tr>
<tr>
<th>{t("about.build_date")}</th>
<td className="build-date selectable-text">
{appInfo?.buildDate ? formatDateTime(appInfo.buildDate) : ""}
</td>
</tr>
<tr>
<th>{t("about.build_revision")}</th>
<td className="selectable-text">
{appInfo?.buildRevision && <a className="tn-link build-revision external" href={`https://github.com/TriliumNext/Trilium/commit/${appInfo.buildRevision}`} target="_blank" style={forceWordBreak}>{appInfo.buildRevision}</a>}
</td>
</tr>
<tr>
<th>{t("about.data_directory")}</th>
<td className="data-directory">
{appInfo?.dataDirectory && (<DirectoryLink directory={appInfo.dataDirectory} style={forceWordBreak} />)}
</td>
</tr>
</tbody>
</table>
<div className="about-dialog-content">
<div className={"icon"} data-icon={altIcon ?? icon} />
<h2>Trilium Notes {isNightly && <span className="channel-name">Nightly</span>}</h2>
<a className="tn-link" href="https://triliumnotes.org/" target="_blank" rel="noopener noreferrer">
triliumnotes.org
</a>
<PropertySheet className="about-dialog-property-sheet">
<PropertySheetItem label={t("about.version_label")}>
{t("about.version", {
appVersion: appInfo?.appVersion,
dbVersion: appInfo?.dbVersion,
syncVersion: appInfo?.syncVersion
})}
<div className="build-info">
<Trans
i18nKey="about.build_info"
values={{
buildDate: appInfo?.buildDate ? formatDateTime(appInfo.buildDate) : ""
}}
components={{
buildRevision: <RevisionLink appInfo={appInfo} /> as React.ReactElement
}}
/>
</div>
</PropertySheetItem>
<PropertySheetItem className="contributor-list use-tn-links" label={t("about.contributors_label")}>
<CachedContributors />
<a href="https://github.com/TriliumNext/Trilium/graphs/contributors" target="_blank" rel="noopener noreferrer">
{t("about.contributor_full_list")}
</a>
</PropertySheetItem>
<PropertySheetItem label={t("about.data_directory")}>
<div style={{wordBreak: "break-all"}}>
{appInfo?.dataDirectory && (<DirectoryLink directory={appInfo.dataDirectory} />)}
</div>
</PropertySheetItem>
</PropertySheet>
</div>
<footer>
<FooterLink
text="GitHub"
url="https://github.com/TriliumNext/Trilium"
tooltip={t("about.github_tooltip")}>
<i className='bx bxl-github'></i>
</FooterLink>
<FooterLink
text="AGPL 3.0"
url="https://docs.triliumnotes.org/user-guide/misc/license"
tooltip={t("about.license_tooltip")}>
{/* https://pictogrammers.com/library/mdi/icon/scale-balance/ */}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12,3C10.73,3 9.6,3.8 9.18,5H3V7H4.95L2,14C1.53,16 3,17 5.5,17C8,17 9.56,16 9,14L6.05,7H9.17C9.5,7.85 10.15,8.5 11,8.83V20H2V22H22V20H13V8.82C13.85,8.5 14.5,7.85 14.82,7H17.95L15,14C14.53,16 16,17 18.5,17C21,17 22.56,16 22,14L19.05,7H21V5H14.83C14.4,3.8 13.27,3 12,3M12,5A1,1 0 0,1 13,6A1,1 0 0,1 12,7A1,1 0 0,1 11,6A1,1 0 0,1 12,5M5.5,10.25L7,14H4L5.5,10.25M18.5,10.25L20,14H17L18.5,10.25Z" /></svg>
</FooterLink>
<FooterLink
text={t("about.donate")}
url="https://triliumnotes.org/en/support-us"
tooltip={t("about.donate_tooltip")}
className="donate-link">
<i className='bx bx-heart' ></i>
</FooterLink>
</footer>
</Modal>
);
}
function DirectoryLink({ directory, style }: { directory: string, style?: CSSProperties }) {
function RevisionLink({appInfo}: {appInfo: AppInfo | null}) {
return <>
{appInfo?.buildRevision && <a href={`https://github.com/TriliumNext/Trilium/commit/${appInfo.buildRevision}`} target="_blank" rel="noopener noreferrer" className="tn-link">
{appInfo.buildRevision.substring(0, 7)}
</a>}
</>;
}
function FooterLink(props: {children: ComponentChildren, text: string, url: string, tooltip: string, className?: string}) {
const linkRef = useRef<HTMLAnchorElement>(null);
useTooltip(linkRef, {
title: props.tooltip,
delay: 250,
placement: "bottom"
})
return <a ref={linkRef} href={props.url} className={props.className} target="_blank" rel="noopener noreferrer" draggable={false}>
{props.children}
{props.text}
</a>
}
type HoverCallback = (contributor: Contributor, isHovering: boolean, part: "name" | "role") => void;
function Contributors({data, onHover}: {data: ContributorList, onHover?: HoverCallback}) {
return data.contributors.map((c, index, array) => {
return <Fragment key={c.name}>
<ContributorListItem data={c} onHover={onHover} />
{/* Add a comma between items */}
{(index < array.length - 1) ? ", " : ". "}
</Fragment>
});
}
function ContributorListItem({data, onHover}: {data: Contributor, onHover?: HoverCallback}) {
const roleRef = useRef<HTMLSpanElement>(null);
const roleString = (data.role) ? t(`about.contributor_roles.${data.role}`) : "";
useTooltip(roleRef, (data.role) ? {
title: t(`about.role_brief_history.${data.role}`),
customClass: "about-dialog-brief-history-tooltip",
placement: "bottom",
offset: [0, 10],
delay: 500
}: {});
return <>
<a
href={data.url}
target="_blank"
rel="noopener noreferrer"
onMouseEnter={() => onHover?.(data, true, "name")}
onMouseLeave={() => onHover?.(data, false, "name")}>
{data.fullName ?? data.name}
</a>
{roleString && <span
ref={roleRef}
onMouseEnter={() => onHover?.(data, true, "role")}
onMouseLeave={() => onHover?.(data, false, "role")}>
(<span className="contributor-role">{roleString}</span>)
</span>}
</>
}
function DirectoryLink({ directory }: { directory: string}) {
if (utils.isElectron()) {
const onClick = (e: MouseEvent) => {
e.preventDefault();
openService.openDirectory(directory);
};
return <a className="tn-link selectable-text" href="#" onClick={onClick} style={style}>{directory}</a>
return <a className="tn-link selectable-text" href="#" onClick={onClick}>{directory}</a>
} else {
return <span className="selectable-text" style={style}>{directory}</span>;
return <span className="selectable-text">{directory}</span>;
}
}
}

View File

@@ -0,0 +1,43 @@
import clsx from "clsx";
import { ComponentChildren } from "preact";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
interface FluidWrapperParams {
className?: string;
breakpoints: {[key: string]: number};
children: ComponentChildren;
}
export function FluidWrapper({className, breakpoints, children}: FluidWrapperParams) {
const ref = useRef<HTMLDivElement>(null);
const sortedBreakpoints = useMemo(() => {
return Object.entries(breakpoints).sort(([, a], [, b]) => a - b)
}, [breakpoints]);
const [activeBreakpoint, setActiveBreakpoint] = useState<string | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const onWidthChanged = (width: number) => {
let match = sortedBreakpoints[0]?.[0] ?? null;
for (const [name, min] of sortedBreakpoints) {
if (width >= min) match = name;
else break;
}
setActiveBreakpoint(match);
};
const observer = new ResizeObserver(([entry]) => onWidthChanged(entry.contentRect.width));
observer.observe(el);
onWidthChanged(el.getBoundingClientRect().width);
return () => observer.disconnect();
}, [sortedBreakpoints]);
return <div ref={ref} className="fluid-container">
<div className={clsx(className, activeBreakpoint)}>
{children}
</div>
</div>
}

View File

@@ -15,7 +15,7 @@ interface CustomTitleBarButton {
export interface ModalProps {
className: string;
title: string | ComponentChildren;
title?: string | ComponentChildren;
customTitleBarButtons?: (CustomTitleBarButton | null)[];
size: "xl" | "lg" | "md" | "sm";
children: ComponentChildren;
@@ -83,9 +83,13 @@ export interface ModalProps {
* spanning the entire height alongside the header, body and footer.
*/
sidebar?: ComponentChildren;
/**
* Indicates if the dialog will be displayed as a full page on mobile devices.
*/
isFullPageOnMobile?: boolean;
}
export default function Modal({ children, className, size, title, customTitleBarButtons: titleBarButtons, header, footer, footerStyle, footerAlignment, onShown, onSubmit, helpPageId, minWidth, maxWidth, zIndex, scrollable, onHidden, modalRef: externalModalRef, formRef, bodyStyle, show, stackable, keepInDom, noFocus, sidebar }: ModalProps) {
export default function Modal({ children, className, size, title, customTitleBarButtons: titleBarButtons, header, footer, footerStyle, footerAlignment, onShown, onSubmit, helpPageId, minWidth, maxWidth, zIndex, scrollable, onHidden, modalRef: externalModalRef, formRef, bodyStyle, show, stackable, keepInDom, noFocus, sidebar, isFullPageOnMobile }: ModalProps) {
const modalRef = useSyncedRef<HTMLDivElement>(externalModalRef);
const modalInstanceRef = useRef<BootstrapModal>();
const elementToFocus = useRef<Element | null>();
@@ -149,7 +153,7 @@ export default function Modal({ children, className, size, title, customTitleBar
return (
<div className={`modal fade mx-auto ${className}`} tabIndex={-1} style={dialogStyle} role="dialog" ref={modalRef}>
{(show || keepInDom) && <div className={`modal-dialog modal-${size} ${scrollable ? "modal-dialog-scrollable" : ""}`} style={documentStyle} role="document">
{(show || keepInDom) && <div className={clsx("modal-dialog", `modal-${size}`, {"modal-dialog-scrollable": scrollable, "modal-dialog-full-page-on-mobile": isFullPageOnMobile, "modal-content-with-sidebar": sidebar})} style={documentStyle} role="document">
<div className={clsx("modal-content", sidebar && "modal-content-with-sidebar")}>
{sidebar && <div className="modal-sidebar">
{title && <div className="modal-sidebar-header">

View File

@@ -0,0 +1,82 @@
:where(.property-sheet) {
--border-radius: 8px;
}
.property-sheet {
dl {
background: var(--card-background-color);
dt {
opacity: .75;
}
dd {
user-select: text;
}
}
}
.property-sheet-container.wide .property-sheet {
display: table;
border-spacing: 0 2px;
border-collapse: separate;
dl {
display: table-row;
--_br: var(--border-radius);
&:first-child {
clip-path: inset(0 round var(--_br) var(--_br) 0 0);
}
&:last-child {
clip-path: inset(0 round 0 0 var(--_br) var(--_br));
}
}
dt, dd {
display: table-cell;
padding: 10px 16px;
vertical-align: top;
}
dt {
white-space: nowrap;
font-weight: normal;
}
dl {
width: 100%;
}
}
.property-sheet-container.narrow .property-sheet {
display: flex;
flex-direction: column;
gap: 2px;
dl {
margin: 0;
padding: 16px 20px;
&:first-child {
border-radius: var(--border-radius) var(--border-radius) 0 0;
}
&:last-child {
border-radius: 0 0 var(--border-radius) var(--border-radius);
}
dt {
margin-bottom: 8px;
font-size: .85em;
font-weight: 550;
text-transform: uppercase;
letter-spacing: .75pt;
}
dd {
margin: 0;
}
}
}

View File

@@ -0,0 +1,28 @@
import { ComponentChildren } from "preact";
import clsx from "clsx";
import "./PropertySheet.css";
import { FluidWrapper } from "./FluidWrapper";
interface PropertySheetParams {
className?: string;
children: ComponentChildren;
wideLayoutBreakpoint?: number;
}
export function PropertySheet({className, children, wideLayoutBreakpoint}: PropertySheetParams) {
return <FluidWrapper
className={clsx("property-sheet-container", className)}
breakpoints={{narrow: 0, wide: wideLayoutBreakpoint || 600}}>
<div className="property-sheet">
{children}
</div>
</FluidWrapper>
}
export function PropertySheetItem({className, label, children}: {className?: string, label: string, children: ComponentChildren}) {
return <dl>
<dt>{label}</dt>
<dd className={className}>{children}</dd>
</dl>
}

35
contributors.json Normal file
View File

@@ -0,0 +1,35 @@
{
"⚠️": "Auto-generated file. Run `pnpm run update-contributors` to regenerate.",
"contributors": [
{
"name": "eliandoran",
"fullName": "Elian Doran",
"url": "https://github.com/eliandoran",
"role": "lead-dev"
},
{
"name": "zadam",
"fullName": "Zadam",
"url": "https://github.com/zadam",
"role": "original-dev"
},
{
"name": "adoriandoran",
"fullName": "Adorian Doran",
"url": "https://github.com/adoriandoran"
},
{
"name": "perfectra1n",
"fullName": "Jon Fuller",
"url": "https://github.com/perfectra1n"
},
{
"name": "JYC333",
"url": "https://github.com/JYC333"
},
{
"name": "Nriver",
"url": "https://github.com/Nriver"
}
]
}

View File

@@ -42,7 +42,8 @@
"dev:linter-check": "cross-env NODE_OPTIONS=--max_old_space_size=4096 eslint .",
"dev:linter-fix": "cross-env NODE_OPTIONS=--max_old_space_size=4096 eslint . --fix",
"postinstall": "tsx scripts/electron-rebuild.mts && pnpm prepare",
"prepare": "pnpm run --filter pdfjs-viewer --filter share-theme build && pnpm run --filter web-clipper postinstall"
"prepare": "pnpm run --filter pdfjs-viewer --filter share-theme build && pnpm run --filter web-clipper postinstall",
"update-contributors": "tsx ./scripts/update-contributors.ts"
},
"private": true,
"devDependencies": {

View File

@@ -8,6 +8,7 @@ export * from "./lib/mime_type.js";
export * from "./lib/bulk_actions.js";
export * from "./lib/server_api.js";
export * from "./lib/shared_constants.js";
export * from "./lib/shared_types.js";
export * from "./lib/ws_api.js";
export * from "./lib/attribute_names.js";
export * from "./lib/utils.js";

View File

@@ -0,0 +1,10 @@
export interface ContributorList {
contributors: Contributor[];
}
export interface Contributor {
name: string;
fullName?: string;
url: string;
role?: "lead-dev" | "original-dev";
}

View File

@@ -0,0 +1,223 @@
import { execSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
interface ContributorEntry {
name: string;
fullName?: string;
url: string;
role?: string;
}
interface ContributorFile {
contributors: ContributorEntry[];
}
interface ContributorInfo {
name: string;
fullName?: string;
email?: string;
commitCount: number;
url?: string;
}
interface ShowTableParams {
title: string;
comment?: string;
contributors: ContributorInfo[];
columns: (keyof ContributorInfo)[];
}
const TRANSLATION_PATHS = [
"apps/client/src/translations/",
"apps/server/src/assets/translations/"
];
/** Authors that are bots or automated tools, not real contributors. */
const EXCLUDED_AUTHORS = new Set([
"Languages add-on",
"Hosted Weblate",
"renovate[bot]"
]);
const NOREPLY_PATTERN = /^(?:\d+\+)?(.+)@users\.noreply\.github\.com$/;
/**
* Manual mapping for contributors whose git email doesn't reveal their
* GitHub username (i.e. no noreply email in .mailmap).
*/
const EMAIL_TO_GITHUB: Record<string, string> = {
"contact@eliandoran.me": "eliandoran",
"zadam.apps@gmail.com": "zadam",
"adorian@esevo.ro": "adoriandoran",
"jonfuller2012@gmail.com": "perfectra1n",
};
const CONTRIBUTORS_PATH = join(__dirname, "..", "contributors.json");
/**
* Resolves a GitHub username from an email address.
*
* 1. Checks the manual mapping.
* 2. Extracts from GitHub noreply emails (e.g. "12345+user@…").
* 3. Scans .mailmap for alternate emails that match the noreply pattern.
*/
function resolveGitHub(email: string, name: string): string | undefined {
if (EMAIL_TO_GITHUB[email]) return EMAIL_TO_GITHUB[email];
const noreply = email.match(NOREPLY_PATTERN);
if (noreply) return noreply[1];
// Grep .mailmap for alternate emails that match the noreply pattern
try {
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const mailmapContent = execSync(`grep -i "${escapedName}" .mailmap 2>/dev/null`).toString();
for (const line of mailmapContent.split("\n")) {
// Extract all emails from the line (inside angle brackets)
for (const [, email] of line.matchAll(/<([^>]+)>/g)) {
const match = email.match(NOREPLY_PATTERN);
if (match) return match[1];
}
}
} catch { /* no matches */ }
return undefined;
}
function parseShortlog(rawOutput: string): Map<string, { email: string; commitCount: number }> {
const result = new Map<string, { email: string; commitCount: number }>();
for (const line of rawOutput.split("\n")) {
const match = line.match(/^\s*(\d+)\s+(.+?)\s+<(.+)>$/);
if (match) {
result.set(match[2], { email: match[3], commitCount: parseInt(match[1]) });
}
}
return result;
}
async function main() {
const { developers } = listLocalGitContributors();
await listGitHubContributors();
updateContributorsJson(developers);
}
function listLocalGitContributors() {
const allOutput = execSync("git shortlog -sne --no-merges HEAD -- src/ apps/").toString();
const translationOutput = execSync(`git shortlog -sne --no-merges HEAD -- ${TRANSLATION_PATHS.join(" ")}`).toString();
const allContribs = parseShortlog(allOutput);
const translationContribs = parseShortlog(translationOutput);
const developers: ContributorInfo[] = [];
const translators: ContributorInfo[] = [];
const MIN_COMMITS = 100;
for (const [name, { email, commitCount }] of allContribs) {
if (EXCLUDED_AUTHORS.has(name)) continue;
const translationCommitCount = translationContribs.get(name)?.commitCount ?? 0;
const isTranslator = translationCommitCount > commitCount * 0.5;
const githubUsername = resolveGitHub(email, name);
const url = githubUsername ? `https://github.com/${githubUsername}` : undefined;
const entry: ContributorInfo = { name, email, commitCount, url };
if (isTranslator) {
if (commitCount >= 20) translators.push(entry);
} else if (commitCount >= MIN_COMMITS) {
developers.push(entry);
}
}
// showTable({
// title: "Local Git Contributors (Developers)",
// columns: ["name", "url", "commitCount"],
// contributors: developers
// });
// showTable({
// title: "Local Git Contributors (Translators)",
// comment: "Contributors where >50% of commits are to translation files.",
// columns: ["name", "url", "commitCount"],
// contributors: translators
// });
return { developers, translators };
}
async function listGitHubContributors() {
let list: any[] | null = null;
const response = await fetch("https://api.github.com/repos/TriliumNext/Trilium/contributors");
if (response.ok) {
list = await response.json();
} else {
console.error(`Unable to request the contributor list from GitHub. Reason: ${response.statusText}`);
}
if (!list) {
return;
}
const MIN_CONTRIBUTIONS = 125;
const contributors: ContributorInfo[] = list
.filter((c) => c.contributions >= MIN_CONTRIBUTIONS)
.map((c) => {
return {
name: c.login,
url: c.html_url,
commitCount: c.contributions
} as ContributorInfo;
});
// showTable({
// title: "GitHub Contributor List",
// comment: "Note: the GitHub list also include contributors that did not directly contribute to Trilium, but to submodules used in the Trilium's repo.",
// contributors: contributors,
// columns: ["name", "url", "commitCount"]
// });
}
/**
* Updates contributors.json, preserving pinned entries (those with special
* roles like lead-dev, original-dev) and regenerating the rest from git data.
*/
function updateContributorsJson(developers: ContributorInfo[]) {
// Read existing file to preserve pinned entries
const existing: ContributorFile = JSON.parse(readFileSync(CONTRIBUTORS_PATH, "utf-8"));
const pinnedRoles = new Set<string>(["lead-dev", "original-dev"]);
const pinned = existing.contributors.filter((c) => c.role && pinnedRoles.has(c.role));
// Build a set of pinned GitHub usernames to avoid duplicates
const pinnedNames = new Set(pinned.map((c) => c.name));
const contributors: ContributorEntry[] = [...pinned];
// Add developers (skip those already pinned)
for (const dev of developers) {
const githubName = dev.url?.replace("https://github.com/", "");
if (!githubName || pinnedNames.has(githubName)) continue;
contributors.push({
name: githubName,
fullName: dev.name !== githubName ? dev.name : undefined,
url: dev.url!
});
}
const output = {
"⚠️": "Auto-generated file. Run `pnpm run update-contributors` to regenerate.",
contributors
};
writeFileSync(CONTRIBUTORS_PATH, JSON.stringify(output, null, 4) + "\n");
console.log(`\n✅ Updated ${CONTRIBUTORS_PATH} with ${contributors.length} contributors.`);
}
function showTable(params: ShowTableParams) {
console.log(`\n──── ${params.title} ────`);
if (params.comment) {
console.log(`\n${params.comment}\n`);
}
console.table(params.contributors, params.columns);
}
main();