feat: a calendar to subscribe to, and the backlog's corners
- Each person can subscribe their dates in Google Calendar, Outlook or
Apple Calendar: GET /api/calendar/{token}.ics (no session) brings
their unfinished tickets with a due date (assigned or followed), the
planned and running sprints and the planned releases of their
projects, as all-day events linking to the web. The token is made,
renewed or dropped under /me/calendar (and in GraphQL); in the web,
from «Calendario» in «Mi trabajo».
- The backlog panel keeps the selected rows' background inside its
rounded corners.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,43 +1,12 @@
|
||||
/** Internal type. DO NOT USE DIRECTLY. */
|
||||
type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
|
||||
/** Internal type. DO NOT USE DIRECTLY. */
|
||||
export type Incremental<T> =
|
||||
| T
|
||||
| {
|
||||
[P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never;
|
||||
};
|
||||
export type Incremental<T> = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };
|
||||
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
|
||||
export type MeQueryVariables = Exact<{ [key: string]: never }>;
|
||||
export type MeQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
export type MeQuery = {
|
||||
me: { id: string; name: string; email: string; appRoles: Array<string> };
|
||||
};
|
||||
|
||||
export const MeDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'query',
|
||||
name: { kind: 'Name', value: 'Me' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'me' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'email' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'appRoles' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<MeQuery, MeQueryVariables>;
|
||||
export type MeQuery = { me: { id: string, name: string, email: string, appRoles: Array<string> } };
|
||||
|
||||
|
||||
export const MeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"appRoles"}}]}}]}}]} as unknown as DocumentNode<MeQuery, MeQueryVariables>;
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './lib/notifications';
|
||||
export * from './lib/facades/projects.facade';
|
||||
export * from './lib/facades/calendar.facade';
|
||||
export * from './lib/facades/project.facade';
|
||||
export * from './lib/facades/board.facade';
|
||||
export * from './lib/facades/issue.facade';
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { inject, Injectable, signal } from '@angular/core';
|
||||
import { CalendarFeed, CalendarGateway } from '@task-stream/web/tracker/domain';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/**
|
||||
* My calendar subscription for presenters: the secret URL to paste into a
|
||||
* calendar app, made, renewed or dropped here. Per person and seldom
|
||||
* touched, so it lives in this facade alone, without a store.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CalendarFacade {
|
||||
private readonly gateway = inject(CalendarGateway);
|
||||
|
||||
readonly feed = signal<CalendarFeed | null>(null);
|
||||
readonly loaded = signal(false);
|
||||
readonly busy = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
load(): void {
|
||||
this.run(this.gateway.mine());
|
||||
}
|
||||
|
||||
/** A new URL: the previous one, if any, stops working. */
|
||||
create(): void {
|
||||
this.run(this.gateway.create());
|
||||
}
|
||||
|
||||
revoke(): void {
|
||||
this.busy.set(true);
|
||||
this.gateway.revoke().subscribe({
|
||||
next: () => this.done(null),
|
||||
error: () => this.failed(),
|
||||
});
|
||||
}
|
||||
|
||||
private run(call: Observable<CalendarFeed | null>): void {
|
||||
this.busy.set(true);
|
||||
this.error.set(null);
|
||||
call.subscribe({
|
||||
next: (feed) => this.done(feed),
|
||||
error: () => this.failed(),
|
||||
});
|
||||
}
|
||||
|
||||
private done(feed: CalendarFeed | null): void {
|
||||
this.feed.set(feed);
|
||||
this.loaded.set(true);
|
||||
this.busy.set(false);
|
||||
}
|
||||
|
||||
private failed(): void {
|
||||
this.loaded.set(true);
|
||||
this.busy.set(false);
|
||||
this.error.set('No se ha podido. Inténtalo de nuevo en un momento.');
|
||||
}
|
||||
}
|
||||
@@ -429,6 +429,13 @@ export interface Notification {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** My calendar subscription: a secret URL calendar apps read (iCalendar). */
|
||||
export interface CalendarFeed {
|
||||
/** Absolute: what goes into Google Calendar, Outlook or Apple Calendar. */
|
||||
url: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface NotificationSettings {
|
||||
/** Copies by e-mail. */
|
||||
email: boolean;
|
||||
|
||||
@@ -2,21 +2,16 @@ import { Observable } from 'rxjs';
|
||||
import {
|
||||
Activity,
|
||||
Attachment,
|
||||
Automation,
|
||||
AutomationInput,
|
||||
BoardColumn,
|
||||
CalendarFeed,
|
||||
CommandResult,
|
||||
Comment,
|
||||
Epic,
|
||||
GitIntegration,
|
||||
GitRepository,
|
||||
Automation,
|
||||
AutomationInput,
|
||||
ImportReport,
|
||||
Webhook,
|
||||
WebhookDelivery,
|
||||
SavedFilter,
|
||||
Sprint,
|
||||
SprintBurndown,
|
||||
SavedFilterCriteria,
|
||||
CommandResult,
|
||||
Issue,
|
||||
IssuePage,
|
||||
IssueSummary,
|
||||
@@ -27,7 +22,13 @@ import {
|
||||
ProjectStats,
|
||||
Release,
|
||||
ReleaseBurnup,
|
||||
SavedFilter,
|
||||
SavedFilterCriteria,
|
||||
Sprint,
|
||||
SprintBurndown,
|
||||
UserDirectoryEntry,
|
||||
Webhook,
|
||||
WebhookDelivery,
|
||||
} from '../models/models';
|
||||
import { ProjectSettings } from '../models/workflow';
|
||||
import {
|
||||
@@ -332,6 +333,14 @@ export abstract class UsersGateway {
|
||||
}
|
||||
|
||||
/** The signed-in person's notifications. */
|
||||
export abstract class CalendarGateway {
|
||||
/** Mine, if I made one. */
|
||||
abstract mine(): Observable<CalendarFeed | null>;
|
||||
/** A new secret URL; the previous one stops working. */
|
||||
abstract create(): Observable<CalendarFeed>;
|
||||
abstract revoke(): Observable<void>;
|
||||
}
|
||||
|
||||
export abstract class NotificationsGateway {
|
||||
abstract mine(
|
||||
unreadOnly: boolean,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { API_CONFIG } from '@task-stream/web/shared/infrastructure';
|
||||
import { CalendarFeed, CalendarGateway } from '@task-stream/web/tracker/domain';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import {
|
||||
CreateCalendarFeedDocument,
|
||||
MyCalendarFeedDocument,
|
||||
RevokeCalendarFeedDocument,
|
||||
} from '../graphql/generated';
|
||||
import { GraphqlOperations } from './graphql-operations';
|
||||
|
||||
@Injectable()
|
||||
export class GraphqlCalendarGateway extends CalendarGateway {
|
||||
private readonly gql = inject(GraphqlOperations);
|
||||
private readonly api = inject(API_CONFIG);
|
||||
|
||||
mine(): Observable<CalendarFeed | null> {
|
||||
return this.gql
|
||||
.query(MyCalendarFeedDocument)
|
||||
.pipe(
|
||||
map((d) => (d.myCalendarFeed ? this.toFeed(d.myCalendarFeed) : null)),
|
||||
);
|
||||
}
|
||||
|
||||
create(): Observable<CalendarFeed> {
|
||||
return this.gql
|
||||
.mutate(CreateCalendarFeedDocument, {})
|
||||
.pipe(map((d) => this.toFeed(d.createCalendarFeed)));
|
||||
}
|
||||
|
||||
revoke(): Observable<void> {
|
||||
return this.gql
|
||||
.mutate(RevokeCalendarFeedDocument, {})
|
||||
.pipe(map(() => undefined));
|
||||
}
|
||||
|
||||
/** The API answers the secret; calendar apps need the whole address. */
|
||||
private toFeed(feed: { token: string; createdAt: string }): CalendarFeed {
|
||||
const base = new URL(
|
||||
this.api.restUrl.replace(/\/+$/, '') + '/',
|
||||
location.origin,
|
||||
);
|
||||
return {
|
||||
url: new URL(`calendar/${feed.token}.ics`, base).toString(),
|
||||
createdAt: feed.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
query MyCalendarFeed {
|
||||
myCalendarFeed {
|
||||
token
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
|
||||
mutation CreateCalendarFeed {
|
||||
createCalendarFeed {
|
||||
token
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
|
||||
mutation RevokeCalendarFeed {
|
||||
revokeCalendarFeed
|
||||
}
|
||||
@@ -438,6 +438,26 @@ export type DeleteAutomationMutationVariables = Exact<{
|
||||
|
||||
export type DeleteAutomationMutation = { deleteAutomation: boolean };
|
||||
|
||||
export type MyCalendarFeedQueryVariables = Exact<{ [key: string]: never }>;
|
||||
|
||||
export type MyCalendarFeedQuery = {
|
||||
myCalendarFeed: { token: string; createdAt: string } | null;
|
||||
};
|
||||
|
||||
export type CreateCalendarFeedMutationVariables = Exact<{
|
||||
[key: string]: never;
|
||||
}>;
|
||||
|
||||
export type CreateCalendarFeedMutation = {
|
||||
createCalendarFeed: { token: string; createdAt: string };
|
||||
};
|
||||
|
||||
export type RevokeCalendarFeedMutationVariables = Exact<{
|
||||
[key: string]: never;
|
||||
}>;
|
||||
|
||||
export type RevokeCalendarFeedMutation = { revokeCalendarFeed: boolean };
|
||||
|
||||
export type UserRefFieldsFragment = { id: string; name: string };
|
||||
|
||||
export type ProjectFieldsFragment = {
|
||||
@@ -3796,6 +3816,83 @@ export const DeleteAutomationDocument = {
|
||||
DeleteAutomationMutation,
|
||||
DeleteAutomationMutationVariables
|
||||
>;
|
||||
export const MyCalendarFeedDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'query',
|
||||
name: { kind: 'Name', value: 'MyCalendarFeed' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'myCalendarFeed' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'token' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<MyCalendarFeedQuery, MyCalendarFeedQueryVariables>;
|
||||
export const CreateCalendarFeedDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'CreateCalendarFeed' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'createCalendarFeed' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'token' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
CreateCalendarFeedMutation,
|
||||
CreateCalendarFeedMutationVariables
|
||||
>;
|
||||
export const RevokeCalendarFeedDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'RevokeCalendarFeed' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'revokeCalendarFeed' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
RevokeCalendarFeedMutation,
|
||||
RevokeCalendarFeedMutationVariables
|
||||
>;
|
||||
export const GitIntegrationDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
CommentsGateway,
|
||||
IssuesGateway,
|
||||
NotificationsGateway,
|
||||
CalendarGateway,
|
||||
ProjectsGateway,
|
||||
ReleasesGateway,
|
||||
SavedFiltersGateway,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
} from './adapters/graphql-collaboration.gateways';
|
||||
import { GraphqlIssuesGateway } from './adapters/graphql-issues.gateway';
|
||||
import { GraphqlNotificationsGateway } from './adapters/graphql-notifications.gateway';
|
||||
import { GraphqlCalendarGateway } from './adapters/graphql-calendar.gateway';
|
||||
import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway';
|
||||
import { HttpProposalsGateway } from './adapters/http-proposals.gateway';
|
||||
import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway';
|
||||
@@ -51,6 +53,7 @@ export function provideTrackerInfrastructure(): EnvironmentProviders {
|
||||
{ provide: ProposalsGateway, useClass: HttpProposalsGateway },
|
||||
{ provide: UsersGateway, useClass: GraphqlUsersGateway },
|
||||
{ provide: NotificationsGateway, useClass: GraphqlNotificationsGateway },
|
||||
{ provide: CalendarGateway, useClass: GraphqlCalendarGateway },
|
||||
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
|
||||
{ provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway },
|
||||
{ provide: SprintsGateway, useClass: GraphqlSprintsGateway },
|
||||
|
||||
@@ -178,6 +178,8 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
|
||||
margin: 0;
|
||||
padding: 0.25rem 0;
|
||||
list-style: none;
|
||||
/* Selected rows have a background: kept inside the rounded corners. */
|
||||
overflow: hidden;
|
||||
}
|
||||
.ts-backlog__row {
|
||||
display: grid;
|
||||
|
||||
@@ -6,9 +6,12 @@ import {
|
||||
DOCUMENT,
|
||||
inject,
|
||||
OnInit,
|
||||
TemplateRef,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { PiIconComponent, PiSkeletonComponent } from '@pi/ui';
|
||||
import { DrawerService } from '@task-stream/web/shared/ui';
|
||||
import {
|
||||
HomeFacade,
|
||||
ProjectsFacade,
|
||||
@@ -28,6 +31,7 @@ import {
|
||||
statusLook,
|
||||
typeLook,
|
||||
} from '../ui/copy';
|
||||
import { CalendarFeedComponent } from '../ui/calendar-feed';
|
||||
|
||||
const VISIBLE = 12;
|
||||
|
||||
@@ -40,7 +44,12 @@ const VISIBLE = 12;
|
||||
@Component({
|
||||
selector: 'ts-my-work',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink, PiIconComponent, PiSkeletonComponent],
|
||||
imports: [
|
||||
RouterLink,
|
||||
PiIconComponent,
|
||||
PiSkeletonComponent,
|
||||
CalendarFeedComponent,
|
||||
],
|
||||
template: `
|
||||
@if (!home.loaded()) {
|
||||
<div class="ts-work">
|
||||
@@ -58,6 +67,14 @@ const VISIBLE = 12;
|
||||
<span class="ts-muted ts-mono"
|
||||
>{{ home.mineTotal() }} abiertos</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-plain-link ts-work__calendar"
|
||||
(click)="openCalendar()"
|
||||
>
|
||||
<pi-icon icon="calendar" size="x-small" aria-hidden="true" />
|
||||
Calendario
|
||||
</button>
|
||||
</header>
|
||||
@if (home.qaFailed()) {
|
||||
<p class="ts-work__alert" role="status">
|
||||
@@ -228,6 +245,9 @@ const VISIBLE = 12;
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<ng-template #calendarPanel>
|
||||
<ts-calendar-feed />
|
||||
</ng-template>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
@@ -250,14 +270,20 @@ const VISIBLE = 12;
|
||||
.ts-work__panel {
|
||||
padding: 1.375rem 1.5rem 1.25rem;
|
||||
}
|
||||
.ts-work__calendar {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.ts-work__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.875rem;
|
||||
h2 {
|
||||
margin: 0;
|
||||
/* Pushes the count and the calendar link to the right. */
|
||||
margin: 0 auto 0 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -415,6 +441,9 @@ const VISIBLE = 12;
|
||||
})
|
||||
export class MyWorkComponent implements OnInit {
|
||||
protected readonly home = inject(HomeFacade);
|
||||
private readonly drawer = inject(DrawerService);
|
||||
private readonly calendarPanel =
|
||||
viewChild.required<TemplateRef<unknown>>('calendarPanel');
|
||||
private readonly projects = inject(ProjectsFacade);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
@@ -483,4 +512,8 @@ export class MyWorkComponent implements OnInit {
|
||||
release.projectId
|
||||
);
|
||||
}
|
||||
|
||||
protected openCalendar(): void {
|
||||
this.drawer.open('Tu calendario', this.calendarPanel());
|
||||
}
|
||||
}
|
||||
|
||||
173
libs/web/tracker/presenters/src/lib/ui/calendar-feed.ts
Normal file
173
libs/web/tracker/presenters/src/lib/ui/calendar-feed.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
OnInit,
|
||||
} from '@angular/core';
|
||||
import { PiActionButtonComponent, PiNoteComponent } from '@pi/ui';
|
||||
import { ToastService } from '@task-stream/web/shared/ui';
|
||||
import { CalendarFacade } from '@task-stream/web/tracker/application';
|
||||
|
||||
/**
|
||||
* My calendar URL: what to paste into Google Calendar, Outlook or Apple
|
||||
* Calendar to see my due dates, sprints and releases there. Renewing it
|
||||
* cuts off whoever had the old one.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ts-calendar-feed',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PiActionButtonComponent, PiNoteComponent],
|
||||
template: `
|
||||
<div class="ts-cal">
|
||||
<p class="ts-muted">
|
||||
Tus tickets sin terminar con fecha límite (los asignados y los que
|
||||
sigues), los sprints en marcha o planificados y las releases con fecha
|
||||
de tus proyectos, en tu calendario. Se actualiza solo, normalmente cada
|
||||
pocas horas.
|
||||
</p>
|
||||
@if (calendar.error(); as error) {
|
||||
<pi-note
|
||||
gap="xs"
|
||||
status="error"
|
||||
appearance="fill"
|
||||
type="status"
|
||||
title="Calendario"
|
||||
[text]="error"
|
||||
/>
|
||||
}
|
||||
@if (!calendar.loaded()) {
|
||||
<p class="ts-muted">Cargando…</p>
|
||||
} @else if (calendar.feed(); as feed) {
|
||||
<label class="ts-cal__label" for="ts-cal-url"
|
||||
>Dirección del calendario</label
|
||||
>
|
||||
<div class="ts-cal__url">
|
||||
<input
|
||||
id="ts-cal-url"
|
||||
readonly
|
||||
[value]="feed.url"
|
||||
(focus)="$any($event.target).select()"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="primary"
|
||||
text="Copiar"
|
||||
(execute)="copy(feed.url)"
|
||||
/>
|
||||
</div>
|
||||
<p class="ts-cal__warn">
|
||||
Es privada: quien la tenga ve estas fechas y títulos. Si se ha
|
||||
compartido de más, genera otra.
|
||||
</p>
|
||||
<details class="ts-cal__how">
|
||||
<summary>Cómo añadirla</summary>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Google Calendar</strong>: Otros calendarios → «+» → Desde
|
||||
URL.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Outlook</strong>: Agregar calendario → Suscribirse desde
|
||||
la Web.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Apple Calendar</strong>: Archivo → Nueva suscripción a
|
||||
calendario.
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
<div class="ts-cal__actions">
|
||||
<pi-action-button
|
||||
size="small"
|
||||
appearance="outline"
|
||||
color="grey"
|
||||
text="Generar otra"
|
||||
[disabled]="calendar.busy()"
|
||||
(execute)="calendar.create()"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
appearance="ghost"
|
||||
color="grey"
|
||||
text="Desactivar"
|
||||
[disabled]="calendar.busy()"
|
||||
(execute)="calendar.revoke()"
|
||||
/>
|
||||
</div>
|
||||
} @else {
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
text="Crear la dirección del calendario"
|
||||
[disabled]="calendar.busy()"
|
||||
(execute)="calendar.create()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
.ts-cal {
|
||||
display: grid;
|
||||
gap: 0.875rem;
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
.ts-cal__label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
.ts-cal__url {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-top: -0.5rem;
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: 1px solid var(--ts-line-strong, var(--ts-line));
|
||||
border-radius: 0.5rem;
|
||||
background: var(--ts-surface, transparent);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-family: var(--ts-mono, ui-monospace, monospace);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
.ts-cal__warn {
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.ts-cal__how {
|
||||
font-size: 0.875rem;
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
ul {
|
||||
margin: 0.5rem 0 0;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
}
|
||||
.ts-cal__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class CalendarFeedComponent implements OnInit {
|
||||
protected readonly calendar = inject(CalendarFacade);
|
||||
private readonly toast = inject(ToastService);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.calendar.load();
|
||||
}
|
||||
|
||||
protected copy(url: string): void {
|
||||
navigator.clipboard
|
||||
?.writeText(url)
|
||||
.then(() => this.toast.success('Dirección copiada'))
|
||||
.catch(() => this.toast.error('No se pudo copiar'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user