Compare commits
2 Commits
909edc9f4d
...
429b52e6f5
| Author | SHA1 | Date | |
|---|---|---|---|
| 429b52e6f5 | |||
| 3c211f0e56 |
@@ -25,6 +25,7 @@ describe('appRoutes', () => {
|
||||
'portfolio',
|
||||
'explore',
|
||||
'share/:token',
|
||||
'notes/:token',
|
||||
'cartera',
|
||||
'explorar',
|
||||
'p/:key',
|
||||
@@ -32,12 +33,13 @@ describe('appRoutes', () => {
|
||||
expect(children[0].canActivate).toContain(authGuard);
|
||||
expect(children[1].canActivate).toContain(authGuard);
|
||||
expect(children[2].canActivate).toBeUndefined();
|
||||
// The page for clients needs no session: its link is the key.
|
||||
// The pages for clients need no session: their link is the key.
|
||||
expect(children[3].canActivate).toBeUndefined();
|
||||
expect(children[6].canActivate).toBeUndefined();
|
||||
expect(children[4].canActivate).toBeUndefined();
|
||||
expect(children[7].canActivate).toBeUndefined();
|
||||
// The Spanish paths of shared links lead to the English ones.
|
||||
expect(children[4].redirectTo).toBe('portfolio');
|
||||
expect(children[5].redirectTo).toBe('explore');
|
||||
expect(children[5].redirectTo).toBe('portfolio');
|
||||
expect(children[6].redirectTo).toBe('explore');
|
||||
});
|
||||
|
||||
it('gives each page its own state, not the root', async () => {
|
||||
|
||||
@@ -409,7 +409,8 @@ h3 {
|
||||
|
||||
// A field's label floats above its box; other fields leave room for it
|
||||
// below them (their hint line), a segmented control does not.
|
||||
> pi-segmented-control {
|
||||
> pi-segmented-control,
|
||||
> p:has(+ pi-text-field, + pi-textarea, + pi-select-field, + pi-date-field) {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +169,11 @@ export const APP_ICONS: Record<string, string> = {
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg>',
|
||||
'user-profile-company':
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21V8l6-3v16M9 21h12V11l-6-2"/></svg>',
|
||||
poll: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M5 20V12M12 20V5M19 20v-9M3 20h18"/></svg>',
|
||||
video:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="6" width="13" height="12" rx="2"/><path d="m16 10.5 5-3v9l-5-3"/></svg>',
|
||||
mic: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21M9 21h6"/></svg>',
|
||||
stop: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>',
|
||||
whatsapp:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20.5l1.4-5.2A8 8 0 1 1 21 12Z"/></svg>',
|
||||
};
|
||||
|
||||
@@ -726,19 +726,6 @@ a.ts-key {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ts-resolve__label {
|
||||
display: grid;
|
||||
gap: 0.375rem;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.ts-resolve__text {
|
||||
font: inherit;
|
||||
color: var(--ts-ink);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.ts-reply__actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
@@ -782,59 +769,31 @@ a.ts-key {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Small text buttons, not @pi/ui's sized ones. */
|
||||
.ts-reminder__action {
|
||||
height: auto !important;
|
||||
min-width: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: 0;
|
||||
background: none !important;
|
||||
color: var(--ts-brand);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.ts-reminder__actions {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.ts-reminder__choices {
|
||||
display: grid;
|
||||
gap: 0.375rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ts-reminder__choice {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
height: auto !important;
|
||||
min-width: 0 !important;
|
||||
padding: 0.625rem 0.875rem !important;
|
||||
border: 1px solid var(--ts-line);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--ts-surface);
|
||||
color: var(--ts-ink);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: var(--ts-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.ts-reminder__field {
|
||||
.ts-reminder__custom {
|
||||
display: grid;
|
||||
gap: 0.375rem;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.8125rem;
|
||||
grid-template-columns: minmax(0, 1fr) 8rem;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
|
||||
.ts-input {
|
||||
font: inherit;
|
||||
color: var(--ts-ink);
|
||||
legend {
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0;
|
||||
color: var(--ts-ink-soft);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -888,45 +847,6 @@ a.ts-key {
|
||||
}
|
||||
}
|
||||
|
||||
/* Small buttons, not @pi/ui's sized ones. */
|
||||
.ts-reviews__button {
|
||||
height: auto !important;
|
||||
min-width: 0 !important;
|
||||
padding: 0.25rem 0.75rem !important;
|
||||
border: 1px solid var(--ts-line-strong) !important;
|
||||
border-radius: 999px !important;
|
||||
background: var(--ts-surface) !important;
|
||||
color: var(--ts-ink);
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
&.is-ok {
|
||||
border-color: transparent !important;
|
||||
background: var(--ts-c-green) !important;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.ts-reviews__link {
|
||||
height: auto !important;
|
||||
min-width: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: 0;
|
||||
background: none !important;
|
||||
color: var(--ts-brand);
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* A quick poll in a comment: each option a bar of its share. */
|
||||
.ts-poll {
|
||||
display: grid;
|
||||
@@ -994,47 +914,44 @@ a.ts-key {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Small text buttons, not @pi/ui's sized ones. */
|
||||
.ts-poll__close {
|
||||
height: auto !important;
|
||||
min-width: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: 0;
|
||||
background: none !important;
|
||||
color: var(--ts-brand);
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ts-poll-editor {
|
||||
display: grid;
|
||||
gap: 0.375rem;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
border: 1px dashed var(--ts-line-strong);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.25rem 1rem 1rem;
|
||||
border: 1px solid var(--ts-line);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--ts-surface);
|
||||
|
||||
legend {
|
||||
padding: 0 0.25rem;
|
||||
color: var(--ts-muted);
|
||||
padding: 0 0.375rem;
|
||||
color: var(--ts-ink-soft);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-poll-editor__row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
align-items: flex-end;
|
||||
|
||||
.ts-input {
|
||||
pi-text-field {
|
||||
flex: 1;
|
||||
font: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-poll-editor__actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Poll, screen and voice: what a comment can carry besides words. */
|
||||
.ts-composer-tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -318,20 +318,22 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
@if (reminder.note) {
|
||||
· «{{ reminder.note }}»
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="ts-reminder__action"
|
||||
(click)="openReminder()"
|
||||
>
|
||||
Cambiar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-reminder__action"
|
||||
(click)="facade.setReminder(null)"
|
||||
>
|
||||
Quitar
|
||||
</button>
|
||||
<span class="ts-reminder__actions">
|
||||
<pi-action-button
|
||||
size="x-small"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
text="Cambiar"
|
||||
(execute)="openReminder()"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="x-small"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
text="Quitar"
|
||||
(execute)="facade.setReminder(null)"
|
||||
/>
|
||||
</span>
|
||||
</p>
|
||||
}
|
||||
|
||||
@@ -382,32 +384,34 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
}
|
||||
<div class="ts-reviews__actions">
|
||||
@if (review.reviewer.id === facade.me()) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-reviews__button is-ok"
|
||||
(click)="openReviewAnswer(review.id, approved)"
|
||||
>
|
||||
✓ Todo bien
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-reviews__button"
|
||||
(click)="openReviewAnswer(review.id, changes)"
|
||||
>
|
||||
Pedir cambios
|
||||
</button>
|
||||
<pi-icon-button
|
||||
icon="check"
|
||||
color="primary"
|
||||
size="small"
|
||||
text="Todo bien"
|
||||
(execute)="openReviewAnswer(review.id, approved)"
|
||||
/>
|
||||
<pi-icon-button
|
||||
icon="edit"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
size="small"
|
||||
text="Pedir cambios"
|
||||
(execute)="openReviewAnswer(review.id, changes)"
|
||||
/>
|
||||
}
|
||||
@if (
|
||||
review.requester.id === facade.me() ||
|
||||
project.canManage()
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-reviews__link"
|
||||
(click)="facade.cancelReview(review.id)"
|
||||
>
|
||||
Retirar
|
||||
</button>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
[hasPadding]="false"
|
||||
text="Retirar"
|
||||
(execute)="facade.cancelReview(review.id)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
@@ -968,20 +972,24 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
· cerrada{{
|
||||
poll.winner === null ? ', sin ganadora' : ''
|
||||
}}
|
||||
} @else if (
|
||||
comment.author.id === facade.me() ||
|
||||
project.canManage()
|
||||
) {
|
||||
·
|
||||
<button
|
||||
type="button"
|
||||
class="ts-poll__close"
|
||||
(click)="facade.closePoll(comment.id)"
|
||||
>
|
||||
Cerrar y decidir
|
||||
</button>
|
||||
}
|
||||
</p>
|
||||
@if (
|
||||
!poll.closed &&
|
||||
(comment.author.id === facade.me() ||
|
||||
project.canManage())
|
||||
) {
|
||||
<div>
|
||||
<pi-icon-button
|
||||
icon="check"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
size="small"
|
||||
text="Cerrar y decidir"
|
||||
(execute)="facade.closePoll(comment.id)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (
|
||||
@@ -1072,31 +1080,27 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
"
|
||||
novalidate
|
||||
>
|
||||
<label class="ts-resolve__label">
|
||||
Conclusión (opcional): quedará como decisión, arriba del
|
||||
ticket
|
||||
<textarea
|
||||
class="ts-input ts-resolve__text"
|
||||
rows="2"
|
||||
maxlength="20000"
|
||||
<pi-textarea
|
||||
placeholder="Vamos con…"
|
||||
[value]="conclusion()"
|
||||
(input)="conclusion.set($any($event.target).value)"
|
||||
></textarea>
|
||||
</label>
|
||||
[(value)]="conclusion"
|
||||
>
|
||||
<pi-label
|
||||
>Conclusión (opcional): quedará como decisión, arriba
|
||||
del ticket</pi-label
|
||||
>
|
||||
</pi-textarea>
|
||||
<div class="ts-reply__actions">
|
||||
<pi-action-button
|
||||
color="primary"
|
||||
text="Resolver"
|
||||
(execute)="resolve(thread.comment)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-plain-link"
|
||||
(click)="resolving.set(null)"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<pi-action-button
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
text="Cancelar"
|
||||
(execute)="resolving.set(null)"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
} @else if (replyingTo() === thread.comment.id) {
|
||||
@@ -1211,64 +1215,71 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
/>
|
||||
@if (pollOn()) {
|
||||
<fieldset class="ts-poll-editor">
|
||||
<legend>Encuesta (2 a 6 opciones)</legend>
|
||||
<legend>Encuesta · de 2 a 6 opciones</legend>
|
||||
@for (option of pollOptions(); track $index) {
|
||||
<div class="ts-poll-editor__row">
|
||||
<input
|
||||
class="ts-input"
|
||||
maxlength="80"
|
||||
[attr.aria-label]="'Opción ' + ($index + 1)"
|
||||
[placeholder]="'Opción ' + ($index + 1)"
|
||||
[value]="option"
|
||||
(input)="
|
||||
setPollOption($index, $any($event.target).value)
|
||||
<pi-text-field
|
||||
[placeholder]="
|
||||
$index === 0 ? 'Vamos con Stripe' : 'Otra opción'
|
||||
"
|
||||
/>
|
||||
[value]="option"
|
||||
(valueChange)="setPollOption($index, $event)"
|
||||
>
|
||||
<pi-label>Opción {{ $index + 1 }}</pi-label>
|
||||
</pi-text-field>
|
||||
@if (pollOptions().length > 2) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-poll__close"
|
||||
<pi-icon-button
|
||||
icon="trash"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
[attr.aria-label]="
|
||||
'Quitar la opción ' + ($index + 1)
|
||||
"
|
||||
(click)="removePollOption($index)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
(execute)="removePollOption($index)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="ts-poll-editor__actions">
|
||||
@if (pollOptions().length < 6) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-poll__close"
|
||||
(click)="pollOptions.set([...pollOptions(), ''])"
|
||||
>
|
||||
+ Añadir opción
|
||||
</button>
|
||||
<pi-icon-button
|
||||
icon="plus"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
size="small"
|
||||
text="Añadir opción"
|
||||
(execute)="pollOptions.set([...pollOptions(), ''])"
|
||||
/>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="ts-poll__close"
|
||||
(click)="pollOn.set(false)"
|
||||
>
|
||||
Quitar la encuesta
|
||||
</button>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
[hasPadding]="false"
|
||||
text="Quitar la encuesta"
|
||||
(execute)="pollOn.set(false)"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
} @else {
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-poll__close"
|
||||
(click)="startPoll()"
|
||||
}
|
||||
<div
|
||||
class="ts-composer-tools"
|
||||
role="group"
|
||||
aria-label="Añadir al comentario"
|
||||
>
|
||||
📊 Añadir una encuesta
|
||||
</button>
|
||||
</div>
|
||||
@if (!pollOn()) {
|
||||
<pi-icon-button
|
||||
icon="poll"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
size="small"
|
||||
text="Encuesta"
|
||||
title="Una encuesta rápida: al cerrarla, la ganadora queda como decisión"
|
||||
(execute)="startPoll()"
|
||||
/>
|
||||
}
|
||||
<ts-media-recorder (recorded)="addRecording($event)" />
|
||||
</div>
|
||||
<pi-file-upload
|
||||
label="Adjuntar al comentario"
|
||||
[multiple]="true"
|
||||
@@ -1449,30 +1460,20 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
<p class="ts-muted">
|
||||
Le llegará un aviso y lo verá en «Mi trabajo» hasta que responda.
|
||||
</p>
|
||||
<label class="ts-reminder__field">
|
||||
Quién
|
||||
<select
|
||||
class="ts-input"
|
||||
[value]="reviewer()"
|
||||
(change)="reviewer.set($any($event.target).value)"
|
||||
<pi-select-field
|
||||
placeholder="Elige a alguien…"
|
||||
[options]="reviewerOptions()"
|
||||
[value]="reviewer() || null"
|
||||
(valueChange)="reviewer.set($event ?? '')"
|
||||
>
|
||||
<option value="">Elige a alguien…</option>
|
||||
@for (member of reviewers(); track member.id) {
|
||||
<option [value]="member.id">{{ member.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="ts-reminder__field">
|
||||
Qué mirar (opcional)
|
||||
<textarea
|
||||
class="ts-input ts-resolve__text"
|
||||
rows="3"
|
||||
maxlength="1000"
|
||||
<pi-label [isRequired]="true">Quién</pi-label>
|
||||
</pi-select-field>
|
||||
<pi-textarea
|
||||
placeholder="Revisa el flujo en móvil, sobre todo el pago"
|
||||
[value]="reviewNote()"
|
||||
(input)="reviewNote.set($any($event.target).value)"
|
||||
></textarea>
|
||||
</label>
|
||||
[(value)]="reviewNote"
|
||||
>
|
||||
<pi-label>Qué mirar (opcional)</pi-label>
|
||||
</pi-textarea>
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
@@ -1489,20 +1490,20 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
(submit)="$event.preventDefault(); sendReviewAnswer()"
|
||||
novalidate
|
||||
>
|
||||
<label class="ts-reminder__field">
|
||||
{{
|
||||
<pi-textarea
|
||||
[placeholder]="
|
||||
answering()?.verdict === approved
|
||||
? 'Probado en móvil y escritorio'
|
||||
: 'El botón de pagar no responde en Safari'
|
||||
"
|
||||
[(value)]="reviewAnswer"
|
||||
>
|
||||
<pi-label [isRequired]="answering()?.verdict === changes">{{
|
||||
answering()?.verdict === approved
|
||||
? 'Un comentario (opcional)'
|
||||
: 'Qué hay que cambiar'
|
||||
}}
|
||||
<textarea
|
||||
class="ts-input ts-resolve__text"
|
||||
rows="4"
|
||||
maxlength="1000"
|
||||
[value]="reviewAnswer()"
|
||||
(input)="reviewAnswer.set($any($event.target).value)"
|
||||
></textarea>
|
||||
</label>
|
||||
}}</pi-label>
|
||||
</pi-textarea>
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
@@ -1522,7 +1523,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
<ng-template #reminderForm>
|
||||
<form
|
||||
class="ts-form"
|
||||
(submit)="$event.preventDefault(); saveReminder(reminderAt())"
|
||||
(submit)="$event.preventDefault(); saveCustomReminder()"
|
||||
novalidate
|
||||
>
|
||||
<p class="ts-muted">
|
||||
@@ -1531,42 +1532,40 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
</p>
|
||||
<div class="ts-reminder__choices" role="group" aria-label="Cuándo">
|
||||
@for (choice of reminderChoices(); track choice.label) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-reminder__choice"
|
||||
(click)="saveReminder(choice.at)"
|
||||
>
|
||||
<span>{{ choice.label }}</span>
|
||||
<span class="ts-muted">{{ when(choice.at) }}</span>
|
||||
</button>
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
[text]="choice.label + ' · ' + when(choice.at)"
|
||||
(execute)="saveReminder(choice.at)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
<label class="ts-reminder__field">
|
||||
Otro momento
|
||||
<input
|
||||
class="ts-input"
|
||||
type="datetime-local"
|
||||
[min]="nowLocal()"
|
||||
[value]="reminderAt()"
|
||||
(input)="reminderAt.set($any($event.target).value)"
|
||||
/>
|
||||
</label>
|
||||
<label class="ts-reminder__field">
|
||||
Nota (opcional)
|
||||
<input
|
||||
class="ts-input"
|
||||
maxlength="280"
|
||||
<fieldset class="ts-reminder__custom">
|
||||
<legend>Otro momento</legend>
|
||||
<pi-date-field [min]="today()" [(value)]="reminderDay">
|
||||
<pi-label>Día</pi-label>
|
||||
</pi-date-field>
|
||||
<pi-select-field
|
||||
[options]="timeOptions"
|
||||
[value]="reminderTime()"
|
||||
(valueChange)="reminderTime.set($event ?? '09:00')"
|
||||
>
|
||||
<pi-label>Hora</pi-label>
|
||||
</pi-select-field>
|
||||
</fieldset>
|
||||
<pi-text-field
|
||||
placeholder="Llamar a QA antes de la release"
|
||||
[value]="reminderNote()"
|
||||
(input)="reminderNote.set($any($event.target).value)"
|
||||
/>
|
||||
</label>
|
||||
[(value)]="reminderNote"
|
||||
>
|
||||
<pi-label>Nota (opcional)</pi-label>
|
||||
</pi-text-field>
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
text="Recordármelo"
|
||||
[disabled]="!reminderAt()"
|
||||
(execute)="saveReminder(reminderAt())"
|
||||
[disabled]="!customReminder()"
|
||||
(execute)="saveCustomReminder()"
|
||||
/>
|
||||
</form>
|
||||
</ng-template>
|
||||
@@ -1698,9 +1697,23 @@ export class IssuePage {
|
||||
)
|
||||
.map((m) => ({ id: m.user.id, name: m.user.name })),
|
||||
);
|
||||
/** The reminder being written: `datetime-local` value and note. */
|
||||
protected readonly reminderAt = signal('');
|
||||
protected readonly reviewerOptions = computed<PiSelectFieldOption<string>[]>(
|
||||
() => this.reviewers().map((m) => ({ value: m.id, label: m.name })),
|
||||
);
|
||||
/** The reminder being written: day, time and note. */
|
||||
protected readonly reminderDay = signal<Date | null>(null);
|
||||
protected readonly reminderTime = signal('09:00');
|
||||
protected readonly reminderNote = signal('');
|
||||
protected readonly timeOptions = TIME_OPTIONS;
|
||||
/** The day and time chosen, if both make a moment still to come. */
|
||||
protected readonly customReminder = computed(() => {
|
||||
const day = this.reminderDay();
|
||||
if (!day) return null;
|
||||
const [hours, minutes] = this.reminderTime().split(':').map(Number);
|
||||
const at = new Date(day);
|
||||
at.setHours(hours, minutes, 0, 0);
|
||||
return at.getTime() > Date.now() ? at : null;
|
||||
});
|
||||
protected readonly reminderChoices = signal<ReminderChoice[]>([]);
|
||||
protected readonly when = whenLabel;
|
||||
private readonly editForm =
|
||||
@@ -2158,12 +2171,18 @@ export class IssuePage {
|
||||
protected openReminder(): void {
|
||||
const current = this.facade.issue()?.myReminder;
|
||||
this.reminderChoices.set(reminderChoices());
|
||||
this.reminderAt.set(current ? toLocalInput(new Date(current.at)) : '');
|
||||
const at = current ? new Date(current.at) : null;
|
||||
this.reminderDay.set(at);
|
||||
this.reminderTime.set(at ? halfHourOf(at) : '09:00');
|
||||
this.reminderNote.set(current?.note ?? '');
|
||||
this.drawer.open('Recordármelo', this.reminderForm());
|
||||
}
|
||||
|
||||
/** `at`: ISO, or the `datetime-local` value (local time). */
|
||||
protected saveCustomReminder(): void {
|
||||
const at = this.customReminder();
|
||||
if (at) this.saveReminder(at.toISOString());
|
||||
}
|
||||
|
||||
protected saveReminder(at: string): void {
|
||||
if (!at) return;
|
||||
const when = new Date(at);
|
||||
@@ -2172,9 +2191,11 @@ export class IssuePage {
|
||||
this.drawer.closeAll();
|
||||
}
|
||||
|
||||
/** Now, as the `min` of a `datetime-local` input. */
|
||||
protected nowLocal(): string {
|
||||
return toLocalInput(new Date());
|
||||
/** The first day a reminder can be set on. */
|
||||
protected today(): Date {
|
||||
const day = new Date();
|
||||
day.setHours(0, 0, 0, 0);
|
||||
return day;
|
||||
}
|
||||
|
||||
protected move(to: string): void {
|
||||
@@ -2303,7 +2324,7 @@ export class IssuePage {
|
||||
|
||||
protected setPollOption(index: number, value: string): void {
|
||||
this.pollOptions.update((options) =>
|
||||
options.map((o, i) => (i === index ? value : o)),
|
||||
options.map((o, i) => (i === index ? value.slice(0, 80) : o)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2548,7 +2569,18 @@ export class IssuePage {
|
||||
}
|
||||
|
||||
/** A date as a `datetime-local` value (local time, to the minute). */
|
||||
function toLocalInput(date: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
|
||||
/** The times a reminder can ring at: every half hour. */
|
||||
const TIME_OPTIONS: PiSelectFieldOption<string>[] = Array.from(
|
||||
{ length: 48 },
|
||||
(_, i) => {
|
||||
const time = `${pad(Math.floor(i / 2))}:${i % 2 ? '30' : '00'}`;
|
||||
return { value: time, label: time };
|
||||
},
|
||||
);
|
||||
|
||||
/** A moment's time, to the half hour before it. */
|
||||
function halfHourOf(date: Date): string {
|
||||
return `${pad(date.getHours())}:${date.getMinutes() < 30 ? '00' : '30'}`;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { PiIconButtonComponent } from '@pi/ui';
|
||||
import { ToastService } from '@task-stream/web/shared/ui';
|
||||
|
||||
/** Long enough to show a bug, short enough to stay under the upload limit. */
|
||||
@@ -42,68 +43,65 @@ function mimeFor(kind: Kind): string {
|
||||
@Component({
|
||||
selector: 'ts-media-recorder',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PiIconButtonComponent],
|
||||
template: `
|
||||
@if (recording(); as kind) {
|
||||
<span class="ts-recorder__live" role="status">
|
||||
<span class="ts-recorder__dot" aria-hidden="true"></span>
|
||||
Grabando {{ kind === 'screen' ? 'la pantalla' : 'tu voz' }} ·
|
||||
{{ clock(elapsed()) }} / {{ clock(max) }}
|
||||
Grabando {{ kind === 'screen' ? 'la pantalla' : 'tu voz' }}
|
||||
<span class="ts-recorder__clock"
|
||||
>{{ clock(elapsed()) }} / {{ clock(max) }}</span
|
||||
>
|
||||
</span>
|
||||
<button type="button" class="ts-recorder__button" (click)="stop()">
|
||||
■ Parar
|
||||
</button>
|
||||
<pi-icon-button
|
||||
icon="stop"
|
||||
color="warn"
|
||||
appearance="outline"
|
||||
size="small"
|
||||
text="Parar"
|
||||
(execute)="stop()"
|
||||
/>
|
||||
} @else {
|
||||
@if (canScreen) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-recorder__button"
|
||||
<pi-icon-button
|
||||
icon="video"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
size="small"
|
||||
text="Grabar pantalla"
|
||||
title="Graba la pantalla (y tu voz, si das permiso al micrófono) y lo adjunta al comentario"
|
||||
(click)="start('screen')"
|
||||
>
|
||||
🎥 Grabar pantalla
|
||||
</button>
|
||||
(execute)="start('screen')"
|
||||
/>
|
||||
}
|
||||
@if (canVoice) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-recorder__button"
|
||||
<pi-icon-button
|
||||
icon="mic"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
size="small"
|
||||
text="Grabar audio"
|
||||
title="Graba un audio y lo adjunta al comentario"
|
||||
(click)="start('voice')"
|
||||
>
|
||||
🎙 Grabar audio
|
||||
</button>
|
||||
(execute)="start('voice')"
|
||||
/>
|
||||
}
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem 1rem;
|
||||
}
|
||||
/* Small text buttons, not @pi/ui's sized ones. */
|
||||
.ts-recorder__button {
|
||||
height: auto !important;
|
||||
min-width: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: 0;
|
||||
background: none !important;
|
||||
color: var(--ts-brand);
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
display: contents;
|
||||
}
|
||||
.ts-recorder__live {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
padding: 0 0.75rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--ts-c-red) 12%, transparent);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ts-recorder__clock {
|
||||
font-weight: 400;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ts-recorder__dot {
|
||||
|
||||
Reference in New Issue
Block a user