diff --git a/assets/css/navbar.scss b/assets/css/navbar.scss
index 874f18d..db91a5a 100644
--- a/assets/css/navbar.scss
+++ b/assets/css/navbar.scss
@@ -10,6 +10,7 @@
padding-left: 33.5833px;
margin: auto;
}
+
.icon {
padding-left: 0;
margin: 0;
@@ -54,39 +55,62 @@
margin-right: 0 !important;
}
- .nav-item {
- margin: 0 .1em;
-
+}
- }
+#regionDropdown {
+ color: var(--text-color);
+ padding: 0;
+ border: none;
+ background: none;
+ height: 2rem;
.fi-globe:before {
font-family: 'Material Symbols Outlined';
content: '\e894';
font-size: 1.3em;
}
+}
- #regionDropdown {
+#regionsDropdown {
+ position: absolute;
+ margin: 12px 0 0;
+ padding: 1em;
+ background: var(--secondary-background);
+ inset: auto;
+ right: 0;
+ display: grid;
+ border: none;
+ grid-template-columns: 1fr 1fr;
+ grid-row-gap: .3em;
+ grid-column-gap: 1em;
+ z-index: 2;
+
+ a {
+ text-decoration: none;
color: var(--text-color);
- }
+ padding: .5em;
- .dropdown-menu {
- right: 0;
- left: auto;
- background: var(--secondary-background);
- column-count: 2;
- z-index: 1100;
- @media screen and (max-width: 445px) {
- left: 0 !important;
+ &:hover {
+ background: var(--text-color);
+ color: var(--background)
}
- }
- .dropdown-item {
- color: var(--bs-nav-link-color);
+ span {
+ margin-right: 0.3em;
+ // Square flags should still be centered (like 4x3 ones)
+ &.fis {
+ width: 1.33333333em !important;
+ }
+ }
}
}
@media screen and (max-width: 992px) {
+ #regionsDropdown {
+ left: 0;
+ right: 0;
+ }
+
.back-to-top-link {
$back-to-top-size: 3rem;
@@ -108,7 +132,7 @@
color: var(--primary);
}
- &-moved{
+ &-moved {
bottom: -$back-to-top-size;
}
}
@@ -119,15 +143,3 @@
display: none;
}
}
-
-// Square flags should still be centered (like 4x3 ones)
-.fis {
- width: 1.33333333em !important;
-}
-
-.dropdown-item {
- // Add space between flag and name
- .fi {
- margin-right: .3em;
- }
-}
diff --git a/assets/css/table.scss b/assets/css/table.scss
index b96ada6..e070c4b 100644
--- a/assets/css/table.scss
+++ b/assets/css/table.scss
@@ -5,7 +5,7 @@
.table-head {
display: grid;
- grid-template-columns: 20em repeat(6, 1fr);
+ grid-template-columns: 20em repeat(5, 1fr);
grid-template-rows: 1fr;
position: sticky;
top: 0;
@@ -15,7 +15,7 @@
font-weight: 500;
color: var(--text-color);
line-height: 53px;
-
+ z-index: 100;
div {
text-align: center;
height: 53px;
@@ -40,12 +40,12 @@
}
/* replace with :has(.tfa) when supported */
- &.green {
- grid-template-columns: 20em repeat(6, 1fr);
+ &:has(.tfa-summary) {
+ grid-template-columns: 20em repeat(5, 1fr);
}
/* replace with :has(.contact) when supported */
- &.red {
+ &:has(.contact) {
grid-template-columns: 20em 1fr;
line-height: 4.4rem;
}
@@ -62,54 +62,35 @@
display: grid;
grid-auto-flow: column;
justify-content: space-between;
- :last-child {
- padding-right: .5em;
- }
+
+
}
.name {
grid-area: 1 / 1 / 2 / 2;
line-height: 4.4rem;
- padding: 0 ;
- }
-
- .note {
- font-size: 2em;
- margin: auto;
- color: #db2828;
- justify-self: end;
+ padding: 0;
}
.docs {
- grid-area: 1 / 2 / 2 / 3;
display: flex;
flex-wrap: wrap;
+ font-family: 'Material Symbols Outlined';
+ font-size: 2em;
+ vertical-align: middle;
- .website-doc {
+ * {
display: block;
text-decoration: none;
margin: auto;
-
- &:after {
- content: '\E873';
- font-family: 'Material Symbols Outlined';
- font-size: 2em;
- vertical-align: middle;
- }
}
- .recovery-doc {
- display: block;
- text-decoration: none;
- margin: auto;
+ .website-doc::after {
+ content: '\E873';
+ }
- &:after {
- content: '\E929';
- font-family: 'Material Symbols Outlined';
- font-size: 2em;
- font-weight: 600;
- vertical-align: middle;
- }
+ .recovery-doc::after {
+ content: '\E929';
}
}
@@ -118,48 +99,22 @@
flex-wrap: wrap;
font-size: 27px;
line-height: 53px;
+ font-family: 'Material Symbols Outlined';
* {
margin: auto;
- display: block;
- }
-
- &.used {
- &:before {
- content: '\E5CA';
- font-family: 'Material Symbols Outlined';
- font-size: 1.5em;
- margin: auto;
- display: block;
- font-weight: 600;
- }
- }
-
- &.sms {
- grid-area: 1 / 3 / 2 / 4;
- }
-
- &.voice {
- grid-area: 1 / 4 / 2 / 5;
- }
-
- &.email {
- grid-area: 1 / 5 / 2 / 6;
- }
-
- &.hardware {
- grid-area: 1 / 6 / 2 / 7;
}
- &.software {
- grid-area: 1 / 7 / 2 / 8;
+ &.used:before {
+ content: '\E5CA';
+ font-size: 1.5em;
+ margin: auto;
+ display: block;
+ font-weight: 600;
}
- .icon-info {
- &:before{
- content: '\E88E';
- font-family: 'Material Symbols Outlined';
- }
+ .icon-info:before {
+ content: '\E88E';
}
}
@@ -173,6 +128,7 @@
button {
margin-left: .5em;
+
&:before {
margin-right: .5em;
}
@@ -201,14 +157,30 @@
.tfa-summary {
position: absolute;
- top:-1000px;
- left:-1000px;
+ top: -1000px;
+ left: -1000px;
}
}
}
-.contact {
+/** Universal styles **/
+
+.note {
+ all: unset;
+ cursor: pointer;
+ font-size: 2em;
+ color: #db2828;
+ justify-self: end;
+ margin: auto .5em auto auto;
+ span {
+ font-size: 1em;
+ }
+ &.open {
+ anchor-name: --note;
+ }
+}
+.contact {
button {
width: 140px;
margin: .2em;
@@ -219,7 +191,6 @@
&:before {
vertical-align: middle;
-
content: '';
}
@@ -245,7 +216,7 @@
}
}
- &.email,&.form {
+ &.email, &.form {
background: #ea4235;
&:before {
@@ -285,13 +256,12 @@
}
}
-/** Universal styles **/
.entry {
- &.green {
+ &:has(.tfa-summary) {
background: var(--table-bg-green);
}
- &.red {
+ &:has(.contact) {
background: var(--table-bg-red);
}
}
@@ -324,14 +294,14 @@
.note {
font-size: 3em;
color: #D03333;
- justify-self: right;
- }
+ height: min-content;
+ }
& > .sms, & > .voice, & > .email, & > .hardware, & > .software {
display: none;
}
- &.green {
+ &:has(.tfa-summary) {
.tfa-summary {
grid-area: 2 / 1 / 3 / 2;
list-style: none;
@@ -371,7 +341,9 @@
}
.website-doc {
-
+ vertical-align: middle;
+ font-family: var(--default-font);
+ font-weight: 300;
&:before {
content: '\E873';
font-family: 'Material Symbols Outlined';
@@ -406,7 +378,7 @@
}
}
- &.red {
+ &:has(.contact) {
.tfa-summary:before {
display: block;
content: '2FA Not Supported';
@@ -435,13 +407,15 @@
line-height: 0;
font-size: 2em;
height: 48px;
+
&:before {
- display:block;
+ display: block;
}
}
.facebook, .twitter {
width: calc(100% - 0.5em);
+
&:before {
padding: 1em;
width: calc(100% - 0.5em);
@@ -451,3 +425,95 @@
}
}
}
+
+.note span {
+ user-select: none;
+ font-size: 1em;
+ vertical-align: middle;
+}
+
+.note-popover {
+ border: 1px solid rgba(0, 0, 0, .35);
+ box-shadow: 0 5px 15px rgba(0, 0, 0, .35);
+ border-radius: 12px;
+ background: var(--background) !important;
+ width: 18rem;
+ padding: 0 !important;
+ line-height: 1.4em;
+ top: 4px;
+ overflow: visible;
+ z-index: 1;
+
+ h5 {
+ text-align: center;
+ background: var(--secondary-background);
+ padding: .4em;
+ border-bottom: 1px solid #bbb;
+ border-radius: 12px 12px 0 0;
+ font-size: 1.2em;
+ color: var(--text-color);
+ }
+
+ p {
+ padding: .6em;
+ font-size: .9em;
+ color: var(--text-color);
+ }
+
+ svg {
+ fill: var(--background);
+ }
+}
+
+.custom-hardware, .custom-software {
+ all: unset;
+ border: none;
+ background: none;
+ cursor: pointer;
+ span {
+ user-select: none;
+ font-size: 1.2em;
+ display: block;
+ }
+}
+
+.custom-popover {
+ background: var(--background) !important;
+ color: var(--text-color);
+ border: 1px solid #ccc;
+ padding: 0 !important;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, .1);
+ border-radius: 12px;
+ border-bottom: 1px solid #bbb;
+ overflow: visible !important;
+ font-family: var(--default-font);
+ font-size: .9rem;
+
+ h5 {
+ font-size: 1.2em;
+ text-align: center;
+ background: var(--secondary-background);
+ padding: .5em;
+ border-top-left-radius: 12px;
+ border-top-right-radius: 12px;
+ }
+
+ li {
+ padding: 0 .6em;
+ border-top: none;
+
+ & + li {
+ border-top: 1px solid #ddd;
+ }
+ }
+
+ ul {
+ list-style-type: none;
+ padding: 0;
+ margin: 0;
+ }
+
+ svg {
+ fill: var(--background) !important;
+ }
+}
diff --git a/index.html b/index.html
index 6c5994c..ad7c950 100644
--- a/index.html
+++ b/index.html
@@ -7,7 +7,7 @@
- Categories
+
diff --git a/lang/en.json b/lang/en.json
new file mode 100644
index 0000000..b6a59db
--- /dev/null
+++ b/lang/en.json
@@ -0,0 +1,56 @@
+{
+ "search-placeholder": "Search websites by name, URL or method (e.g. 2fa:sms)",
+ "categories": "Categories",
+ "backup": "Backup and Sync",
+ "banking": "Banking",
+ "betting": "Betting",
+ "cloud": "Cloud Computing",
+ "communication": "Communication",
+ "creativity": "Creativity",
+ "crowdfunding": "Crowdfunding",
+ "cryptocurrencies": "Cryptocurrencies",
+ "developer": "Developer",
+ "domains": "Domains",
+ "education": "Edu7cation",
+ "email": "Email",
+ "entertainment": "Entertainment",
+ "finance": "Finance",
+ "food": "Food",
+ "gaming": "Gaming",
+ "government": "Government",
+ "health": "Health",
+ "hosting": "Hosting/VPS",
+ "hotels": "Hotels and Accommodations",
+ "identity": "Identity Management",
+ "insurance": "Insurance",
+ "investing": "Investing",
+ "iot": "IoT",
+ "legal": "Legal & Compliance",
+ "marketing": "Marketing & Analytics",
+ "payments": "Payments",
+ "postal": "Post and Shipping",
+ "remote": "Remote Access",
+ "retail": "Retail",
+ "security": "Security",
+ "social": "Social",
+ "task": "Task Management",
+ "tickets": "Tickets and Events",
+ "transport": "Transport",
+ "universities": "Universities",
+ "utilities": "Utilities",
+ "vpn": "VPN",
+ "other": "Other",
+ "docs": "Docs",
+ "sms": "SMS/Calls",
+ "hardware": "Hardware",
+ "software": "Software",
+ "custom-hardware": "Custom Hardware",
+ "custom-software": "Custom Software",
+ "attention": "Attention!",
+ "social-media-warning": "Posting to social media could potentially give other people clues to what accounts you have.",
+ "accept-risk": "I accept the risk",
+ "go-back": "Go back",
+ "exception": "Exceptions & Restrictions",
+ "u2f": "Passkeys",
+ "totp": "TOTP"
+}
diff --git a/lang/es.json b/lang/es.json
new file mode 100644
index 0000000..e749e9c
--- /dev/null
+++ b/lang/es.json
@@ -0,0 +1,55 @@
+{
+ "search-placeholder": "Busca sitios web por nombre o URL",
+ "categories": "Categorías",
+ "backup": "Respaldo",
+ "banking": "Banca",
+ "betting": "Apuestas",
+ "cloud": "Nube",
+ "communication": "Comunicación",
+ "creativity": "Creatividad",
+ "crowdfunding": "Financiación colectiva",
+ "cryptocurrencies": "Criptomonedas",
+ "developer": "Desarrollador",
+ "domains": "Dominios",
+ "education": "Educación",
+ "email": "Correo",
+ "entertainment": "Entretenimiento",
+ "finance": "Finanzas",
+ "food": "Comida",
+ "gaming": "Videojuegos",
+ "government": "Gobierno",
+ "health": "Salud",
+ "hosting": "Alojamiento web",
+ "hotels": "Hoteles y alojamientos",
+ "identity": "ID y clave",
+ "insurance": "Seguros",
+ "investing": "Inversiones",
+ "iot": "IoT",
+ "legal": "Legal y cumplimiento",
+ "marketing": "Marketing y análisis",
+ "payments": "Pagos",
+ "postal": "Correo y envíos",
+ "remote": "Acceso remoto",
+ "retail": "Compras",
+ "security": "Seguridad",
+ "social": "Redes sociales",
+ "task": "Gestión de tareas",
+ "tickets": "Eventos",
+ "transport": "Transporte",
+ "universities": "Universidades",
+ "utilities": "Servicios públicos",
+ "vpn": "VPN",
+ "other": "Otros",
+ "docs": "Documentación",
+ "sms": "SMS/Llamadas",
+ "hardware": "Físico",
+ "software": "App",
+ "custom-hardware": "Físico personalizado",
+ "custom-software": "App personalizada",
+ "attention": "¡Atención!",
+ "social-media-warning": "Publicar en redes sociales podría dar pistas a otros sobre qué cuentas tienes.",
+ "accept-risk": "Acepto el riesgo",
+ "go-back": "Volver",
+ "u2f": "Passkeys",
+ "totp": "TOTP"
+}
diff --git a/lang/fr.json b/lang/fr.json
new file mode 100644
index 0000000..537b9b6
--- /dev/null
+++ b/lang/fr.json
@@ -0,0 +1,55 @@
+{
+ "search-placeholder": "Recherchez des sites par nom, URL ou méthode (par ex. 2fa:sms)",
+ "categories": "Catégories",
+ "backup": "Sauvegarde et synchronisation",
+ "banking": "Banque",
+ "betting": "Paris",
+ "cloud": "Informatique en nuage",
+ "communication": "Communication",
+ "creativity": "Créativité",
+ "crowdfunding": "Financement participatif",
+ "cryptocurrencies": "Cryptomonnaies",
+ "developer": "Développeur",
+ "domains": "Domaines",
+ "education": "Éducation",
+ "email": "E-mail",
+ "entertainment": "Divertissement",
+ "finance": "Finance",
+ "food": "Nourriture",
+ "gaming": "Jeux",
+ "government": "Gouvernement",
+ "health": "Santé",
+ "hosting": "Hébergement/VPS",
+ "hotels": "Hôtels et hébergements",
+ "identity": "Gestion d'identité",
+ "insurance": "Assurance",
+ "investing": "Investissement",
+ "iot": "IoT",
+ "legal": "Juridique",
+ "marketing": "Marketing & Analytique",
+ "payments": "Paiements",
+ "postal": "Poste et expédition",
+ "remote": "Accès à distance",
+ "retail": "Commerce de détail",
+ "security": "Sécurité",
+ "social": "Réseaux sociaux",
+ "task": "Gestion des tâches",
+ "tickets": "Billets et événements",
+ "transport": "Transport",
+ "universities": "Universités",
+ "utilities": "Services publics",
+ "vpn": "VPN",
+ "other": "Autre",
+ "docs": "Documentation",
+ "sms": "SMS/Appels",
+ "hardware": "Matériel",
+ "software": "Logiciel",
+ "custom-hardware": "Matériel personnalisé",
+ "custom-software": "Logiciel personnalisé",
+ "attention": "Attention!",
+ "social-media-warning": "Publier sur les réseaux sociaux pourrait potentiellement donner à d'autres des indices sur vos comptes.",
+ "accept-risk": "J'accepte le risque",
+ "go-back": "Retour",
+ "u2f": "Passkeys",
+ "totp": "TOTP"
+}
diff --git a/lang/sv.json b/lang/sv.json
new file mode 100644
index 0000000..d17ce51
--- /dev/null
+++ b/lang/sv.json
@@ -0,0 +1,60 @@
+{
+ "search-placeholder": "Sök tjänster med namn, address eller 2FA metod (e.g. 2fa:sms)",
+ "categories": "Kategorier",
+ "backup": "Backup och Synk",
+ "banking": "Banker",
+ "betting": "Betting",
+ "cloud": "Molntjänster",
+ "communication": "Kommunikation",
+ "creativity": "Kreativitet",
+ "crowdfunding": "Crowdfunding",
+ "cryptocurrencies": "Cryptovalutor",
+ "developer": "Utveckling",
+ "domains": "Domäner",
+ "education": "Skola & Utbildning",
+ "email": "E-post",
+ "entertainment": "Underhållning",
+ "finance": "Finans",
+ "food": "Mat",
+ "gaming": "Spel",
+ "government": "Myndigheter",
+ "health": "Hälsa",
+ "hosting": "Hosting/VPS",
+ "hotels": "Hotell & Boende",
+ "identity": "ID och Lösenord",
+ "insurance": "Försäkring",
+ "investing": "Investering",
+ "iot": "IoT",
+ "legal": "Juridiska tjänster",
+ "marketing": "Reklam & Analys",
+ "payments": "Betalning",
+ "postal": "Post och Frakt",
+ "remote": "Fjärrstyrning",
+ "retail": "Handel",
+ "security": "Säkerhet",
+ "social": "Sociala medier",
+ "task": "Planering",
+ "tickets": "Evenemang",
+ "transport": "Transport",
+ "universities": "Universitet",
+ "utilities": "Hushållstjänster",
+ "vpn": "VPN",
+ "other": "Annat",
+ "docs": "Artiklar",
+ "sms": "SMS/Samtal",
+ "call": "Telefonsamtal",
+ "u2f": "Passkeys",
+ "totp": "TOTP",
+ "hardware": "Hårdvara",
+ "software": "Appar",
+ "custom-hardware": "Särskild MFA-hårdvara",
+ "custom-software": "Särskild MFA-app",
+ "recovery": "Återställning",
+ "documentation": "Dokumentation",
+ "attention": "OBS!",
+ "social-media-warning": "Att lägga upp på sociala medier kan potentiellt ge andra ledtrådar om vilka konton du har.",
+ "accept-risk": "Jag förstår",
+ "go-back": "Avbryt",
+ "u2f": "Passkeys",
+ "totp": "TOTP"
+}
diff --git a/package-lock.json b/package-lock.json
index 8390d87..eb89fa2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "6.0.0",
"license": "GPL-3.0",
"dependencies": {
+ "@floating-ui/react": "^0.26.28",
"@popperjs/core": "^2.11.8",
"@preact/signals": "^1.3.0",
"algoliasearch": "4.14.2",
@@ -492,6 +493,59 @@
"node": ">=12"
}
},
+ "node_modules/@floating-ui/core": {
+ "version": "1.6.8",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz",
+ "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.8"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.6.12",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.12.tgz",
+ "integrity": "sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/core": "^1.6.0",
+ "@floating-ui/utils": "^0.2.8"
+ }
+ },
+ "node_modules/@floating-ui/react": {
+ "version": "0.26.28",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
+ "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/react-dom": "^2.1.2",
+ "@floating-ui/utils": "^0.2.8",
+ "tabbable": "^6.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@floating-ui/react-dom": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz",
+ "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/dom": "^1.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.8",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz",
+ "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==",
+ "license": "MIT"
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
@@ -1651,7 +1705,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -1724,6 +1777,19 @@
"uc.micro": "^2.0.0"
}
},
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
"node_modules/lower-case": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
@@ -2041,6 +2107,33 @@
],
"license": "MIT"
},
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
"node_modules/readdirp": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz",
@@ -2159,6 +2252,16 @@
"@parcel/watcher": "^2.4.1"
}
},
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -2240,6 +2343,12 @@
"node": ">=8"
}
},
+ "node_modules/tabbable": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
+ "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
+ "license": "MIT"
+ },
"node_modules/terser": {
"version": "5.36.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.36.0.tgz",
diff --git a/package.json b/package.json
index bd8858e..7bd2ae2 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,7 @@
"preview": "vite preview"
},
"dependencies": {
- "@popperjs/core": "^2.11.8",
+ "@floating-ui/react": "^0.26.28",
"@preact/signals": "^1.3.0",
"algoliasearch": "4.14.2",
"bootstrap": "5.2.2",
diff --git a/src/components/category.js b/src/components/category.js
index 4f9629a..1c0245f 100644
--- a/src/components/category.js
+++ b/src/components/category.js
@@ -2,6 +2,7 @@ import {html} from 'htm/preact';
import {Component, render} from 'preact';
import {API_URL} from '../constants.js';
import Table from './table.jsx';
+import useTranslation from '../hooks/useTranslation';
class Categories extends Component {
constructor(props) {
@@ -26,7 +27,7 @@ class Categories extends Component {
}).catch((err) => this.setState({error: err}));
}
- componentWillMount() {
+ componentDidMount() {
// Set initial hash and columns
this.handleHashChange();
this.handleResize();
@@ -96,9 +97,12 @@ class Button extends Component {
setSelectedCategory(name);
};
+
+
render(props) {
const {name, category, activeCategory} = props;
const isActive = activeCategory === name;
+ const t = useTranslation();
return html`
- ${category?.title}
+ ${t(name)}
`;
}
diff --git a/src/components/dialog.js b/src/components/dialog.js
index 4a33367..afab7cc 100644
--- a/src/components/dialog.js
+++ b/src/components/dialog.js
@@ -1,8 +1,11 @@
import {render} from 'preact';
import {html} from 'htm/preact';
import {useEffect} from 'preact/hooks';
+import useTranslation from '../hooks/useTranslation.js';
function Dialog() {
+ const t = useTranslation();
+
useEffect(() => {
document.getElementById("social-media-accept").addEventListener("click", () => {
window.localStorage.setItem('social-media-notice', 'hidden');
@@ -10,16 +13,16 @@ function Dialog() {
document.getElementById('social-media-accept').getAttribute('data-url'), "_blank"
);
});
+ document.getElementById('categories-title').innerText = t('categories') || 'Categories';
}, []);
return html`
-
Attention!
- Posting to social media could potentially give other people clues to what
- accounts you have.
+ ${t('attention')}
+ ${t('social-media-warning')}
`;
diff --git a/src/components/regions.js b/src/components/regions.js
index 8349e56..04f009a 100644
--- a/src/components/regions.js
+++ b/src/components/regions.js
@@ -6,28 +6,16 @@ export default class Regions extends Component {
constructor() {
super();
this.state = {
- regions: {},
currentRegion: '',
+ open: false,
};
+ this.loadDropdown = this.loadDropdown.bind(this);
}
componentDidMount() {
- this.fetchRegions();
this.setCurrentRegion();
}
- // Fetch regions and set them to state
- async fetchRegions() {
- try {
- const res = await fetch(`${API_URL}/regions.json`,
- {cache: 'force-cache'});
- const data = await res.json();
- this.setState({regions: data || {}});
- } catch (error) {
- console.error('Error fetching regions:', error);
- }
- }
-
// Determine the current region based on URL
setCurrentRegion() {
const region = window.location.pathname.replace(/\//g, '');
@@ -38,41 +26,75 @@ export default class Regions extends Component {
});
}
- render(_, {regions, currentRegion}) {
+ loadDropdown = () => {
+ this.setState((prevState) => ({open: !prevState.open}));
+ };
+
+ render(_, {currentRegion, open}) {
return html`
+ `;
+ }
+}
+
+class Dropdown extends Component {
+ constructor() {
+ super();
+ this.state = {
+ regions: {},
+ };
+ }
+
+ componentDidMount() {
+ this.fetchRegions();
+ }
+
+ // Fetch regions and set them to state
+ async fetchRegions() {
+ try {
+ const res = await fetch(`${API_URL}/regions.json`,
+ {cache: 'force-cache'});
+ const data = await res.json();
+ this.setState({regions: data || {}});
+ } catch (error) {
+ console.error('Error fetching regions:', error);
+ }
+ }
+
+ render(_, {regions}) {
+ const regionKeys = Object.keys(regions).sort((a, b) => regions[a].name.localeCompare(regions[b].name));
+
+ return html`
+
- `;
+ `)}
+ `;
}
}
diff --git a/src/components/search.js b/src/components/search.js
index 3188058..88991af 100644
--- a/src/components/search.js
+++ b/src/components/search.js
@@ -1,8 +1,9 @@
-import { html } from "htm/preact";
-import { render } from "preact";
-import { useEffect, useState } from "preact/hooks";
-import algoliasearch from "algoliasearch";
-import Table from "./table";
+import { html } from 'htm/preact';
+import { render } from 'preact';
+import { useEffect, useState, useRef } from 'preact/hooks';
+import algoliasearch from 'algoliasearch';
+import Table from './table';
+import useTranslation from '../hooks/useTranslation.js';
const client = algoliasearch(
import.meta.env.VITE_ALGOLIA_APP_ID,
@@ -116,45 +117,45 @@ function sendSearch(query) {
}
function Search() {
- const [query, setQuery] = useState("");
- let timeout = null;
+ const [query, setQuery] = useState('');
+ const timeout = useRef(null);
+ const t = useTranslation();
useEffect(() => {
- const searchParams = new URLSearchParams(window.location.search);
- if (searchParams.has("q")) {
- const query = searchParams.get("q");
- setQuery(query);
- sendSearch(query);
- }
+ const fetchInitialQuery = async () => {
+ const searchParams = new URLSearchParams(window.location.search);
+ if (searchParams.has('q')) {
+ const query = searchParams.get('q');
+ setQuery(query);
+ sendSearch(query);
+ }
+ };
+ fetchInitialQuery();
}, []);
- /**
- * Search and update query parameter without reloading the page
- *
- * @param {string} query - The query
- */
const search = (query) => {
sendSearch(query);
if (query) {
- // Source: https://stackoverflow.com/a/70591485
const url = new URL(window.location.href);
- url.searchParams.set("q", query);
- window.history.pushState(null, "", url.toString());
- } else window.history.pushState(null, "", window.location.pathname);
+ url.searchParams.set('q', query);
+ window.history.pushState(null, '', url.toString());
+ } else {
+ window.history.pushState(null, '', window.location.pathname);
+ }
};
return html`
{
- if (timeout) clearTimeout(timeout);
- timeout = setTimeout(() => search(event.target.value), 1000);
+ if (timeout.current) clearTimeout(timeout.current);
+ timeout.current = setTimeout(() => search(event.target.value), 1000);
}}
value=${query}
/>
@@ -163,4 +164,4 @@ function Search() {
`;
}
-render(html`<${Search} />`, document.getElementById("search"));
+render(html`<${Search} />`, document.getElementById('search'));
diff --git a/src/components/style.directory.js b/src/components/style.directory.js
index 5042805..0da4368 100644
--- a/src/components/style.directory.js
+++ b/src/components/style.directory.js
@@ -1,3 +1,4 @@
import "/assets/css/search.scss";
import "/assets/css/category-buttons.scss";
import "/assets/css/table.scss";
+
diff --git a/src/components/table.jsx b/src/components/table.jsx
index 8ffcefb..1283f83 100644
--- a/src/components/table.jsx
+++ b/src/components/table.jsx
@@ -1,142 +1,187 @@
-import { useEffect, useState } from 'preact/hooks';
-import { Popover } from 'bootstrap';
-import { API_URL, IMG_PATH } from '../constants.js';
-
-const method_names = {
- 'sms': 'SMS',
- 'email': 'Email',
- 'call': 'Phone Calls',
- 'totp': 'TOTP',
- 'u2f': 'Passkeys',
-};
-
-function Table({ Category, Title, search, grid }) {
+import {useEffect, useRef, useState} from 'preact/hooks';
+import {API_URL, IMG_PATH} from '../constants.js';
+import useTranslation from '../hooks/useTranslation.js';
+import {html} from 'htm/preact';
+import {
+ FloatingArrow,
+ arrow,
+ useFloating,
+ useInteractions,
+ useClick,
+ useDismiss,
+ autoPlacement,
+} from '@floating-ui/react';
+
+function Table({Category, search, grid}) {
const [entries, setEntries] = useState([]);
const [region, setRegion] = useState('');
useEffect(() => {
- if (!search) {
+ if (search) {
+ setEntries(search);
+ } else {
setRegion(window.location.pathname.slice(1, -1));
const region = window.location.pathname.slice(1);
fetch(`${API_URL}/${region || 'int/'}${Category}.json`,
- { cache: 'force-cache' }).
+ {cache: 'force-cache'}).
then(res => res.json()).
then(data => setEntries(Object.entries(data) || [])).
catch(err => console.error('Error fetching categories:', err)); // Add error handling
- } else setEntries(search);
- // Scroll to category button
- if (!search) {
+ // Scroll to category button
window.location.hash = `#${Category}`;
document.getElementById(Category)?.
- scrollIntoView({ behavior: 'smooth', block: 'start' });
+ scrollIntoView({behavior: 'smooth', block: 'start'});
}
}, []);
return (
-
-
-
{Title}
-
Docs
-
SMS
-
Phone Calls
-
Email
-
Hardware
-
Software
-
+
+
+
+
{entries.map(([name, data]) => (
-
+
))}
);
}
-function Entry({ name, data }) {
- const color = data?.methods !== undefined ? 'green' : 'red';
+function Entry({name, data}) {
return (
-
+
-
-
+
+
{name}
- {data.notes &&
emergency_home }
+ {data.notes &&
}
- {color === 'green' ?
+ {data?.methods ?
<>
-
- > :
-
+
+ >:
+
}
-
+
);
}
-const mfaPopoverConfig = {
- html: true,
- sanitize: false,
- trigger: "hover focus"
-};
+function Note({content}) {
+ const t = useTranslation();
+ const [isOpen, setIsOpen] = useState(false);
+ const arrowRef = useRef(null);
-function Methods({ methods, customSoftware, customHardware }) {
- useEffect(() => {
- [...document.querySelectorAll('.note')].map((el) => new Popover(el, {
- trigger: 'hover focus',
- title: 'Exceptions & Restrictions'
- }));
+ // Initialize floating UI
+ const {refs, context, floatingStyles} = useFloating({
+ open: isOpen, onOpenChange: setIsOpen, middleware: [
+ autoPlacement(), arrow({element: arrowRef})],
+ });
+ // Set up interactions
+ const click = useClick(context);
+ const dismiss = useDismiss(context);
- [...document.querySelectorAll('.custom-hardware-popover')].map((el) => new Popover(el, {
- ...mfaPopoverConfig,
- title: 'Custom Hardware 2FA'
- }));
+ const {getReferenceProps, getFloatingProps} = useInteractions([click, dismiss]);
- [...document.querySelectorAll('.custom-software-popover')].map((el) => new Popover(el, {
- ...mfaPopoverConfig,
- title: 'Custom Software 2FA'
- }));
- }, []);
+ return (<>
+
+ emergency_home
+
+
+ {isOpen && (
+
+
+ {t('exception')}
+ {content}
+ )}
+ >);
+}
+
+let staticTranslations = null; // Translations for table head
+
+function Head({category}) {
+ const t = useTranslation();
+
+ // Populate static translations once
+ if (!staticTranslations) {
+ staticTranslations = {
+ docs: t('docs'),
+ sms: t('sms'),
+ email: t('email'),
+ hardware: t('hardware'),
+ software: t('software'),
+ };
+ }
+
+ return html`
+
+
${t(category)}
+
${staticTranslations.docs}
+
${staticTranslations.sms}
+
${staticTranslations.email}
+
${staticTranslations.hardware}
+
${staticTranslations.software}
+
+ `;
+}
+function Methods({methods, customSoftware, customHardware}) {
+ const t = useTranslation();
return (
<>
{methods && methods.filter((method) => !method.includes('custom')).
- map((method) => {method_names[method]} )}
- {methods?.includes("custom-hardware") && Custom Hardware: {customHardware?.join(", ")} }
- {methods?.includes("custom-software") && Custom Software: {customSoftware?.join(", ")} }
+ map((method) => ({t(method)} ))}
+ {methods?.includes('custom-hardware') &&
+ {t('custom-hardware')}: {customHardware?.join(', ')} }
+ {methods?.includes('custom-software') &&
+ {t('custom-software')}: {customSoftware?.join(', ')} }
-
-
-
-
- {methods?.includes("custom-hardware") &&
}
+
+
+
+
+
+
+ {methods?.includes('custom-hardware') &&
+ }
-
- {methods?.includes("custom-software") &&
}
+
+
+ {methods?.includes('custom-software') &&
+ }
>
);
@@ -146,20 +191,69 @@ function Methods({ methods, customSoftware, customHardware }) {
* Show custom methods
*
* @param {Object} props - The props for this compoennt
- * @param {("software"|"hardware")} props.type - The type of custom methods
+ * @param {('software'|'hardware')} props.type - The type of custom methods
* @param {string[]} props.methods - The custom methods
*/
-function CustomMethods({ type, methods }) {
- return methods ?
-
`${method} `).join("")} data-bs-toggle="popover">
- :
;
+function CustomMethods({type, methods}) {
+ const t = useTranslation();
+ const [isOpen, setIsOpen] = useState(false);
+ const arrowRef = useRef(null);
+
+ // Initialize floating UI
+ const {refs, context, floatingStyles} = useFloating({
+ open: isOpen,
+ onOpenChange: setIsOpen,
+ middleware: [
+ autoPlacement(),
+ arrow({element: arrowRef}),
+ ],
+ });
+
+ // Set up interactions
+ const click = useClick(context);
+ const dismiss = useDismiss(context);
+
+ const {getReferenceProps, getFloatingProps} = useInteractions(
+ [click, dismiss]);
+
+ return (
+ <>
+
+ info
+
+
+ {isOpen && (
+
+
+ {t(`custom-${type}`)}
+
+ {methods.map((method, index) => (
+ {method}
+ ))}
+
+
+ )}
+ >
+ );
}
-// Social Media Notices
/**
* Alert the user to the privacy implications of posting on social media.
*
- * @param {("tweet"|"facebook"|"email")} type - The type of social media
+ * @param {('tweet'|'facebook'|'email')} type - The type of social media
* @param {string} lang - An ISO 639-1 language code
* @param {string} handle - The social media handle
*/
@@ -174,26 +268,44 @@ function socialMediaNotice(type, lang, handle) {
}
}
-function Contact({ contact }) {
- const lang = contact.language || "en";
+function Contact({contact}) {
+ const lang = contact.language || 'en';
return (
- {contact.twitter && ( socialMediaNotice("tweet", lang, contact.twitter)}> )}
- {contact.facebook && ( socialMediaNotice("facebook", lang, contact.facebook)}> )}
- {contact.email && ( socialMediaNotice("email", lang, contact.email)}> )}
- {contact.form && ( window.open(contact.form, "_blank")}> )}
+ {contact.twitter && ( socialMediaNotice('tweet',
+ lang, contact.twitter)}> )}
+ {contact.facebook && ( socialMediaNotice('facebook',
+ lang, contact.facebook)}> )}
+ {contact.email && ( socialMediaNotice('email', lang,
+ contact.email)}> )}
+ {contact.form && ( window.open(contact.form,
+ '_blank')}> )}
);
}
-function Icon({ entry }) {
+function Icon({entry}) {
+ const [shouldRender, setShouldRender] = useState(false);
+
+ useEffect(() => {
+ // Defer rendering until after the initial render
+ const timeout = setTimeout(() => setShouldRender(true), 0);
+ return () => clearTimeout(timeout); // Clean up timeout
+ }, []);
+
+ if (!shouldRender) return html`
`; // Placeholder
+
let src = IMG_PATH;
if (entry['img']) {
src += entry['img'][0] + '/' + entry['img'];
} else {
src += entry.domain[0] + '/' + entry['domain'] + '.svg';
}
- return (
);
+ return html`
`;
}
export default Table;
diff --git a/src/hooks/useTranslation.js b/src/hooks/useTranslation.js
new file mode 100644
index 0000000..09cb46e
--- /dev/null
+++ b/src/hooks/useTranslation.js
@@ -0,0 +1,16 @@
+import { useState, useEffect, useMemo } from 'preact/hooks';
+import i18n from '../i18n';
+
+function useTranslation() {
+ const [, forceUpdate] = useState(0);
+
+ useEffect(() => {
+ const onLoad = () => forceUpdate((n) => n + 1);
+ i18n.subscribe(onLoad);
+ return () => i18n.unsubscribe(onLoad);
+ }, []);
+
+ return useMemo(() => i18n.get.bind(i18n), [i18n.translations]);
+}
+
+export default useTranslation;
diff --git a/src/i18n.js b/src/i18n.js
new file mode 100644
index 0000000..57c3762
--- /dev/null
+++ b/src/i18n.js
@@ -0,0 +1,69 @@
+class i18n {
+ constructor() {
+ this.defaultLanguage = 'en'; // Fallback language
+ this.currentLanguage = navigator.language.split('-')[0] || 'en';
+ this.translations = {}; // Will hold the translations
+ this.listeners = [];
+ this.isLoaded = false;
+ this._loadLanguages();
+ }
+
+ async _loadLanguages() {
+ try {
+ // Use import.meta.glob to create a mapping of language files
+ const languageFiles = import.meta.glob('../lang/*.json');
+
+ // Load the default language
+ const defaultLangPath = `../lang/${this.defaultLanguage}.json`;
+ if (languageFiles[defaultLangPath]) {
+ const defaultLangModule = await languageFiles[defaultLangPath]();
+ this.translations[this.defaultLanguage] = defaultLangModule.default;
+ } else {
+ console.error(`Default language file not found at ${defaultLangPath}`);
+ }
+
+ // If current language is different from default, load it as well
+ if (this.currentLanguage !== this.defaultLanguage) {
+ const currentLangPath = `../lang/${this.currentLanguage}.json`;
+ if (languageFiles[currentLangPath]) {
+ const currentLangModule = await languageFiles[currentLangPath]();
+ this.translations[this.currentLanguage] = currentLangModule.default;
+ } else {
+ console.warn(`Language file for ${this.currentLanguage} not found at ${currentLangPath}, falling back to default language.`);
+ }
+ }
+
+ console.debug('Languages loaded:', Object.keys(this.translations));
+ this.isLoaded = true;
+ this._notifyListeners();
+ } catch (error) {
+ console.error('Failed to load language files:', error);
+ }
+ }
+
+ get(key) {
+ const { currentLanguage, defaultLanguage, translations } = this;
+ if (translations[currentLanguage] && translations[currentLanguage][key]) {
+ return translations[currentLanguage][key];
+ } else if (translations[defaultLanguage] && translations[defaultLanguage][key]) {
+ return translations[defaultLanguage][key];
+ }
+ }
+
+ // Subscription management
+ subscribe(listener) {
+ if (!this.listeners.includes(listener)) {
+ this.listeners.push(listener);
+ }
+ }
+
+ unsubscribe(listener) {
+ this.listeners = this.listeners.filter((l) => l !== listener);
+ }
+
+ _notifyListeners() {
+ this.listeners.forEach((listener) => listener());
+ }
+}
+
+export default new i18n();