@closerclick/closer-click-profile 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/package.json +35 -0
- package/src/index.d.ts +54 -0
- package/src/index.js +734 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 seyacat
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# @closerclick/closer-click-profile
|
|
2
|
+
|
|
3
|
+
Web Component (`<closer-click-profile>`) compartido del ecosistema **Closer
|
|
4
|
+
Click**: la tarjeta de **perfil + reputación** de un peer, idéntica en todas las
|
|
5
|
+
apps (messenger, eco, trueque, …). Autohosteado, Shadow DOM, **sin JS de
|
|
6
|
+
terceros ni cookies**. Bilingüe es/en. Temable solo por CSS custom properties.
|
|
7
|
+
|
|
8
|
+
Muestra:
|
|
9
|
+
|
|
10
|
+
- **Identidad** — avatar (iniciales + color determinista por pubkey), nombre,
|
|
11
|
+
pubkey corta, "conocido desde".
|
|
12
|
+
- **Mi calificación** (modo `edit`) — confianza + afinidad (estrellas) + notas
|
|
13
|
+
privadas.
|
|
14
|
+
- **Web of Trust** — endosos firmados de mi red (lista + promedio).
|
|
15
|
+
- **Reputación de la red** — `reputationOf` ponderada por mi web-of-trust
|
|
16
|
+
(anti-sybil), confianza/afinidad en %.
|
|
17
|
+
|
|
18
|
+
## Filosofía / decisiones
|
|
19
|
+
|
|
20
|
+
- **UI compartida = Web Component** (el ecosistema es mixto Vue/vanilla), no un
|
|
21
|
+
componente Vue. Mismo patrón que `@closerclick/closer-click-support`.
|
|
22
|
+
- **Sin acoplar datos**: el componente no depende de identity ni reputation. La
|
|
23
|
+
app inyecta un `provider` (adapter). Para los paquetes estándar hay un helper
|
|
24
|
+
de 1 línea: `createVaultProfileProvider({ identity, reputation })`.
|
|
25
|
+
- **Tema por CSS vars `--ccp-*`** (defaults = paleta del messenger). Cada app
|
|
26
|
+
cambia solo los colores; el layout/comportamiento es idéntico.
|
|
27
|
+
|
|
28
|
+
## Uso (Vue)
|
|
29
|
+
|
|
30
|
+
```js
|
|
31
|
+
import '@closerclick/closer-click-profile'
|
|
32
|
+
import { createVaultProfileProvider } from '@closerclick/closer-click-profile'
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```html
|
|
36
|
+
<closer-click-profile
|
|
37
|
+
modal
|
|
38
|
+
:pubkey="pk"
|
|
39
|
+
:name="nick"
|
|
40
|
+
:since="firstSeen"
|
|
41
|
+
:online="isOnline"
|
|
42
|
+
@cc-profile-rate="onRated"
|
|
43
|
+
@cc-profile-close="close"
|
|
44
|
+
@cc-profile-refresh="askPeers" />
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
// tras montar, setear la propiedad JS `provider` (objeto, no atributo):
|
|
49
|
+
el.provider = createVaultProfileProvider({ identity, reputation })
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
> En Vue 3, configurá `isCustomElement: (tag) => tag.startsWith('closer-click-')`
|
|
53
|
+
> en `compilerOptions` del plugin de Vue.
|
|
54
|
+
|
|
55
|
+
## Uso (vanilla)
|
|
56
|
+
|
|
57
|
+
```html
|
|
58
|
+
<script type="module" src="https://cdn.jsdelivr.net/npm/@closerclick/closer-click-profile@0.1/src/index.js"></script>
|
|
59
|
+
<closer-click-profile pubkey="..." name="Ada"></closer-click-profile>
|
|
60
|
+
<script type="module">
|
|
61
|
+
const el = document.querySelector('closer-click-profile')
|
|
62
|
+
el.provider = createVaultProfileProvider({ identity, reputation })
|
|
63
|
+
</script>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## API
|
|
67
|
+
|
|
68
|
+
### Atributos
|
|
69
|
+
|
|
70
|
+
| Atributo | Descripción |
|
|
71
|
+
|-----------|-------------|
|
|
72
|
+
| `pubkey` | JWK string del sujeto (requerido) |
|
|
73
|
+
| `name` | nombre/nick a mostrar |
|
|
74
|
+
| `since` | timestamp ms del primer contacto ("conocido desde") |
|
|
75
|
+
| `online` | booleano: muestra el punto de en-línea |
|
|
76
|
+
| `mode` | `edit` (default) \| `view` (solo lectura, sin editor ni footer) |
|
|
77
|
+
| `modal` | booleano: envuelve en backdrop + header/footer |
|
|
78
|
+
| `heading` | título del header (override) |
|
|
79
|
+
| `lang` | `es` \| `en` \| `auto` (default `auto`) |
|
|
80
|
+
|
|
81
|
+
### Propiedad JS
|
|
82
|
+
|
|
83
|
+
- `.provider: ProfileProvider` — adapter de datos. Métodos (todos opcionales):
|
|
84
|
+
`getMyRating`, `getEndorsements`, `getCloudReputation`, `rate`.
|
|
85
|
+
|
|
86
|
+
### Métodos
|
|
87
|
+
|
|
88
|
+
- `el.reload()` — recarga los datos del provider.
|
|
89
|
+
|
|
90
|
+
### Eventos (bubbles, composed)
|
|
91
|
+
|
|
92
|
+
- `cc-profile-rate` — `detail { pubkey, indicators, notes }` (tras guardar OK).
|
|
93
|
+
- `cc-profile-close`.
|
|
94
|
+
- `cc-profile-refresh` — `detail { pubkey }` (botón ↻ del Web of Trust; la app
|
|
95
|
+
puede difundir un `RATING_QUERY` a sus contactos).
|
|
96
|
+
|
|
97
|
+
## Tema (CSS custom properties)
|
|
98
|
+
|
|
99
|
+
Todas con fallback a las vars del messenger (`--bg-1`, `--accent`, …) y luego a
|
|
100
|
+
un default. Override seteándolas en el elemento o un ancestro:
|
|
101
|
+
|
|
102
|
+
```css
|
|
103
|
+
closer-click-profile {
|
|
104
|
+
--ccp-bg: #fff;
|
|
105
|
+
--ccp-accent: #6c5ce7;
|
|
106
|
+
--ccp-gold: #f1c40f;
|
|
107
|
+
--ccp-affinity: #00b894;
|
|
108
|
+
--ccp-radius: 12px;
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`--ccp-bg`, `--ccp-bg-2..4`, `--ccp-border`, `--ccp-text`, `--ccp-muted`,
|
|
113
|
+
`--ccp-accent`, `--ccp-accent-2`, `--ccp-gold`, `--ccp-derived`, `--ccp-online`,
|
|
114
|
+
`--ccp-affinity`, `--ccp-radius`, `--ccp-font`, `--ccp-font-headline`,
|
|
115
|
+
`--ccp-font-mono`.
|
|
116
|
+
|
|
117
|
+
## Licencia
|
|
118
|
+
|
|
119
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@closerclick/closer-click-profile",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Web Component (custom element) <closer-click-profile> reutilizable por cualquier app del ecosistema Closer Click: tarjeta de perfil + reputación (confianza/afinidad, web-of-trust, reputación de la red). Autohosteado, Shadow DOM, temable por CSS vars.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"module": "src/index.js",
|
|
8
|
+
"types": "src/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./src/index.d.ts",
|
|
12
|
+
"import": "./src/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"keywords": [
|
|
21
|
+
"closer-click",
|
|
22
|
+
"web-component",
|
|
23
|
+
"custom-element",
|
|
24
|
+
"profile",
|
|
25
|
+
"reputation",
|
|
26
|
+
"web-of-trust",
|
|
27
|
+
"cross-app"
|
|
28
|
+
],
|
|
29
|
+
"author": "seyacat",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/closerclick/closer-click-profile.git"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export interface Endorsement {
|
|
2
|
+
ratedBy?: string
|
|
3
|
+
rating?: number
|
|
4
|
+
issuedAt?: number
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface CloudIndicator {
|
|
8
|
+
score: number | null
|
|
9
|
+
confidence: number
|
|
10
|
+
trustedCount: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CloudReputation {
|
|
14
|
+
score: number | null
|
|
15
|
+
confidence: number
|
|
16
|
+
trustedCount: number
|
|
17
|
+
rawCount: number
|
|
18
|
+
txBoundCount: number
|
|
19
|
+
indicators?: Record<string, CloudIndicator>
|
|
20
|
+
samples?: unknown[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MyRating {
|
|
24
|
+
confianza: number
|
|
25
|
+
afinidad: number
|
|
26
|
+
notes: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Adapter de datos que la app inyecta en `<closer-click-profile>.provider`. */
|
|
30
|
+
export interface ProfileProvider {
|
|
31
|
+
getMyRating?(pubkey: string): Promise<MyRating>
|
|
32
|
+
getEndorsements?(pubkey: string): Promise<{ endorsements: Endorsement[]; derived: number | null }>
|
|
33
|
+
getCloudReputation?(pubkey: string): Promise<CloudReputation | null>
|
|
34
|
+
rate?(pubkey: string, indicators: Record<string, number>, notes: string): Promise<unknown>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class CloserClickProfile extends HTMLElement {
|
|
38
|
+
provider: ProfileProvider | null
|
|
39
|
+
reload(): Promise<void>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Cablea un provider a los paquetes estándar del ecosistema (1 línea). */
|
|
43
|
+
export function createVaultProfileProvider(cfg: {
|
|
44
|
+
identity: any
|
|
45
|
+
reputation: any
|
|
46
|
+
}): ProfileProvider
|
|
47
|
+
|
|
48
|
+
export default CloserClickProfile
|
|
49
|
+
|
|
50
|
+
declare global {
|
|
51
|
+
interface HTMLElementTagNameMap {
|
|
52
|
+
'closer-click-profile': CloserClickProfile
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @closerclick/closer-click-profile
|
|
3
|
+
*
|
|
4
|
+
* Web Component (custom element) `<closer-click-profile>` reutilizable por
|
|
5
|
+
* CUALQUIER app del ecosistema Closer Click (Vue o vanilla). Muestra la tarjeta
|
|
6
|
+
* de PERFIL + REPUTACIÓN de un peer, unificada para todas las apps:
|
|
7
|
+
* - Identidad: avatar (iniciales + color determinista por pubkey), nombre,
|
|
8
|
+
* pubkey corta, "conocido desde".
|
|
9
|
+
* - Mi calificación (modo edit): confianza + afinidad (estrellas) + notas.
|
|
10
|
+
* - Web of Trust: endosos firmados de mi red (lista + promedio).
|
|
11
|
+
* - Reputación de la red: `reputationOf` ponderada por mi web-of-trust
|
|
12
|
+
* (anti-sybil), con confianza/afinidad en %.
|
|
13
|
+
*
|
|
14
|
+
* Filosofía Closer Click: NO carga JS de terceros ni cookies. 100% autohosteado
|
|
15
|
+
* (Shadow DOM). Los DATOS no se acoplan: la app inyecta un `provider` (adapter)
|
|
16
|
+
* — usá `createVaultProfileProvider({ identity, reputation })` para cablearlo en
|
|
17
|
+
* una línea a los paquetes estándar del ecosistema.
|
|
18
|
+
*
|
|
19
|
+
* TEMA: todo el color sale de CSS custom properties `--ccp-*` seteables en el
|
|
20
|
+
* elemento (o heredadas). Defaults = paleta del messenger. Así cada app cambia
|
|
21
|
+
* SOLO los colores; el layout/comportamiento es idéntico.
|
|
22
|
+
*
|
|
23
|
+
* Uso (Vue):
|
|
24
|
+
* import '@closerclick/closer-click-profile'
|
|
25
|
+
* import { createVaultProfileProvider } from '@closerclick/closer-click-profile'
|
|
26
|
+
* <closer-click-profile modal :pubkey="pk" :name="nick"
|
|
27
|
+
* @cc-profile-rate="onRated" @cc-profile-close="close" />
|
|
28
|
+
* // en mounted: el.provider = createVaultProfileProvider({ identity, reputation })
|
|
29
|
+
*
|
|
30
|
+
* API:
|
|
31
|
+
* Atributos:
|
|
32
|
+
* pubkey JWK string del sujeto (requerido)
|
|
33
|
+
* name nombre/nick a mostrar
|
|
34
|
+
* since timestamp ms del primer contacto ("conocido desde")
|
|
35
|
+
* online booleano: muestra el punto de en-línea
|
|
36
|
+
* mode 'edit' (default) | 'view' (solo lectura, sin editor ni footer)
|
|
37
|
+
* modal booleano: envuelve la tarjeta en backdrop + header/footer
|
|
38
|
+
* heading título del header (override)
|
|
39
|
+
* lang 'es' | 'en' | 'auto' (default 'auto')
|
|
40
|
+
* Propiedad JS:
|
|
41
|
+
* .provider adapter de datos (ver createVaultProfileProvider)
|
|
42
|
+
* Métodos: el.reload()
|
|
43
|
+
* Eventos (bubbles, composed):
|
|
44
|
+
* 'cc-profile-rate' detail { pubkey, indicators, notes } (tras guardar)
|
|
45
|
+
* 'cc-profile-close'
|
|
46
|
+
* 'cc-profile-refresh' detail { pubkey } (botón ↻ del web-of-trust)
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
const I18N = {
|
|
50
|
+
es: {
|
|
51
|
+
headingEdit: 'Calificar contacto',
|
|
52
|
+
headingView: 'Perfil',
|
|
53
|
+
contact: 'Contacto',
|
|
54
|
+
knownSince: 'Conocido desde',
|
|
55
|
+
confianza: 'Confianza',
|
|
56
|
+
confianzaHint: '(¿qué tan confiable/íntegro es?)',
|
|
57
|
+
afinidad: 'Afinidad',
|
|
58
|
+
afinidadHint: '(me interesa / sigo / conozco)',
|
|
59
|
+
of5: '/ 5',
|
|
60
|
+
remove: 'Quitar',
|
|
61
|
+
notes: 'Notas privadas (solo para ti)',
|
|
62
|
+
notesPh: 'ej. Lo conocí jugando ajedrez. Cumple con su palabra.',
|
|
63
|
+
wot: 'Lo que dicen otros (Web of Trust)',
|
|
64
|
+
wotEmpty: 'Sin endosos firmados todavía.',
|
|
65
|
+
endorsements: 'endosos',
|
|
66
|
+
cloud: 'Reputación de la red',
|
|
67
|
+
cloudAsking: 'Consultando…',
|
|
68
|
+
cloudUnavailable: 'Registro no disponible.',
|
|
69
|
+
cloudEmpty: 'Sin reputación en el registro todavía.',
|
|
70
|
+
ofYourNet: 'de tu red',
|
|
71
|
+
withReceipt: 'con recibo',
|
|
72
|
+
weak1: 'reseña(s),',
|
|
73
|
+
weak2: 'ninguna de tu red',
|
|
74
|
+
weak3: '— señal débil.',
|
|
75
|
+
privacy: '⌬ Tu rating se firma con tu clave privada, se comparte con peers de confianza y se publica en el registro de reputación.',
|
|
76
|
+
cancel: 'Cancelar',
|
|
77
|
+
save: 'Guardar calificación',
|
|
78
|
+
saving: 'Guardando…',
|
|
79
|
+
close: 'Cerrar',
|
|
80
|
+
saveError: 'Error al guardar',
|
|
81
|
+
labels: ['Sin calificar', 'Sospechoso', 'Dudoso', 'Confiable', 'Muy confiable', 'De total confianza'],
|
|
82
|
+
},
|
|
83
|
+
en: {
|
|
84
|
+
headingEdit: 'Rate contact',
|
|
85
|
+
headingView: 'Profile',
|
|
86
|
+
contact: 'Contact',
|
|
87
|
+
knownSince: 'Known since',
|
|
88
|
+
confianza: 'Trust',
|
|
89
|
+
confianzaHint: '(how trustworthy/honest are they?)',
|
|
90
|
+
afinidad: 'Affinity',
|
|
91
|
+
afinidadHint: '(I follow / know / care)',
|
|
92
|
+
of5: '/ 5',
|
|
93
|
+
remove: 'Remove',
|
|
94
|
+
notes: 'Private notes (for you only)',
|
|
95
|
+
notesPh: 'e.g. Met them playing chess. Keeps their word.',
|
|
96
|
+
wot: 'What others say (Web of Trust)',
|
|
97
|
+
wotEmpty: 'No signed endorsements yet.',
|
|
98
|
+
endorsements: 'endorsements',
|
|
99
|
+
cloud: 'Network reputation',
|
|
100
|
+
cloudAsking: 'Querying…',
|
|
101
|
+
cloudUnavailable: 'Registry unavailable.',
|
|
102
|
+
cloudEmpty: 'No reputation in the registry yet.',
|
|
103
|
+
ofYourNet: 'from your network',
|
|
104
|
+
withReceipt: 'with receipt',
|
|
105
|
+
weak1: 'review(s),',
|
|
106
|
+
weak2: 'none from your network',
|
|
107
|
+
weak3: '— weak signal.',
|
|
108
|
+
privacy: '⌬ Your rating is signed with your private key, shared with trusted peers and published to the reputation registry.',
|
|
109
|
+
cancel: 'Cancel',
|
|
110
|
+
save: 'Save rating',
|
|
111
|
+
saving: 'Saving…',
|
|
112
|
+
close: 'Close',
|
|
113
|
+
saveError: 'Save error',
|
|
114
|
+
labels: ['Unrated', 'Suspicious', 'Doubtful', 'Trustworthy', 'Very trustworthy', 'Fully trusted'],
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Paleta determinista para el avatar (independiente del tema).
|
|
119
|
+
const AVATAR_PALETTE = [
|
|
120
|
+
'#9c7a8c', '#c89738', '#5a8a3a', '#a37a45',
|
|
121
|
+
'#6b8a9c', '#c0392b', '#7a6b5d', '#b8773d',
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
const STYLE = `
|
|
125
|
+
:host {
|
|
126
|
+
/* ----- Tema (override desde la app) ----- */
|
|
127
|
+
--ccp-bg: var(--bg-1, #faf3e7);
|
|
128
|
+
--ccp-bg-2: var(--bg-2, #f5ede0);
|
|
129
|
+
--ccp-bg-3: var(--bg-3, #ede2cf);
|
|
130
|
+
--ccp-bg-4: var(--bg-4, #e0d3ba);
|
|
131
|
+
--ccp-border: var(--border, #d4c4a8);
|
|
132
|
+
--ccp-text: var(--text, #2b211a);
|
|
133
|
+
--ccp-muted: var(--muted, #8a7a66);
|
|
134
|
+
--ccp-accent: var(--accent, #c0392b);
|
|
135
|
+
--ccp-accent-2: var(--accent-2, #a93226);
|
|
136
|
+
--ccp-gold: var(--gold, #d4a72c);
|
|
137
|
+
--ccp-derived: var(--derived, #a37a45);
|
|
138
|
+
--ccp-online: var(--online, #5a8a3a);
|
|
139
|
+
--ccp-affinity: var(--affinity, #2dd4bf);
|
|
140
|
+
--ccp-radius: var(--ccp-radius, 16px);
|
|
141
|
+
--ccp-font: var(--font-body, 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif);
|
|
142
|
+
--ccp-font-headline: var(--font-headline, var(--ccp-font));
|
|
143
|
+
--ccp-font-mono: var(--font-mono, ui-monospace, Menlo, Consolas, monospace);
|
|
144
|
+
|
|
145
|
+
font-family: var(--ccp-font);
|
|
146
|
+
color: var(--ccp-text);
|
|
147
|
+
}
|
|
148
|
+
* { box-sizing: border-box; }
|
|
149
|
+
|
|
150
|
+
/* ----- Modal chrome (atributo "modal") ----- */
|
|
151
|
+
.backdrop {
|
|
152
|
+
position: fixed; inset: 0;
|
|
153
|
+
background: rgba(0, 0, 0, 0.45);
|
|
154
|
+
display: flex; align-items: center; justify-content: center;
|
|
155
|
+
padding: 16px;
|
|
156
|
+
z-index: 2147483000;
|
|
157
|
+
}
|
|
158
|
+
.modal {
|
|
159
|
+
background: var(--ccp-bg);
|
|
160
|
+
border: 1px solid var(--ccp-border);
|
|
161
|
+
border-radius: var(--ccp-radius);
|
|
162
|
+
width: 100%; max-width: 460px;
|
|
163
|
+
max-height: 92vh;
|
|
164
|
+
display: flex; flex-direction: column;
|
|
165
|
+
overflow: hidden;
|
|
166
|
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
|
|
167
|
+
}
|
|
168
|
+
.head {
|
|
169
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
170
|
+
padding: 18px 24px;
|
|
171
|
+
border-bottom: 1px solid var(--ccp-border);
|
|
172
|
+
}
|
|
173
|
+
.head h2 {
|
|
174
|
+
margin: 0; font-family: var(--ccp-font-headline);
|
|
175
|
+
font-size: 18px; font-weight: 600; color: var(--ccp-text);
|
|
176
|
+
}
|
|
177
|
+
.x {
|
|
178
|
+
background: transparent; border: 0;
|
|
179
|
+
font-size: 24px; cursor: pointer; color: var(--ccp-muted);
|
|
180
|
+
width: 32px; height: 32px; border-radius: 8px; line-height: 1;
|
|
181
|
+
}
|
|
182
|
+
.x:hover { background: var(--ccp-bg-3); color: var(--ccp-text); }
|
|
183
|
+
|
|
184
|
+
.body { padding: 20px 24px; display: flex; flex-direction: column; gap: 20px; overflow-y: auto; }
|
|
185
|
+
:host([modal]) .body { max-height: 70vh; }
|
|
186
|
+
|
|
187
|
+
/* ----- Identity ----- */
|
|
188
|
+
.identity {
|
|
189
|
+
display: flex; gap: 14px; align-items: center;
|
|
190
|
+
background: var(--ccp-bg-2);
|
|
191
|
+
border: 1px solid var(--ccp-border);
|
|
192
|
+
border-radius: 12px; padding: 14px;
|
|
193
|
+
}
|
|
194
|
+
.avatar-wrap { position: relative; flex-shrink: 0; }
|
|
195
|
+
.avatar {
|
|
196
|
+
width: 56px; height: 56px; border-radius: 50%;
|
|
197
|
+
display: flex; align-items: center; justify-content: center;
|
|
198
|
+
color: #fff; font-family: var(--ccp-font-headline);
|
|
199
|
+
font-weight: 600; font-size: 18px;
|
|
200
|
+
}
|
|
201
|
+
.online-dot {
|
|
202
|
+
position: absolute; right: 0; bottom: 0;
|
|
203
|
+
width: 14px; height: 14px; border-radius: 50%;
|
|
204
|
+
background: var(--ccp-online); border: 2px solid var(--ccp-bg-2);
|
|
205
|
+
}
|
|
206
|
+
.identity-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
|
207
|
+
.name { font-family: var(--ccp-font-headline); font-weight: 600; font-size: 17px; color: var(--ccp-text); }
|
|
208
|
+
.pubkey {
|
|
209
|
+
background: var(--ccp-bg-3); padding: 2px 8px; border-radius: 6px;
|
|
210
|
+
font-family: var(--ccp-font-mono); font-size: 11.5px; color: var(--ccp-muted);
|
|
211
|
+
width: fit-content;
|
|
212
|
+
}
|
|
213
|
+
.since { font-size: 12px; color: var(--ccp-muted); }
|
|
214
|
+
|
|
215
|
+
/* ----- Sections ----- */
|
|
216
|
+
.section { display: flex; flex-direction: column; gap: 6px; position: relative; }
|
|
217
|
+
.section-label { font-size: 13px; font-weight: 500; color: var(--ccp-muted); }
|
|
218
|
+
.section-label small { font-weight: 400; }
|
|
219
|
+
.stars-row { display: flex; gap: 6px; align-items: center; }
|
|
220
|
+
.star-btn {
|
|
221
|
+
background: transparent; border: 0; font-size: 36px;
|
|
222
|
+
color: var(--ccp-bg-4); cursor: pointer; padding: 0; line-height: 1;
|
|
223
|
+
transition: color 100ms ease-out, transform 100ms ease-out;
|
|
224
|
+
}
|
|
225
|
+
.star-btn[disabled] { cursor: default; }
|
|
226
|
+
.star-btn:not([disabled]):hover { transform: scale(1.1); }
|
|
227
|
+
.star-btn.filled { color: var(--ccp-gold); text-shadow: 0 1px 2px rgba(212, 167, 44, 0.35); }
|
|
228
|
+
.star-btn.afin.filled { color: var(--ccp-affinity); text-shadow: none; }
|
|
229
|
+
|
|
230
|
+
.rating-meta { display: flex; gap: 8px; align-items: center; font-size: 14px; color: var(--ccp-text); margin-top: 4px; }
|
|
231
|
+
.rating-num { font-weight: 600; }
|
|
232
|
+
.rating-label { color: var(--ccp-muted); }
|
|
233
|
+
.clear {
|
|
234
|
+
background: transparent; border: 0; color: var(--ccp-muted); cursor: pointer;
|
|
235
|
+
font-size: 12px; margin-left: auto; text-decoration: underline;
|
|
236
|
+
}
|
|
237
|
+
.clear:hover { color: var(--ccp-accent); }
|
|
238
|
+
|
|
239
|
+
textarea {
|
|
240
|
+
width: 100%; resize: vertical; font: inherit; color: var(--ccp-text);
|
|
241
|
+
background: #fff; border: 1px solid var(--ccp-border); border-radius: 8px;
|
|
242
|
+
padding: 8px 10px;
|
|
243
|
+
}
|
|
244
|
+
textarea:focus { outline: none; border-color: var(--ccp-accent); }
|
|
245
|
+
.counter { position: absolute; right: 8px; bottom: 8px; font-size: 11px; color: var(--ccp-muted); background: #fff; padding: 0 4px; }
|
|
246
|
+
|
|
247
|
+
/* ----- Web of Trust + Cloud ----- */
|
|
248
|
+
.panel { background: var(--ccp-bg-3); border-radius: 12px; padding: 14px; }
|
|
249
|
+
.panel + .panel { margin-top: 12px; }
|
|
250
|
+
.panel-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
|
251
|
+
.panel-title { font-size: 12px; font-weight: 600; color: var(--ccp-text); text-transform: uppercase; letter-spacing: 0.05em; }
|
|
252
|
+
.refresh { background: transparent; border: 0; color: var(--ccp-muted); cursor: pointer; font-size: 16px; width: 24px; height: 24px; border-radius: 6px; }
|
|
253
|
+
.refresh:hover { background: var(--ccp-bg-4); color: var(--ccp-text); }
|
|
254
|
+
.empty { font-size: 13px; color: var(--ccp-muted); font-style: italic; }
|
|
255
|
+
.weak { font-size: 13px; color: var(--ccp-muted); }
|
|
256
|
+
.summary { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
|
257
|
+
.summary:last-child { margin-bottom: 0; }
|
|
258
|
+
.stars { font-size: 16px; letter-spacing: 1px; }
|
|
259
|
+
.stars.derived { color: var(--ccp-derived); }
|
|
260
|
+
.stars.afin { color: var(--ccp-affinity); }
|
|
261
|
+
.num { font-family: var(--ccp-font-headline); font-weight: 600; font-size: 14px; color: var(--ccp-text); }
|
|
262
|
+
.count { font-size: 12px; color: var(--ccp-muted); }
|
|
263
|
+
.ind { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--ccp-muted); min-width: 64px; }
|
|
264
|
+
|
|
265
|
+
.endorsements { list-style: none; padding: 0; margin: 0; max-height: 130px; overflow-y: auto; }
|
|
266
|
+
.endorsements li {
|
|
267
|
+
display: flex; gap: 8px; align-items: center; font-size: 12.5px;
|
|
268
|
+
padding: 6px 0; border-bottom: 1px solid var(--ccp-border);
|
|
269
|
+
}
|
|
270
|
+
.endorsements li:last-child { border-bottom: 0; }
|
|
271
|
+
.endorsements .key { background: var(--ccp-bg-2); padding: 1px 6px; border-radius: 4px; font-family: var(--ccp-font-mono); font-size: 11px; color: var(--ccp-muted); }
|
|
272
|
+
.endorsements .r { color: var(--ccp-derived); }
|
|
273
|
+
.endorsements .when { color: var(--ccp-muted); margin-left: auto; font-size: 11px; }
|
|
274
|
+
|
|
275
|
+
.privacy { margin: 0; font-size: 12px; color: var(--ccp-muted); text-align: center; line-height: 1.5; }
|
|
276
|
+
.error { margin: 0; font-size: 13px; color: var(--ccp-accent); font-weight: 500; }
|
|
277
|
+
|
|
278
|
+
/* ----- Footer ----- */
|
|
279
|
+
.foot { display: flex; gap: 10px; justify-content: flex-end; padding: 14px 24px; background: var(--ccp-bg-2); border-top: 1px solid var(--ccp-border); }
|
|
280
|
+
.btn { font: inherit; font-weight: 600; padding: 9px 16px; border-radius: 10px; border: 1px solid transparent; cursor: pointer; }
|
|
281
|
+
.btn.primary { background: var(--ccp-accent); color: #fff; }
|
|
282
|
+
.btn.primary:hover:not(:disabled) { background: var(--ccp-accent-2); }
|
|
283
|
+
.btn.primary:disabled { opacity: 0.6; cursor: default; }
|
|
284
|
+
.btn.secondary { background: transparent; color: var(--ccp-text); border-color: var(--ccp-border); }
|
|
285
|
+
.btn.secondary:hover { background: var(--ccp-bg-3); }
|
|
286
|
+
`
|
|
287
|
+
|
|
288
|
+
class CloserClickProfile extends HTMLElement {
|
|
289
|
+
static get observedAttributes() {
|
|
290
|
+
return ['pubkey', 'name', 'since', 'online', 'mode', 'modal', 'heading', 'lang']
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
constructor() {
|
|
294
|
+
super()
|
|
295
|
+
this.attachShadow({ mode: 'open' })
|
|
296
|
+
this._provider = null
|
|
297
|
+
this._my = { confianza: 0, afinidad: 0, notes: '' }
|
|
298
|
+
this._endorsements = []
|
|
299
|
+
this._derived = null
|
|
300
|
+
this._cloud = null
|
|
301
|
+
this._cloudLoading = true
|
|
302
|
+
this._hover = 0
|
|
303
|
+
this._hoverAfin = 0
|
|
304
|
+
this._saving = false
|
|
305
|
+
this._error = ''
|
|
306
|
+
this._loadToken = 0
|
|
307
|
+
this._onKeydown = this._onKeydown.bind(this)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
set provider(p) { this._provider = p; if (this.isConnected) this.reload() }
|
|
311
|
+
get provider() { return this._provider }
|
|
312
|
+
|
|
313
|
+
connectedCallback() {
|
|
314
|
+
document.addEventListener('keydown', this._onKeydown)
|
|
315
|
+
this._render()
|
|
316
|
+
this.reload()
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
disconnectedCallback() {
|
|
320
|
+
document.removeEventListener('keydown', this._onKeydown)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
attributeChangedCallback(name, oldV, newV) {
|
|
324
|
+
if (oldV === newV) return
|
|
325
|
+
if (name === 'pubkey') { this._resetState(); this.reload(); return }
|
|
326
|
+
if (this.shadowRoot.childElementCount) this._render()
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
_onKeydown(e) {
|
|
330
|
+
if (e.key === 'Escape' && this.hasAttribute('modal')) this._close()
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/* ---- idioma ---- */
|
|
334
|
+
get _lang() {
|
|
335
|
+
const attr = (this.getAttribute('lang') || 'auto').toLowerCase()
|
|
336
|
+
if (attr === 'es' || attr === 'en') return attr
|
|
337
|
+
const doc = (document.documentElement.lang || '').toLowerCase()
|
|
338
|
+
const nav = (navigator.language || '').toLowerCase()
|
|
339
|
+
return (doc || nav).startsWith('en') ? 'en' : 'es'
|
|
340
|
+
}
|
|
341
|
+
get _t() { return I18N[this._lang] }
|
|
342
|
+
get _pubkey() { return this.getAttribute('pubkey') || '' }
|
|
343
|
+
get _editable() { return (this.getAttribute('mode') || 'edit') !== 'view' }
|
|
344
|
+
|
|
345
|
+
_resetState() {
|
|
346
|
+
this._my = { confianza: 0, afinidad: 0, notes: '' }
|
|
347
|
+
this._endorsements = []
|
|
348
|
+
this._derived = null
|
|
349
|
+
this._cloud = null
|
|
350
|
+
this._cloudLoading = true
|
|
351
|
+
this._hover = 0
|
|
352
|
+
this._hoverAfin = 0
|
|
353
|
+
this._error = ''
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/* ---- carga de datos vía provider ---- */
|
|
357
|
+
async reload() {
|
|
358
|
+
const pk = this._pubkey
|
|
359
|
+
const p = this._provider
|
|
360
|
+
if (!pk || !p) { this._render(); return }
|
|
361
|
+
const token = ++this._loadToken
|
|
362
|
+
this._cloudLoading = true
|
|
363
|
+
this._render()
|
|
364
|
+
|
|
365
|
+
// Mi calificación + endosos locales (rápido) en paralelo con la nube (lento).
|
|
366
|
+
try {
|
|
367
|
+
if (typeof p.getMyRating === 'function') {
|
|
368
|
+
const my = await p.getMyRating(pk)
|
|
369
|
+
if (token !== this._loadToken) return
|
|
370
|
+
if (my) this._my = { confianza: my.confianza || 0, afinidad: my.afinidad || 0, notes: my.notes || '' }
|
|
371
|
+
}
|
|
372
|
+
} catch (_) { /* best-effort */ }
|
|
373
|
+
try {
|
|
374
|
+
if (typeof p.getEndorsements === 'function') {
|
|
375
|
+
const e = await p.getEndorsements(pk)
|
|
376
|
+
if (token !== this._loadToken) return
|
|
377
|
+
this._endorsements = (e && e.endorsements) || []
|
|
378
|
+
this._derived = e ? e.derived : null
|
|
379
|
+
}
|
|
380
|
+
} catch (_) { /* best-effort */ }
|
|
381
|
+
if (token === this._loadToken) this._render()
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
const cloud = typeof p.getCloudReputation === 'function' ? await p.getCloudReputation(pk) : null
|
|
385
|
+
if (token !== this._loadToken) return
|
|
386
|
+
this._cloud = cloud
|
|
387
|
+
} catch (_) { this._cloud = null } finally {
|
|
388
|
+
if (token === this._loadToken) { this._cloudLoading = false; this._render() }
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async _save() {
|
|
393
|
+
if (!this._editable || this._saving) return
|
|
394
|
+
const p = this._provider
|
|
395
|
+
const pk = this._pubkey
|
|
396
|
+
if (!p || typeof p.rate !== 'function' || !pk) return
|
|
397
|
+
this._error = ''
|
|
398
|
+
this._saving = true
|
|
399
|
+
this._render()
|
|
400
|
+
const indicators = { confianza: this._my.confianza }
|
|
401
|
+
if (this._my.afinidad > 0) indicators.afinidad = this._my.afinidad
|
|
402
|
+
try {
|
|
403
|
+
await p.rate(pk, indicators, this._my.notes)
|
|
404
|
+
this._saving = false
|
|
405
|
+
this._emit('cc-profile-rate', { pubkey: pk, indicators, notes: this._my.notes })
|
|
406
|
+
if (this.hasAttribute('modal')) this._close()
|
|
407
|
+
else this._render()
|
|
408
|
+
} catch (e) {
|
|
409
|
+
this._saving = false
|
|
410
|
+
this._error = (e && e.message) || this._t.saveError
|
|
411
|
+
this._render()
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
_close() { this._emit('cc-profile-close', { pubkey: this._pubkey }) }
|
|
416
|
+
_refresh() { this._emit('cc-profile-refresh', { pubkey: this._pubkey }); this.reload() }
|
|
417
|
+
_emit(type, detail) { this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true })) }
|
|
418
|
+
|
|
419
|
+
/* ---- helpers de presentación ---- */
|
|
420
|
+
_initials(s) {
|
|
421
|
+
return (s || '?').trim().split(/\s+/).slice(0, 2).map(w => w[0] || '').join('').toUpperCase() || '?'
|
|
422
|
+
}
|
|
423
|
+
_avatarBg(key) {
|
|
424
|
+
const s = key || ''
|
|
425
|
+
let h = 0
|
|
426
|
+
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0
|
|
427
|
+
return AVATAR_PALETTE[h % AVATAR_PALETTE.length]
|
|
428
|
+
}
|
|
429
|
+
_stars(n) {
|
|
430
|
+
const v = Math.max(0, Math.min(5, Math.round(n || 0)))
|
|
431
|
+
return '★'.repeat(v) + '☆'.repeat(5 - v)
|
|
432
|
+
}
|
|
433
|
+
_fmtDate(ts) {
|
|
434
|
+
if (!ts) return ''
|
|
435
|
+
try { return new Date(Number(ts)).toLocaleDateString([], { day: '2-digit', month: 'short', year: 'numeric' }) }
|
|
436
|
+
catch { return '' }
|
|
437
|
+
}
|
|
438
|
+
_shortKey(k) {
|
|
439
|
+
k = k || ''
|
|
440
|
+
return k.length > 20 ? k.slice(0, 12) + '…' + k.slice(-4) : k
|
|
441
|
+
}
|
|
442
|
+
_esc(s) {
|
|
443
|
+
return String(s == null ? '' : s).replace(/[&<>"']/g, c => (
|
|
444
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
|
445
|
+
))
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
_starsRow(kind, filled, hover) {
|
|
449
|
+
const active = hover || filled
|
|
450
|
+
const cls = kind === 'afin' ? 'star-btn afin' : 'star-btn'
|
|
451
|
+
const dis = this._editable ? '' : 'disabled'
|
|
452
|
+
let out = `<div class="stars-row" data-row="${kind}">`
|
|
453
|
+
for (let n = 1; n <= 5; n++) {
|
|
454
|
+
out += `<button type="button" class="${cls}${n <= active ? ' filled' : ''}" data-star="${kind}" data-n="${n}" ${dis}>★</button>`
|
|
455
|
+
}
|
|
456
|
+
return out + '</div>'
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
_render() {
|
|
460
|
+
const t = this._t
|
|
461
|
+
const sr = this.shadowRoot
|
|
462
|
+
const isModal = this.hasAttribute('modal')
|
|
463
|
+
const editable = this._editable
|
|
464
|
+
const pk = this._pubkey
|
|
465
|
+
const name = this.getAttribute('name') || t.contact
|
|
466
|
+
const since = this.getAttribute('since')
|
|
467
|
+
const online = this.hasAttribute('online') && this.getAttribute('online') !== 'false'
|
|
468
|
+
const heading = this.getAttribute('heading') || (editable ? t.headingEdit : t.headingView)
|
|
469
|
+
|
|
470
|
+
const confLabel = t.labels[this._hover || this._my.confianza] || t.labels[0]
|
|
471
|
+
|
|
472
|
+
// ----- Identidad -----
|
|
473
|
+
let body = `
|
|
474
|
+
<div class="identity">
|
|
475
|
+
<div class="avatar-wrap">
|
|
476
|
+
<div class="avatar" style="background:${this._avatarBg(pk)}">${this._esc(this._initials(name))}</div>
|
|
477
|
+
${online ? '<span class="online-dot"></span>' : ''}
|
|
478
|
+
</div>
|
|
479
|
+
<div class="identity-text">
|
|
480
|
+
<div class="name">${this._esc(name)}</div>
|
|
481
|
+
<code class="pubkey">${this._esc(this._shortKey(pk))}</code>
|
|
482
|
+
${since ? `<div class="since">${this._esc(t.knownSince)} ${this._esc(this._fmtDate(since))}</div>` : ''}
|
|
483
|
+
</div>
|
|
484
|
+
</div>`
|
|
485
|
+
|
|
486
|
+
// ----- Editor (confianza / afinidad / notas) -----
|
|
487
|
+
if (editable) {
|
|
488
|
+
body += `
|
|
489
|
+
<div class="section">
|
|
490
|
+
<span class="section-label">${this._esc(t.confianza)} <small>${this._esc(t.confianzaHint)}</small></span>
|
|
491
|
+
${this._starsRow('conf', this._my.confianza, this._hover)}
|
|
492
|
+
<div class="rating-meta">
|
|
493
|
+
<span class="rating-num">${this._hover || this._my.confianza || 0} ${t.of5}</span>
|
|
494
|
+
<span class="rating-label">— ${this._esc(confLabel)}</span>
|
|
495
|
+
${this._my.confianza > 0 ? `<button class="clear" data-clear="conf">${this._esc(t.remove)}</button>` : ''}
|
|
496
|
+
</div>
|
|
497
|
+
</div>
|
|
498
|
+
<div class="section">
|
|
499
|
+
<span class="section-label">${this._esc(t.afinidad)} <small>${this._esc(t.afinidadHint)}</small></span>
|
|
500
|
+
${this._starsRow('afin', this._my.afinidad, this._hoverAfin)}
|
|
501
|
+
<div class="rating-meta">
|
|
502
|
+
<span class="rating-num">${this._hoverAfin || this._my.afinidad || 0} ${t.of5}</span>
|
|
503
|
+
${this._my.afinidad > 0 ? `<button class="clear" data-clear="afin">${this._esc(t.remove)}</button>` : ''}
|
|
504
|
+
</div>
|
|
505
|
+
</div>
|
|
506
|
+
<label class="section">
|
|
507
|
+
<span class="section-label">${this._esc(t.notes)}</span>
|
|
508
|
+
<textarea rows="3" maxlength="500" placeholder="${this._esc(t.notesPh)}">${this._esc(this._my.notes)}</textarea>
|
|
509
|
+
<span class="counter">${(this._my.notes || '').length} / 500</span>
|
|
510
|
+
</label>`
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ----- Web of Trust (endosos locales) -----
|
|
514
|
+
let wot = ''
|
|
515
|
+
if (this._endorsements.length === 0) {
|
|
516
|
+
wot = `<div class="empty">${this._esc(t.wotEmpty)}</div>`
|
|
517
|
+
} else {
|
|
518
|
+
const rows = this._endorsements.slice(0, 5).map(e => `
|
|
519
|
+
<li>
|
|
520
|
+
<code class="key">${this._esc((e.ratedBy || '').slice(0, 12))}…</code>
|
|
521
|
+
<span class="r">${this._stars(e.rating)}</span>
|
|
522
|
+
<span class="when">${this._esc(this._fmtDate(e.issuedAt))}</span>
|
|
523
|
+
</li>`).join('')
|
|
524
|
+
wot = `
|
|
525
|
+
<div class="summary">
|
|
526
|
+
<span class="stars derived">${this._stars(this._derived)}</span>
|
|
527
|
+
<span class="num">${this._derived != null ? this._derived.toFixed(1) : '—'}</span>
|
|
528
|
+
<span class="count">(${this._endorsements.length} ${this._esc(t.endorsements)})</span>
|
|
529
|
+
</div>
|
|
530
|
+
<ul class="endorsements">${rows}</ul>`
|
|
531
|
+
}
|
|
532
|
+
body += `
|
|
533
|
+
<div class="panel">
|
|
534
|
+
<div class="panel-head">
|
|
535
|
+
<span class="panel-title">${this._esc(t.wot)}</span>
|
|
536
|
+
<button class="refresh" data-refresh title="↻">↻</button>
|
|
537
|
+
</div>
|
|
538
|
+
${wot}
|
|
539
|
+
</div>`
|
|
540
|
+
|
|
541
|
+
// ----- Reputación de la red -----
|
|
542
|
+
let cloud
|
|
543
|
+
if (this._cloudLoading) {
|
|
544
|
+
cloud = `<div class="empty">${this._esc(t.cloudAsking)}</div>`
|
|
545
|
+
} else if (this._cloud) {
|
|
546
|
+
const c = this._cloud
|
|
547
|
+
const afin = c.indicators && c.indicators.afinidad
|
|
548
|
+
let parts = ''
|
|
549
|
+
if (c.score != null) {
|
|
550
|
+
const pct = Math.round(c.score * 100)
|
|
551
|
+
const receipt = c.txBoundCount ? ` · ${c.txBoundCount} ${this._esc(t.withReceipt)}` : ''
|
|
552
|
+
parts += `
|
|
553
|
+
<div class="summary">
|
|
554
|
+
<span class="ind">${this._esc(t.confianza)}</span>
|
|
555
|
+
<span class="stars derived">${this._stars(c.score * 5)}</span>
|
|
556
|
+
<span class="num">${pct}%</span>
|
|
557
|
+
<span class="count">${c.trustedCount} ${this._esc(t.ofYourNet)}${receipt}</span>
|
|
558
|
+
</div>`
|
|
559
|
+
}
|
|
560
|
+
if (afin && afin.score != null) {
|
|
561
|
+
parts += `
|
|
562
|
+
<div class="summary">
|
|
563
|
+
<span class="ind">${this._esc(t.afinidad)}</span>
|
|
564
|
+
<span class="stars afin">${this._stars(afin.score * 5)}</span>
|
|
565
|
+
<span class="num">${Math.round(afin.score * 100)}%</span>
|
|
566
|
+
<span class="count">${afin.trustedCount} ${this._esc(t.ofYourNet)}</span>
|
|
567
|
+
</div>`
|
|
568
|
+
}
|
|
569
|
+
if (c.score == null && c.rawCount > 0) {
|
|
570
|
+
parts = `<div class="weak">${c.rawCount} ${this._esc(t.weak1)} <strong>${this._esc(t.weak2)}</strong> ${this._esc(t.weak3)}</div>`
|
|
571
|
+
} else if (c.score == null && (!c.rawCount)) {
|
|
572
|
+
parts = `<div class="empty">${this._esc(t.cloudEmpty)}</div>`
|
|
573
|
+
}
|
|
574
|
+
cloud = parts
|
|
575
|
+
} else {
|
|
576
|
+
cloud = `<div class="empty">${this._esc(t.cloudUnavailable)}</div>`
|
|
577
|
+
}
|
|
578
|
+
body += `
|
|
579
|
+
<div class="panel">
|
|
580
|
+
<div class="panel-head">
|
|
581
|
+
<span class="panel-title">${this._esc(t.cloud)}</span>
|
|
582
|
+
<button class="refresh" data-reload title="↻">↻</button>
|
|
583
|
+
</div>
|
|
584
|
+
${cloud}
|
|
585
|
+
</div>`
|
|
586
|
+
|
|
587
|
+
if (editable) body += `<p class="privacy">${this._esc(t.privacy)}</p>`
|
|
588
|
+
if (this._error) body += `<p class="error">${this._esc(this._error)}</p>`
|
|
589
|
+
|
|
590
|
+
// ----- Ensamblado (modal o inline) -----
|
|
591
|
+
const footer = editable ? `
|
|
592
|
+
<footer class="foot">
|
|
593
|
+
${isModal ? `<button class="btn secondary" data-cancel>${this._esc(t.cancel)}</button>` : ''}
|
|
594
|
+
<button class="btn primary" data-save ${this._saving ? 'disabled' : ''}>${this._esc(this._saving ? t.saving : t.save)}</button>
|
|
595
|
+
</footer>` : ''
|
|
596
|
+
|
|
597
|
+
let html = `<style>${STYLE}</style>`
|
|
598
|
+
if (isModal) {
|
|
599
|
+
html += `
|
|
600
|
+
<div class="backdrop" data-backdrop>
|
|
601
|
+
<div class="modal" role="dialog" aria-modal="true">
|
|
602
|
+
<header class="head">
|
|
603
|
+
<h2>${this._esc(heading)}</h2>
|
|
604
|
+
<button class="x" data-cancel aria-label="${this._esc(t.close)}">×</button>
|
|
605
|
+
</header>
|
|
606
|
+
<div class="body">${body}</div>
|
|
607
|
+
${footer}
|
|
608
|
+
</div>
|
|
609
|
+
</div>`
|
|
610
|
+
} else {
|
|
611
|
+
html += `<div class="body">${body}</div>${footer}`
|
|
612
|
+
}
|
|
613
|
+
sr.innerHTML = html
|
|
614
|
+
this._wire()
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
_wire() {
|
|
618
|
+
const sr = this.shadowRoot
|
|
619
|
+
const q = (s) => sr.querySelector(s)
|
|
620
|
+
const qa = (s) => sr.querySelectorAll(s)
|
|
621
|
+
|
|
622
|
+
const backdrop = q('[data-backdrop]')
|
|
623
|
+
if (backdrop) backdrop.addEventListener('click', (e) => { if (e.target === backdrop) this._close() })
|
|
624
|
+
qa('[data-cancel]').forEach(b => b.addEventListener('click', () => this._close()))
|
|
625
|
+
const save = q('[data-save]'); if (save) save.addEventListener('click', () => this._save())
|
|
626
|
+
const refresh = q('[data-refresh]'); if (refresh) refresh.addEventListener('click', () => this._refresh())
|
|
627
|
+
const reload = q('[data-reload]'); if (reload) reload.addEventListener('click', () => this.reload())
|
|
628
|
+
|
|
629
|
+
if (this._editable) {
|
|
630
|
+
qa('[data-star]').forEach(btn => {
|
|
631
|
+
const kind = btn.getAttribute('data-star')
|
|
632
|
+
const n = Number(btn.getAttribute('data-n'))
|
|
633
|
+
btn.addEventListener('click', () => {
|
|
634
|
+
if (kind === 'conf') this._my.confianza = n; else this._my.afinidad = n
|
|
635
|
+
this._render()
|
|
636
|
+
})
|
|
637
|
+
btn.addEventListener('mouseenter', () => {
|
|
638
|
+
if (kind === 'conf') this._hover = n; else this._hoverAfin = n
|
|
639
|
+
this._render()
|
|
640
|
+
})
|
|
641
|
+
})
|
|
642
|
+
qa('[data-row]').forEach(row => {
|
|
643
|
+
const kind = row.getAttribute('data-row')
|
|
644
|
+
row.addEventListener('mouseleave', () => {
|
|
645
|
+
if (kind === 'conf') this._hover = 0; else this._hoverAfin = 0
|
|
646
|
+
this._render()
|
|
647
|
+
})
|
|
648
|
+
})
|
|
649
|
+
qa('[data-clear]').forEach(btn => {
|
|
650
|
+
const kind = btn.getAttribute('data-clear')
|
|
651
|
+
btn.addEventListener('click', () => {
|
|
652
|
+
if (kind === 'conf') this._my.confianza = 0; else this._my.afinidad = 0
|
|
653
|
+
this._render()
|
|
654
|
+
})
|
|
655
|
+
})
|
|
656
|
+
const ta = q('textarea')
|
|
657
|
+
if (ta) {
|
|
658
|
+
// Mantener foco/cursor entre re-renders: actualizamos estado sin re-render.
|
|
659
|
+
ta.addEventListener('input', (e) => {
|
|
660
|
+
this._my.notes = e.target.value
|
|
661
|
+
const counter = q('.counter')
|
|
662
|
+
if (counter) counter.textContent = `${this._my.notes.length} / 500`
|
|
663
|
+
})
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (typeof customElements !== 'undefined' && !customElements.get('closer-click-profile')) {
|
|
670
|
+
customElements.define('closer-click-profile', CloserClickProfile)
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Cablea un `provider` para `<closer-click-profile>` a los paquetes estándar del
|
|
675
|
+
* ecosistema (duck-typing, sin dependencia dura). Integración de 1 línea.
|
|
676
|
+
*
|
|
677
|
+
* @param {object} cfg
|
|
678
|
+
* @param {object} cfg.identity instancia conectada de closer-click-identity
|
|
679
|
+
* (usa `me.publickey`, `getRatingsForSubject(pk)`).
|
|
680
|
+
* @param {object} cfg.reputation instancia de createVaultReputation(identity)
|
|
681
|
+
* (usa `getRatings`, `reputationOf`, `rate`).
|
|
682
|
+
* @returns {object} provider
|
|
683
|
+
*/
|
|
684
|
+
export function createVaultProfileProvider({ identity, reputation } = {}) {
|
|
685
|
+
if (!reputation) throw new Error('closer-click-profile: se requiere `reputation` (createVaultReputation)')
|
|
686
|
+
const myPubkey = () => (identity && identity.me && identity.me.publickey) || null
|
|
687
|
+
|
|
688
|
+
return {
|
|
689
|
+
async getMyRating(pubkey) {
|
|
690
|
+
let confianza = 0; let afinidad = 0; let notes = ''
|
|
691
|
+
try {
|
|
692
|
+
if (identity && typeof identity.getRatingsForSubject === 'function') {
|
|
693
|
+
const r = await identity.getRatingsForSubject(pubkey)
|
|
694
|
+
if (r && r.mine) {
|
|
695
|
+
if (typeof r.mine.rating === 'number') confianza = r.mine.rating
|
|
696
|
+
if (r.mine.notes) notes = r.mine.notes
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
} catch (_) { /* best-effort */ }
|
|
700
|
+
try {
|
|
701
|
+
if (typeof reputation.getRatings === 'function') {
|
|
702
|
+
const { attestations } = await reputation.getRatings(pubkey)
|
|
703
|
+
const me = myPubkey()
|
|
704
|
+
const mine = (attestations || []).find(a => a && a.issuer === me)
|
|
705
|
+
if (mine && mine.indicators && typeof mine.indicators.afinidad === 'number') afinidad = mine.indicators.afinidad
|
|
706
|
+
}
|
|
707
|
+
} catch (_) { /* best-effort */ }
|
|
708
|
+
return { confianza, afinidad, notes }
|
|
709
|
+
},
|
|
710
|
+
|
|
711
|
+
async getEndorsements(pubkey) {
|
|
712
|
+
try {
|
|
713
|
+
if (identity && typeof identity.getRatingsForSubject === 'function') {
|
|
714
|
+
const r = await identity.getRatingsForSubject(pubkey)
|
|
715
|
+
const endorsements = (r && r.endorsements) || []
|
|
716
|
+
const vals = endorsements.map(e => e && e.rating).filter(n => typeof n === 'number')
|
|
717
|
+
const derived = vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null
|
|
718
|
+
return { endorsements, derived }
|
|
719
|
+
}
|
|
720
|
+
} catch (_) { /* best-effort */ }
|
|
721
|
+
return { endorsements: [], derived: null }
|
|
722
|
+
},
|
|
723
|
+
|
|
724
|
+
async getCloudReputation(pubkey) {
|
|
725
|
+
try { return await reputation.reputationOf(pubkey) } catch (_) { return null }
|
|
726
|
+
},
|
|
727
|
+
|
|
728
|
+
async rate(pubkey, indicators, notes) {
|
|
729
|
+
return reputation.rate(pubkey, indicators, { notes })
|
|
730
|
+
},
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
export default CloserClickProfile
|