feat: add auto-generated pseudonyms and identicon avatars for users
This commit is contained in:
@@ -265,6 +265,26 @@ app-header .btn-profile {
|
|||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app-header .btn-profile-avatar {
|
||||||
|
padding: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
transition: border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
app-header .btn-profile-avatar:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
app-header .btn-profile-avatar svg {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
app-header .btn-login-text,
|
app-header .btn-login-text,
|
||||||
app-header .btn-profile-text {
|
app-header .btn-profile-text {
|
||||||
display: inline;
|
display: inline;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { i18n, t } from '../i18n.js'
|
|||||||
import { router } from '../router.js'
|
import { router } from '../router.js'
|
||||||
import { auth } from '../services/auth.js'
|
import { auth } from '../services/auth.js'
|
||||||
import { notificationsService } from '../services/notifications.js'
|
import { notificationsService } from '../services/notifications.js'
|
||||||
|
import { generateAvatar } from '../services/identity.js'
|
||||||
|
|
||||||
class AppHeader extends HTMLElement {
|
class AppHeader extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -188,17 +189,18 @@ class AppHeader extends HTMLElement {
|
|||||||
</a>
|
</a>
|
||||||
<div class="dropdown" id="profile-dropdown">
|
<div class="dropdown" id="profile-dropdown">
|
||||||
<button
|
<button
|
||||||
class="btn btn-icon btn-outline"
|
class="btn btn-icon btn-profile-avatar"
|
||||||
id="profile-toggle"
|
id="profile-toggle"
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded="${this.profileDropdownOpen}"
|
aria-expanded="${this.profileDropdownOpen}"
|
||||||
aria-label="${t('header.profile')}"
|
aria-label="${t('header.profile')}"
|
||||||
title="${t('header.profile')}"
|
title="${t('header.profile')}"
|
||||||
>
|
>
|
||||||
|
${auth.currentUser?.id ? generateAvatar(auth.currentUser.id, 32) : `
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||||
<circle cx="12" cy="7" r="4"></circle>
|
<circle cx="12" cy="7" r="4"></circle>
|
||||||
</svg>
|
</svg>`}
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu dropdown-menu-right" role="menu">
|
<div class="dropdown-menu dropdown-menu-right" role="menu">
|
||||||
<a href="#/my-listings" class="dropdown-item" role="menuitem">
|
<a href="#/my-listings" class="dropdown-item" role="menuitem">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../location-map.js'
|
|||||||
import '../listing-card.js'
|
import '../listing-card.js'
|
||||||
import { categoriesService } from '../../services/categories.js'
|
import { categoriesService } from '../../services/categories.js'
|
||||||
import { reputationService } from '../../services/reputation.js'
|
import { reputationService } from '../../services/reputation.js'
|
||||||
|
import { generatePseudonym, generateAvatar } from '../../services/identity.js'
|
||||||
|
|
||||||
class PageListing extends HTMLElement {
|
class PageListing extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -437,10 +438,10 @@ class PageListing extends HTMLElement {
|
|||||||
<!-- Seller Card -->
|
<!-- Seller Card -->
|
||||||
<div class="sidebar-card seller-card">
|
<div class="sidebar-card seller-card">
|
||||||
<div class="seller-header">
|
<div class="seller-header">
|
||||||
<div class="seller-avatar">?</div>
|
<div class="seller-avatar">${this.listing?.user_created ? generateAvatar(this.listing.user_created, 48) : '?'}</div>
|
||||||
<div class="seller-info">
|
<div class="seller-info">
|
||||||
<strong>${t('listing.anonymousSeller')}</strong>
|
<strong>${this.listing?.user_created ? escapeHTML(generatePseudonym(this.listing.user_created)) : t('listing.anonymousSeller')}</strong>
|
||||||
<span>${t('listing.memberSince')} 2024</span>
|
<span class="seller-auto-label">${t('listing.autoGenerated')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${this.renderSellerReputation()}
|
${this.renderSellerReputation()}
|
||||||
@@ -517,7 +518,7 @@ class PageListing extends HTMLElement {
|
|||||||
<div class="tab-content" id="tab-chat">
|
<div class="tab-content" id="tab-chat">
|
||||||
<chat-widget
|
<chat-widget
|
||||||
listing-id="${this.listing?.id || ''}"
|
listing-id="${this.listing?.id || ''}"
|
||||||
recipient-name="${t('listing.anonymousSeller')}"
|
recipient-name="${this.listing?.user_created ? escapeHTML(generatePseudonym(this.listing.user_created)) : t('listing.anonymousSeller')}"
|
||||||
seller-public-key="${this.listing?.contact_public_key || ''}"
|
seller-public-key="${this.listing?.contact_public_key || ''}"
|
||||||
></chat-widget>
|
></chat-widget>
|
||||||
</div>
|
</div>
|
||||||
@@ -980,14 +981,18 @@ style.textContent = /* css */`
|
|||||||
page-listing .seller-avatar {
|
page-listing .seller-avatar {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
border-radius: 50%;
|
border-radius: var(--radius-full);
|
||||||
background: var(--color-primary);
|
overflow: hidden;
|
||||||
color: white;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-weight: var(--font-weight-bold);
|
}
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
|
page-listing .seller-avatar svg {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
page-listing .seller-info {
|
page-listing .seller-info {
|
||||||
@@ -1000,6 +1005,12 @@ style.textContent = /* css */`
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
page-listing .seller-auto-label {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
page-listing .seller-reputation {
|
page-listing .seller-reputation {
|
||||||
padding: var(--space-sm) 0 0;
|
padding: var(--space-sm) 0 0;
|
||||||
margin-top: var(--space-sm);
|
margin-top: var(--space-sm);
|
||||||
|
|||||||
80
js/services/identity.js
Normal file
80
js/services/identity.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
const ADJECTIVES = [
|
||||||
|
'swift','calm','bold','keen','wise','warm','bright','silent','gentle','steady',
|
||||||
|
'clever','vivid','nimble','serene','brave','quick','subtle','sharp','lucid','agile',
|
||||||
|
'noble','witty','mellow','fierce','stout','deft','proud','fair','grand','cool'
|
||||||
|
]
|
||||||
|
|
||||||
|
const ANIMALS = [
|
||||||
|
'fox','owl','wolf','bear','lynx','hare','deer','hawk','otter','raven',
|
||||||
|
'crane','puma','seal','finch','cobra','bison','ibex','oriole','quail','gecko',
|
||||||
|
'panda','tiger','eagle','badger','moose','robin','coral','falcon','heron','viper'
|
||||||
|
]
|
||||||
|
|
||||||
|
function hashToBytes(input) {
|
||||||
|
const hex = input.replace(/[^0-9a-fA-F]/g, '')
|
||||||
|
const bytes = []
|
||||||
|
for (let i = 0; i < hex.length; i += 2) {
|
||||||
|
bytes.push(parseInt(hex.substring(i, i + 2), 16))
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generatePseudonym(hash) {
|
||||||
|
if (!hash || hash.length < 8) return 'Unknown'
|
||||||
|
const bytes = hashToBytes(hash)
|
||||||
|
const adj = ADJECTIVES[bytes[0] % ADJECTIVES.length]
|
||||||
|
const animal = ANIMALS[bytes[1] % ANIMALS.length]
|
||||||
|
const num = ((bytes[2] << 8) | bytes[3]) % 100
|
||||||
|
const capitalAdj = adj.charAt(0).toUpperCase() + adj.slice(1)
|
||||||
|
const capitalAnimal = animal.charAt(0).toUpperCase() + animal.slice(1)
|
||||||
|
return `${capitalAdj}${capitalAnimal}${num}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateAvatar(hash, size = 64) {
|
||||||
|
if (!hash || hash.length < 16) {
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="32" fill="#A8A29E"/>
|
||||||
|
<text x="32" y="40" text-anchor="middle" font-family="system-ui" font-size="24" fill="#fff">?</text>
|
||||||
|
</svg>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = hashToBytes(hash)
|
||||||
|
|
||||||
|
const hues = [
|
||||||
|
174, // teal (brand)
|
||||||
|
210, // blue
|
||||||
|
260, // purple
|
||||||
|
330, // pink
|
||||||
|
25, // orange
|
||||||
|
45, // gold
|
||||||
|
140, // green
|
||||||
|
190, // cyan
|
||||||
|
]
|
||||||
|
const hue = hues[bytes[4] % hues.length]
|
||||||
|
const bg = `hsl(${hue}, 45%, 45%)`
|
||||||
|
const fg = `hsl(${hue}, 30%, 85%)`
|
||||||
|
|
||||||
|
const grid = 5
|
||||||
|
const cellSize = 64 / grid
|
||||||
|
let cells = ''
|
||||||
|
|
||||||
|
for (let y = 0; y < grid; y++) {
|
||||||
|
for (let x = 0; x < Math.ceil(grid / 2); x++) {
|
||||||
|
const byteIndex = (y * 3 + x + 5) % bytes.length
|
||||||
|
if (bytes[byteIndex] % 2 === 0) {
|
||||||
|
const rx = x * cellSize
|
||||||
|
const ry = y * cellSize
|
||||||
|
cells += `<rect x="${rx}" y="${ry}" width="${cellSize}" height="${cellSize}" fill="${fg}"/>`
|
||||||
|
const mirrorX = (grid - 1 - x) * cellSize
|
||||||
|
if (mirrorX !== rx) {
|
||||||
|
cells += `<rect x="${mirrorX}" y="${ry}" width="${cellSize}" height="${cellSize}" fill="${fg}"/>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="32" fill="${bg}"/>
|
||||||
|
${cells}
|
||||||
|
</svg>`
|
||||||
|
}
|
||||||
@@ -86,6 +86,7 @@
|
|||||||
"location": "Standort",
|
"location": "Standort",
|
||||||
"seller": "Anbieter",
|
"seller": "Anbieter",
|
||||||
"anonymousSeller": "Anonymer Anbieter",
|
"anonymousSeller": "Anonymer Anbieter",
|
||||||
|
"autoGenerated": "automatisch generiert",
|
||||||
"memberSince": "Mitglied seit",
|
"memberSince": "Mitglied seit",
|
||||||
"postedOn": "Eingestellt am",
|
"postedOn": "Eingestellt am",
|
||||||
"contactSeller": "Anbieter kontaktieren",
|
"contactSeller": "Anbieter kontaktieren",
|
||||||
|
|||||||
@@ -86,6 +86,7 @@
|
|||||||
"location": "Location",
|
"location": "Location",
|
||||||
"seller": "Seller",
|
"seller": "Seller",
|
||||||
"anonymousSeller": "Anonymous Seller",
|
"anonymousSeller": "Anonymous Seller",
|
||||||
|
"autoGenerated": "auto-generated",
|
||||||
"memberSince": "Member since",
|
"memberSince": "Member since",
|
||||||
"postedOn": "Posted on",
|
"postedOn": "Posted on",
|
||||||
"contactSeller": "Contact Seller",
|
"contactSeller": "Contact Seller",
|
||||||
|
|||||||
@@ -86,6 +86,7 @@
|
|||||||
"location": "Ubicación",
|
"location": "Ubicación",
|
||||||
"seller": "Vendedor",
|
"seller": "Vendedor",
|
||||||
"anonymousSeller": "Vendedor anónimo",
|
"anonymousSeller": "Vendedor anónimo",
|
||||||
|
"autoGenerated": "generado automáticamente",
|
||||||
"memberSince": "Miembro desde",
|
"memberSince": "Miembro desde",
|
||||||
"postedOn": "Publicado el",
|
"postedOn": "Publicado el",
|
||||||
"contactSeller": "Contactar al vendedor",
|
"contactSeller": "Contactar al vendedor",
|
||||||
|
|||||||
@@ -86,6 +86,7 @@
|
|||||||
"location": "Emplacement",
|
"location": "Emplacement",
|
||||||
"seller": "Vendeur",
|
"seller": "Vendeur",
|
||||||
"anonymousSeller": "Vendeur anonyme",
|
"anonymousSeller": "Vendeur anonyme",
|
||||||
|
"autoGenerated": "généré automatiquement",
|
||||||
"memberSince": "Membre depuis",
|
"memberSince": "Membre depuis",
|
||||||
"postedOn": "Publié le",
|
"postedOn": "Publié le",
|
||||||
"contactSeller": "Contacter le vendeur",
|
"contactSeller": "Contacter le vendeur",
|
||||||
|
|||||||
@@ -86,6 +86,7 @@
|
|||||||
"location": "Località",
|
"location": "Località",
|
||||||
"seller": "Venditore",
|
"seller": "Venditore",
|
||||||
"anonymousSeller": "Venditore anonimo",
|
"anonymousSeller": "Venditore anonimo",
|
||||||
|
"autoGenerated": "generato automaticamente",
|
||||||
"memberSince": "Membro dal",
|
"memberSince": "Membro dal",
|
||||||
"postedOn": "Pubblicato il",
|
"postedOn": "Pubblicato il",
|
||||||
"contactSeller": "Contatta il venditore",
|
"contactSeller": "Contatta il venditore",
|
||||||
|
|||||||
@@ -86,6 +86,7 @@
|
|||||||
"location": "Localização",
|
"location": "Localização",
|
||||||
"seller": "Vendedor",
|
"seller": "Vendedor",
|
||||||
"anonymousSeller": "Vendedor Anônimo",
|
"anonymousSeller": "Vendedor Anônimo",
|
||||||
|
"autoGenerated": "gerado automaticamente",
|
||||||
"memberSince": "Membro desde",
|
"memberSince": "Membro desde",
|
||||||
"postedOn": "Publicado em",
|
"postedOn": "Publicado em",
|
||||||
"contactSeller": "Contatar Vendedor",
|
"contactSeller": "Contatar Vendedor",
|
||||||
|
|||||||
@@ -86,6 +86,7 @@
|
|||||||
"location": "Местоположение",
|
"location": "Местоположение",
|
||||||
"seller": "Продавец",
|
"seller": "Продавец",
|
||||||
"anonymousSeller": "Анонимный продавец",
|
"anonymousSeller": "Анонимный продавец",
|
||||||
|
"autoGenerated": "сгенерировано автоматически",
|
||||||
"memberSince": "Участник с",
|
"memberSince": "Участник с",
|
||||||
"postedOn": "Опубликовано",
|
"postedOn": "Опубликовано",
|
||||||
"contactSeller": "Связаться с продавцом",
|
"contactSeller": "Связаться с продавцом",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const CACHE_NAME = 'kashilo-v51';
|
const CACHE_NAME = 'kashilo-v52';
|
||||||
const STATIC_ASSETS = [
|
const STATIC_ASSETS = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
@@ -37,6 +37,7 @@ const STATIC_ASSETS = [
|
|||||||
'/js/services/btcpay.js',
|
'/js/services/btcpay.js',
|
||||||
'/js/services/favorites.js',
|
'/js/services/favorites.js',
|
||||||
'/js/services/notifications.js',
|
'/js/services/notifications.js',
|
||||||
|
'/js/services/identity.js',
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
'/js/components/app-shell.js',
|
'/js/components/app-shell.js',
|
||||||
|
|||||||
Reference in New Issue
Block a user