fix(web): polls, recordings, reviews and reminders use @pi/ui's fields and buttons

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 20:24:37 +02:00
parent 3c211f0e56
commit 429b52e6f5
5 changed files with 301 additions and 348 deletions

View File

@@ -409,7 +409,8 @@ h3 {
// A field's label floats above its box; other fields leave room for it // A field's label floats above its box; other fields leave room for it
// below them (their hint line), a segmented control does not. // 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; margin-bottom: 0.75rem;
} }
} }

View File

@@ -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>', '<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': '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>', '<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: 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>', '<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>',
}; };

View File

@@ -726,19 +726,6 @@ a.ts-key {
font-style: italic; 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 { .ts-reply__actions {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
@@ -782,59 +769,31 @@ a.ts-key {
font-size: 0.875rem; font-size: 0.875rem;
} }
/* Small text buttons, not @pi/ui's sized ones. */ .ts-reminder__actions {
.ts-reminder__action { display: inline-flex;
height: auto !important; gap: 0.25rem;
min-width: 0 !important; margin-left: auto;
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__choices { .ts-reminder__choices {
display: grid; display: grid;
gap: 0.375rem; gap: 0.5rem;
} }
.ts-reminder__choice { .ts-reminder__custom {
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 {
display: grid; display: grid;
gap: 0.375rem; grid-template-columns: minmax(0, 1fr) 8rem;
color: var(--ts-muted); gap: 0.75rem;
font-size: 0.8125rem; margin: 0;
padding: 0;
border: 0;
.ts-input { legend {
font: inherit; margin-bottom: 1.25rem;
color: var(--ts-ink); 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. */ /* A quick poll in a comment: each option a bar of its share. */
.ts-poll { .ts-poll {
display: grid; display: grid;
@@ -994,47 +914,44 @@ a.ts-key {
font-size: 0.75rem; 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 { .ts-poll-editor {
display: grid; display: grid;
gap: 0.375rem; gap: 1rem;
margin: 0; margin: 0;
padding: 0.75rem; padding: 1.25rem 1rem 1rem;
border: 1px dashed var(--ts-line-strong); border: 1px solid var(--ts-line);
border-radius: 0.5rem; border-radius: 0.75rem;
background: var(--ts-surface);
legend { legend {
padding: 0 0.25rem; padding: 0 0.375rem;
color: var(--ts-muted); color: var(--ts-ink-soft);
font-size: 0.8125rem; font-size: 0.8125rem;
font-weight: 600;
} }
} }
.ts-poll-editor__row { .ts-poll-editor__row {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
align-items: center; align-items: flex-end;
.ts-input { pi-text-field {
flex: 1; flex: 1;
font: inherit;
} }
} }
.ts-poll-editor__actions { .ts-poll-editor__actions {
display: flex; 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;
} }

View File

@@ -318,20 +318,22 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
@if (reminder.note) { @if (reminder.note) {
· «{{ reminder.note }}» · «{{ reminder.note }}»
} }
<button <span class="ts-reminder__actions">
type="button" <pi-action-button
class="ts-reminder__action" size="x-small"
(click)="openReminder()" color="grey"
> appearance="outline"
Cambiar text="Cambiar"
</button> (execute)="openReminder()"
<button />
type="button" <pi-action-button
class="ts-reminder__action" size="x-small"
(click)="facade.setReminder(null)" color="grey"
> appearance="ghost"
Quitar text="Quitar"
</button> (execute)="facade.setReminder(null)"
/>
</span>
</p> </p>
} }
@@ -382,32 +384,34 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
} }
<div class="ts-reviews__actions"> <div class="ts-reviews__actions">
@if (review.reviewer.id === facade.me()) { @if (review.reviewer.id === facade.me()) {
<button <pi-icon-button
type="button" icon="check"
class="ts-reviews__button is-ok" color="primary"
(click)="openReviewAnswer(review.id, approved)" size="small"
> text="Todo bien"
✓ Todo bien (execute)="openReviewAnswer(review.id, approved)"
</button> />
<button <pi-icon-button
type="button" icon="edit"
class="ts-reviews__button" color="grey"
(click)="openReviewAnswer(review.id, changes)" appearance="outline"
> size="small"
Pedir cambios text="Pedir cambios"
</button> (execute)="openReviewAnswer(review.id, changes)"
/>
} }
@if ( @if (
review.requester.id === facade.me() || review.requester.id === facade.me() ||
project.canManage() project.canManage()
) { ) {
<button <pi-action-button
type="button" size="small"
class="ts-reviews__link" color="grey"
(click)="facade.cancelReview(review.id)" appearance="ghost"
> [hasPadding]="false"
Retirar text="Retirar"
</button> (execute)="facade.cancelReview(review.id)"
/>
} }
</div> </div>
} @else { } @else {
@@ -968,20 +972,24 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
· cerrada{{ · cerrada{{
poll.winner === null ? ', sin ganadora' : '' 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> </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> </div>
} }
@if ( @if (
@@ -1072,31 +1080,27 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
" "
novalidate novalidate
> >
<label class="ts-resolve__label"> <pi-textarea
Conclusión (opcional): quedará como decisión, arriba del
ticket
<textarea
class="ts-input ts-resolve__text"
rows="2"
maxlength="20000"
placeholder="Vamos con…" placeholder="Vamos con…"
[value]="conclusion()" [(value)]="conclusion"
(input)="conclusion.set($any($event.target).value)" >
></textarea> <pi-label
</label> >Conclusión (opcional): quedará como decisión, arriba
del ticket</pi-label
>
</pi-textarea>
<div class="ts-reply__actions"> <div class="ts-reply__actions">
<pi-action-button <pi-action-button
color="primary" color="primary"
text="Resolver" text="Resolver"
(execute)="resolve(thread.comment)" (execute)="resolve(thread.comment)"
/> />
<button <pi-action-button
type="button" color="grey"
class="ts-plain-link" appearance="ghost"
(click)="resolving.set(null)" text="Cancelar"
> (execute)="resolving.set(null)"
Cancelar />
</button>
</div> </div>
</form> </form>
} @else if (replyingTo() === thread.comment.id) { } @else if (replyingTo() === thread.comment.id) {
@@ -1211,64 +1215,71 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
/> />
@if (pollOn()) { @if (pollOn()) {
<fieldset class="ts-poll-editor"> <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) { @for (option of pollOptions(); track $index) {
<div class="ts-poll-editor__row"> <div class="ts-poll-editor__row">
<input <pi-text-field
class="ts-input" [placeholder]="
maxlength="80" $index === 0 ? 'Vamos con Stripe' : 'Otra opción'
[attr.aria-label]="'Opción ' + ($index + 1)"
[placeholder]="'Opción ' + ($index + 1)"
[value]="option"
(input)="
setPollOption($index, $any($event.target).value)
" "
/> [value]="option"
(valueChange)="setPollOption($index, $event)"
>
<pi-label>Opción {{ $index + 1 }}</pi-label>
</pi-text-field>
@if (pollOptions().length > 2) { @if (pollOptions().length > 2) {
<button <pi-icon-button
type="button" icon="trash"
class="ts-poll__close" color="grey"
appearance="ghost"
[attr.aria-label]=" [attr.aria-label]="
'Quitar la opción ' + ($index + 1) 'Quitar la opción ' + ($index + 1)
" "
(click)="removePollOption($index)" (execute)="removePollOption($index)"
> />
×
</button>
} }
</div> </div>
} }
<div class="ts-poll-editor__actions"> <div class="ts-poll-editor__actions">
@if (pollOptions().length < 6) { @if (pollOptions().length < 6) {
<button <pi-icon-button
type="button" icon="plus"
class="ts-poll__close" color="grey"
(click)="pollOptions.set([...pollOptions(), ''])" appearance="outline"
> size="small"
+ Añadir opción text="Añadir opción"
</button> (execute)="pollOptions.set([...pollOptions(), ''])"
/>
} }
<button <pi-action-button
type="button" size="small"
class="ts-poll__close" color="grey"
(click)="pollOn.set(false)" appearance="ghost"
> [hasPadding]="false"
Quitar la encuesta text="Quitar la encuesta"
</button> (execute)="pollOn.set(false)"
/>
</div> </div>
</fieldset> </fieldset>
} @else { }
<div> <div
<button class="ts-composer-tools"
type="button" role="group"
class="ts-poll__close" aria-label="Añadir al comentario"
(click)="startPoll()"
> >
📊 Añadir una encuesta @if (!pollOn()) {
</button> <pi-icon-button
</div> 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)" /> <ts-media-recorder (recorded)="addRecording($event)" />
</div>
<pi-file-upload <pi-file-upload
label="Adjuntar al comentario" label="Adjuntar al comentario"
[multiple]="true" [multiple]="true"
@@ -1449,30 +1460,20 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<p class="ts-muted"> <p class="ts-muted">
Le llegará un aviso y lo verá en «Mi trabajo» hasta que responda. Le llegará un aviso y lo verá en «Mi trabajo» hasta que responda.
</p> </p>
<label class="ts-reminder__field"> <pi-select-field
Quién placeholder="Elige a alguien…"
<select [options]="reviewerOptions()"
class="ts-input" [value]="reviewer() || null"
[value]="reviewer()" (valueChange)="reviewer.set($event ?? '')"
(change)="reviewer.set($any($event.target).value)"
> >
<option value="">Elige a alguien…</option> <pi-label [isRequired]="true">Quién</pi-label>
@for (member of reviewers(); track member.id) { </pi-select-field>
<option [value]="member.id">{{ member.name }}</option> <pi-textarea
}
</select>
</label>
<label class="ts-reminder__field">
Qué mirar (opcional)
<textarea
class="ts-input ts-resolve__text"
rows="3"
maxlength="1000"
placeholder="Revisa el flujo en móvil, sobre todo el pago" placeholder="Revisa el flujo en móvil, sobre todo el pago"
[value]="reviewNote()" [(value)]="reviewNote"
(input)="reviewNote.set($any($event.target).value)" >
></textarea> <pi-label>Qué mirar (opcional)</pi-label>
</label> </pi-textarea>
<pi-action-button <pi-action-button
size="w100" size="w100"
color="primary" color="primary"
@@ -1489,20 +1490,20 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
(submit)="$event.preventDefault(); sendReviewAnswer()" (submit)="$event.preventDefault(); sendReviewAnswer()"
novalidate 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 answering()?.verdict === approved
? 'Un comentario (opcional)' ? 'Un comentario (opcional)'
: 'Qué hay que cambiar' : 'Qué hay que cambiar'
}} }}</pi-label>
<textarea </pi-textarea>
class="ts-input ts-resolve__text"
rows="4"
maxlength="1000"
[value]="reviewAnswer()"
(input)="reviewAnswer.set($any($event.target).value)"
></textarea>
</label>
<pi-action-button <pi-action-button
size="w100" size="w100"
color="primary" color="primary"
@@ -1522,7 +1523,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<ng-template #reminderForm> <ng-template #reminderForm>
<form <form
class="ts-form" class="ts-form"
(submit)="$event.preventDefault(); saveReminder(reminderAt())" (submit)="$event.preventDefault(); saveCustomReminder()"
novalidate novalidate
> >
<p class="ts-muted"> <p class="ts-muted">
@@ -1531,42 +1532,40 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
</p> </p>
<div class="ts-reminder__choices" role="group" aria-label="Cuándo"> <div class="ts-reminder__choices" role="group" aria-label="Cuándo">
@for (choice of reminderChoices(); track choice.label) { @for (choice of reminderChoices(); track choice.label) {
<button <pi-action-button
type="button" size="w100"
class="ts-reminder__choice" color="grey"
(click)="saveReminder(choice.at)" appearance="outline"
> [text]="choice.label + ' · ' + when(choice.at)"
<span>{{ choice.label }}</span> (execute)="saveReminder(choice.at)"
<span class="ts-muted">{{ when(choice.at) }}</span> />
</button>
} }
</div> </div>
<label class="ts-reminder__field"> <fieldset class="ts-reminder__custom">
Otro momento <legend>Otro momento</legend>
<input <pi-date-field [min]="today()" [(value)]="reminderDay">
class="ts-input" <pi-label>Día</pi-label>
type="datetime-local" </pi-date-field>
[min]="nowLocal()" <pi-select-field
[value]="reminderAt()" [options]="timeOptions"
(input)="reminderAt.set($any($event.target).value)" [value]="reminderTime()"
/> (valueChange)="reminderTime.set($event ?? '09:00')"
</label> >
<label class="ts-reminder__field"> <pi-label>Hora</pi-label>
Nota (opcional) </pi-select-field>
<input </fieldset>
class="ts-input" <pi-text-field
maxlength="280"
placeholder="Llamar a QA antes de la release" placeholder="Llamar a QA antes de la release"
[value]="reminderNote()" [(value)]="reminderNote"
(input)="reminderNote.set($any($event.target).value)" >
/> <pi-label>Nota (opcional)</pi-label>
</label> </pi-text-field>
<pi-action-button <pi-action-button
size="w100" size="w100"
color="primary" color="primary"
text="Recordármelo" text="Recordármelo"
[disabled]="!reminderAt()" [disabled]="!customReminder()"
(execute)="saveReminder(reminderAt())" (execute)="saveCustomReminder()"
/> />
</form> </form>
</ng-template> </ng-template>
@@ -1698,9 +1697,23 @@ export class IssuePage {
) )
.map((m) => ({ id: m.user.id, name: m.user.name })), .map((m) => ({ id: m.user.id, name: m.user.name })),
); );
/** The reminder being written: `datetime-local` value and note. */ protected readonly reviewerOptions = computed<PiSelectFieldOption<string>[]>(
protected readonly reminderAt = signal(''); () => 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 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 reminderChoices = signal<ReminderChoice[]>([]);
protected readonly when = whenLabel; protected readonly when = whenLabel;
private readonly editForm = private readonly editForm =
@@ -2158,12 +2171,18 @@ export class IssuePage {
protected openReminder(): void { protected openReminder(): void {
const current = this.facade.issue()?.myReminder; const current = this.facade.issue()?.myReminder;
this.reminderChoices.set(reminderChoices()); 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.reminderNote.set(current?.note ?? '');
this.drawer.open('Recordármelo', this.reminderForm()); 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 { protected saveReminder(at: string): void {
if (!at) return; if (!at) return;
const when = new Date(at); const when = new Date(at);
@@ -2172,9 +2191,11 @@ export class IssuePage {
this.drawer.closeAll(); this.drawer.closeAll();
} }
/** Now, as the `min` of a `datetime-local` input. */ /** The first day a reminder can be set on. */
protected nowLocal(): string { protected today(): Date {
return toLocalInput(new Date()); const day = new Date();
day.setHours(0, 0, 0, 0);
return day;
} }
protected move(to: string): void { protected move(to: string): void {
@@ -2303,7 +2324,7 @@ export class IssuePage {
protected setPollOption(index: number, value: string): void { protected setPollOption(index: number, value: string): void {
this.pollOptions.update((options) => 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). */ /** 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'); 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'}`;
} }

View File

@@ -6,6 +6,7 @@ import {
output, output,
signal, signal,
} from '@angular/core'; } from '@angular/core';
import { PiIconButtonComponent } from '@pi/ui';
import { ToastService } from '@task-stream/web/shared/ui'; import { ToastService } from '@task-stream/web/shared/ui';
/** Long enough to show a bug, short enough to stay under the upload limit. */ /** Long enough to show a bug, short enough to stay under the upload limit. */
@@ -42,68 +43,65 @@ function mimeFor(kind: Kind): string {
@Component({ @Component({
selector: 'ts-media-recorder', selector: 'ts-media-recorder',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiIconButtonComponent],
template: ` template: `
@if (recording(); as kind) { @if (recording(); as kind) {
<span class="ts-recorder__live" role="status"> <span class="ts-recorder__live" role="status">
<span class="ts-recorder__dot" aria-hidden="true"></span> <span class="ts-recorder__dot" aria-hidden="true"></span>
Grabando {{ kind === 'screen' ? 'la pantalla' : 'tu voz' }} · Grabando {{ kind === 'screen' ? 'la pantalla' : 'tu voz' }}
{{ clock(elapsed()) }} / {{ clock(max) }} <span class="ts-recorder__clock"
>{{ clock(elapsed()) }} / {{ clock(max) }}</span
>
</span> </span>
<button type="button" class="ts-recorder__button" (click)="stop()"> <pi-icon-button
■ Parar icon="stop"
</button> color="warn"
appearance="outline"
size="small"
text="Parar"
(execute)="stop()"
/>
} @else { } @else {
@if (canScreen) { @if (canScreen) {
<button <pi-icon-button
type="button" icon="video"
class="ts-recorder__button" 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" title="Graba la pantalla (y tu voz, si das permiso al micrófono) y lo adjunta al comentario"
(click)="start('screen')" (execute)="start('screen')"
> />
🎥 Grabar pantalla
</button>
} }
@if (canVoice) { @if (canVoice) {
<button <pi-icon-button
type="button" icon="mic"
class="ts-recorder__button" color="grey"
appearance="outline"
size="small"
text="Grabar audio"
title="Graba un audio y lo adjunta al comentario" title="Graba un audio y lo adjunta al comentario"
(click)="start('voice')" (execute)="start('voice')"
> />
🎙 Grabar audio
</button>
} }
} }
`, `,
styles: ` styles: `
:host { :host {
display: flex; display: contents;
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;
}
} }
.ts-recorder__live { .ts-recorder__live {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.5rem; 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; font-variant-numeric: tabular-nums;
} }
.ts-recorder__dot { .ts-recorder__dot {