first commit

This commit is contained in:
2025-09-27 18:13:02 -04:00
commit 6e1b5407b7
253 changed files with 289900 additions and 0 deletions
@@ -0,0 +1,84 @@
<div class="flex flex-col gap-4">
<header class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<p class="text-xs uppercase tracking-wide text-obs-l-text-muted dark:text-obs-d-text-muted">Calendrier</p>
<h2 class="text-lg font-semibold text-obs-l-text-main dark:text-obs-d-text-main">
{{ viewDate() | calendarDate: 'monthViewTitle' : 'fr' }}
</h2>
@if (selectionLabel(); as label) {
<span class="mt-1 inline-flex items-center rounded-full bg-obs-l-bg-secondary/70 px-2 py-0.5 text-xs font-medium text-obs-l-text-muted dark:bg-obs-d-bg-secondary/60 dark:text-obs-d-text-muted">
{{ label }}
</span>
}
</div>
<div class="flex items-center justify-end gap-2">
<button
type="button"
(click)="shiftMonth(-1)"
aria-label="Mois précédent"
class="h-9 w-9 rounded-full border border-obs-l-border bg-obs-l-bg-main text-lg leading-none text-obs-l-text-main transition hover:bg-obs-l-bg-secondary dark:border-obs-d-border dark:bg-obs-d-bg-main dark:text-obs-d-text-main dark:hover:bg-obs-d-bg-secondary"
>
‹
</button>
<button
type="button"
(click)="goToToday()"
aria-label="Revenir à aujourd'hui"
class="inline-flex items-center gap-1 rounded-full border border-obs-l-border bg-obs-l-bg-main px-3 py-1.5 text-sm font-medium text-obs-l-text-main transition hover:bg-obs-l-bg-secondary dark:border-obs-d-border dark:bg-obs-d-bg-main dark:text-obs-d-text-main dark:hover:bg-obs-d-bg-secondary"
>
Aujourd'hui
</button>
<button
type="button"
(click)="shiftMonth(1)"
aria-label="Mois suivant"
class="h-9 w-9 rounded-full border border-obs-l-border bg-obs-l-bg-main text-lg leading-none text-obs-l-text-main transition hover:bg-obs-l-bg-secondary dark:border-obs-d-border dark:bg-obs-d-bg-main dark:text-obs-d-text-main dark:hover:bg-obs-d-bg-secondary"
>
›
</button>
</div>
</header>
<div class="rounded-2xl border border-obs-l-border bg-obs-l-bg-main/80 shadow-panel backdrop-blur-xs dark:border-obs-d-border dark:bg-obs-d-bg-secondary/80">
<mwl-calendar-month-view
class="calendar-compact"
[viewDate]="viewDate()"
[refresh]="refresh$"
[cellTemplate]="dayCellTemplate"
[weekStartsOn]="1"
[events]="[]"
(pointerup)="onDatePointerUp()"
></mwl-calendar-month-view>
</div>
<div class="flex flex-wrap items-center justify-center gap-3 text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">
<span class="inline-flex items-center gap-2 rounded-full bg-obs-l-bg-secondary/60 px-3 py-1 dark:bg-obs-d-bg-secondary/60">
<span class="h-2 w-2 rounded-full bg-gradient-to-r from-green-500 to-emerald-400"></span>
Création
</span>
<span class="inline-flex items-center gap-2 rounded-full bg-obs-l-bg-secondary/60 px-3 py-1 dark:bg-obs-d-bg-secondary/60">
<span class="h-2 w-2 rounded-full bg-gradient-to-r from-indigo-400 to-indigo-600"></span>
Modification
</span>
</div>
</div>
<ng-template #dayCellTemplate let-day="day">
<button
type="button"
class="day-cell"
[ngClass]="getCellClasses(day)"
(pointerdown)="onDatePointerDown(day, $event)"
(pointerenter)="onDatePointerEnter(day)"
>
<span class="day-number">{{ day.date | date: 'd' }}</span>
<div class="indicators">
@if (hasCreated(day.date)) {
<span class="indicator cal-event-created" aria-hidden="true"></span>
}
@if (hasUpdated(day.date)) {
<span class="indicator cal-event-updated" aria-hidden="true"></span>
}
</div>
</button>
</ng-template>
@@ -0,0 +1,272 @@
.calendar-card {
width: 100%;
max-width: 300px;
background: rgba(248, 250, 252, 0.94);
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 0.75rem;
padding: 0.6rem 0.65rem 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
box-shadow: 0 12px 32px -20px rgba(15, 23, 42, 0.28);
overflow: hidden;
}
.calendar-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
}
.toolbar-controls {
display: inline-flex;
align-items: center;
gap: 0.35rem;
button {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
padding: 0.25rem 0.75rem;
border-radius: 0.55rem;
border: 1px solid rgba(184, 162, 148, 0.45);
background: rgba(255, 255, 255, 0.65);
color: inherit;
font-size: 0.8rem;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease;
&:hover,
&:focus-visible {
background: rgba(148, 163, 184, 0.22);
border-color: rgba(148, 163, 184, 0.6);
}
}
}
.calendar-legend {
display: flex;
justify-content: center;
gap: 0.75rem;
font-size: 0.6rem;
color: rgba(47, 60, 86, 0.65);
margin-top: 3.2rem;
.legend-item {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.5rem;
border-radius: 999px;
background: rgba(148, 163, 184, 0.18);
backdrop-filter: blur(4px);
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.legend-dot.legend-created {
background: linear-gradient(135deg, #22c55e, #34d399);
}
.legend-dot.legend-updated {
background: linear-gradient(135deg, #8b5cf6, #6366f1);
}
}
.day-cell {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 1.4rem;
border-radius: 0.5rem;
padding: 0.15rem;
cursor: pointer;
transition: background 0.2s ease, border 0.2s ease;
border: 1px solid transparent;
&.is-outside {
opacity: 0.4;
}
&.is-today {
border-color: rgba(99, 102, 241, 0.55);
}
&.is-selected {
background: rgba(129, 140, 248, 0.16);
}
&.is-range-start,
&.is-range-end {
border-color: rgba(129, 140, 248, 0.75);
background: rgba(129, 140, 248, 0.28);
}
.day-number {
width: 1.2rem;
height: 1.2rem;
font-size: 0.65rem;
font-weight: 600;
line-height: 1;
border-radius: 0.45rem;
display: flex;
align-items: center;
justify-content: center;
color: inherit;
}
.indicators {
margin-top: 0.12rem;
display: flex;
flex-direction: column;
gap: 1px;
width: 100%;
.indicator {
display: block;
height: 2px;
border-radius: 9999px;
margin: 0.25px 0;
box-shadow: 0 0 4px rgba(15, 23, 42, 0.1);
}
.indicator.cal-event-created {
background: linear-gradient(90deg, #22c55e, #34d399);
}
.indicator.cal-event-updated {
background: linear-gradient(90deg, #8b5cf6, #6366f1);
}
}
}
:host-context(.dark) {
.calendar-card {
// background: rgba(34, 39, 54, 0.96);
background: #202123;
border-color: rgba(89, 102, 126, 0.5);
max-width: 280px;
padding: 0.6rem 0.65rem 1rem;
}
.toolbar-controls button {
background: #232527;
border-color: rgba(99, 102, 241, 0.35);
&:hover,
&:focus-visible {
background: rgba(129, 140, 248, 0.22);
border-color: rgba(129, 140, 248, 0.55);
}
}
.toolbar-controls {
gap: 0.25rem;
}
.toolbar-title {
gap: 0.1rem;
font-size: 0.8rem;
}
.toolbar-title .selection-label {
color: rgba(191, 214, 255, 0.75);
}
.calendar-legend {
color: rgba(210, 219, 237, 0.8);
gap: 0.85rem;
.legend-item {
background: rgba(99, 102, 241, 0.22);
}
}
.day-cell {
min-height: 1.25rem;
padding: 0.12rem;
&.is-selected {
background: rgba(129, 140, 248, 0.28);
}
&.is-range-start,
&.is-range-end {
background: rgba(129, 140, 248, 0.38);
border-color: rgba(167, 139, 250, 0.75);
box-shadow: 0 0 0 1px rgba(22, 163, 74, 0.25);
}
}
}
:host ::ng-deep .calendar-compact {
max-height: 172px;
min-height: 160px;
}
:host ::ng-deep .calendar-compact .cal-week {
gap: 0.18rem;
}
:host ::ng-deep .calendar-compact .cal-month-view {
background: rgba(248, 250, 252, 0.95);
border: 1px solid rgba(203, 213, 225, 0.45);
}
:host ::ng-deep .calendar-compact .cal-header {
padding: 0.15rem 0.3rem;
background: transparent;
}
:host ::ng-deep .calendar-compact .cal-header .cal-cell {
font-size: 0;
text-transform: uppercase;
padding: 0.15rem 0;
text-align: center;
}
:host ::ng-deep .calendar-compact .cal-header .cal-cell::first-letter {
font-size: 0.55rem;
font-weight: 700;
display: inline-block;
color: rgba(79, 70, 229, 0.85);
}
:host ::ng-deep .calendar-compact .cal-cell {
background: rgba(255, 255, 255, 0.85);
border-radius: 0.5rem;
border: 1px solid rgba(226, 232, 240, 0.6);
padding: 0.15rem;
}
:host-context(.dark) ::ng-deep .calendar-compact .cal-month-view {
// background: rgba(37, 44, 63, 0.95);
background: #2B2D46;
border-color: rgba(88, 103, 132, 0.55);
}
:host-context(.dark) ::ng-deep .calendar-compact .cal-cell {
// background: rgba(48, 58, 78, 0.92);
background: #2D2E30;
border-color: rgba(107, 114, 128, 0.4);
color: rgba(220, 230, 246, 0.95);
}
:host-context(.dark) ::ng-deep .calendar-compact .cal-day-number {
color: rgba(236, 253, 245, 0.9);
}
:host-context(.dark) ::ng-deep .calendar-compact .cal-header .cal-cell::first-letter {
color: rgba(167, 139, 250, 0.85);
}
@@ -0,0 +1,324 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
HostListener,
computed,
inject,
output,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import {
CalendarMonthViewComponent,
CalendarMonthViewDay,
CalendarDatePipe,
DateAdapter,
provideCalendar,
} from 'angular-calendar';
import { adapterFactory } from 'angular-calendar/date-adapters/date-fns';
import { MarkdownCalendarService } from '../../services/markdown-calendar.service';
import { FileMetadata } from '../../types';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Observable, Subject } from 'rxjs';
interface DayBucket {
created: FileMetadata[];
updated: FileMetadata[];
}
interface DateRange {
start: Date | null;
end: Date | null;
}
@Component({
selector: 'app-markdown-calendar',
standalone: true,
imports: [CommonModule, CalendarMonthViewComponent, CalendarDatePipe],
templateUrl: './markdown-calendar.component.html',
styleUrls: ['./markdown-calendar.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
provideCalendar({
provide: DateAdapter,
useFactory: adapterFactory,
}),
],
})
export class MarkdownCalendarComponent {
private readonly calendarService = inject(MarkdownCalendarService);
private readonly destroyRef = inject(DestroyRef);
readonly fileSelected = output<string>();
readonly searchResultsChange = output<FileMetadata[]>();
readonly searchStateChange = output<'idle' | 'loading' | 'error'>();
readonly searchErrorChange = output<string | null>();
readonly selectionSummaryChange = output<string | null>();
readonly requestSearchPanel = output<void>();
readonly viewDate = signal(this.normalizeDay(new Date()));
readonly metadata = signal<FileMetadata[]>([]);
readonly dayBuckets = computed<Map<string, DayBucket>>(() =>
this.calendarService.groupMetadataByDay(this.metadata())
);
readonly isLoading = signal<boolean>(false);
readonly loadError = signal<string | null>(null);
readonly searchResults = signal<FileMetadata[]>([]);
readonly isSearching = signal<boolean>(false);
readonly searchError = signal<string | null>(null);
readonly selectedRange = signal<DateRange>({ start: null, end: null });
readonly selectionLabel = computed(() => this.buildSelectionLabel());
readonly refresh$ = new Subject<void>();
private dragAnchor: Date | null = null;
private searchRequestId = 0;
constructor() {
this.loadMetadata();
}
// --- Lifecycle & data loading ------------------------------------------------
private loadMetadata(): void {
this.isLoading.set(true);
this.loadError.set(null);
this.calendarService
.getFilesMetadata()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (data) => {
this.metadata.set(data);
this.isLoading.set(false);
this.refresh$.next();
},
error: () => {
this.isLoading.set(false);
this.loadError.set('Impossible de charger les métadonnées des fichiers.');
},
});
}
// --- Calendar interactions ---------------------------------------------------
shiftMonth(offset: number): void {
const current = this.viewDate();
const shifted = new Date(current);
shifted.setMonth(current.getMonth() + offset, 1);
this.viewDate.set(this.normalizeDay(shifted));
this.refresh$.next();
}
goToToday(): void {
this.viewDate.set(this.normalizeDay(new Date()));
this.refresh$.next();
}
onDatePointerDown(day: CalendarMonthViewDay<Date>, event: PointerEvent): void {
if (event.button !== 0) {
return;
}
event.preventDefault();
const date = this.normalizeDay(day.date);
this.dragAnchor = date;
this.selectedRange.set({ start: date, end: date });
this.isSearching.set(false);
this.searchError.set(null);
this.searchResults.set([]);
this.searchRequestId++;
this.searchResultsChange.emit([]);
this.searchStateChange.emit('idle');
this.searchErrorChange.emit(null);
this.emitSelectionSummary();
this.requestSearchPanel.emit();
this.refresh$.next();
}
onDatePointerEnter(day: CalendarMonthViewDay<Date>): void {
if (!this.dragAnchor) {
return;
}
const hovered = this.normalizeDay(day.date);
const range = this.normalizeRange(this.dragAnchor, hovered);
this.selectedRange.set(range);
this.emitSelectionSummary();
this.requestSearchPanel.emit();
this.refresh$.next();
}
@HostListener('document:pointerup')
finishRangeSelection(): void {
this.finalizeSelection();
}
onDatePointerUp(): void {
this.finalizeSelection();
}
private finalizeSelection(): void {
if (!this.dragAnchor) {
return;
}
const currentRange = this.selectedRange();
const { start, end } = currentRange;
this.dragAnchor = null;
if (!start || !end) {
return;
}
if (start.getTime() === end.getTime()) {
this.fetchResultsForSingleDate(start);
} else {
this.fetchResultsForRange(start, end);
}
this.emitSelectionSummary();
this.refresh$.next();
}
// --- Result handling ---------------------------------------------------------
private fetchResultsForSingleDate(date: Date): void {
this.runSearch(this.calendarService.searchFilesByDate(date));
}
private fetchResultsForRange(start: Date, end: Date): void {
this.runSearch(this.calendarService.searchFilesByDateRange(start, end));
}
private runSearch(source$: Observable<FileMetadata[]>): void {
const token = ++this.searchRequestId;
this.isSearching.set(true);
this.searchError.set(null);
this.searchStateChange.emit('loading');
this.searchErrorChange.emit(null);
source$.subscribe({
next: (files: FileMetadata[]) => {
if (token !== this.searchRequestId) {
return;
}
this.searchResults.set(files);
this.isSearching.set(false);
this.searchStateChange.emit('idle');
this.searchResultsChange.emit(files);
},
error: () => {
if (token !== this.searchRequestId) {
return;
}
this.isSearching.set(false);
this.searchError.set('Recherche impossible pour cette période.');
this.searchStateChange.emit('error');
this.searchErrorChange.emit('Recherche impossible pour cette période.');
this.searchResultsChange.emit([]);
this.searchResults.set([]);
},
});
}
openFile(file: FileMetadata): void {
this.fileSelected.emit(file.id);
}
trackByFileId = (_: number, file: FileMetadata) => file.id;
// --- Template helpers --------------------------------------------------------
hasCreated(date: Date): boolean {
const bucket = this.getBucketForDate(date);
return !!bucket && bucket.created.length > 0;
}
hasUpdated(date: Date): boolean {
const bucket = this.getBucketForDate(date);
return !!bucket && bucket.updated.length > 0;
}
isInSelectedRange(date: Date): boolean {
const { start, end } = this.selectedRange();
if (!start || !end) {
return false;
}
const value = this.normalizeDay(date).getTime();
return value >= start.getTime() && value <= end.getTime();
}
isRangeStart(date: Date): boolean {
const { start } = this.selectedRange();
return !!start && this.normalizeDay(date).getTime() === start.getTime();
}
isRangeEnd(date: Date): boolean {
const { end } = this.selectedRange();
return !!end && this.normalizeDay(date).getTime() === end.getTime();
}
getCellClasses(day: CalendarMonthViewDay<Date>): Record<string, boolean> {
const date = this.normalizeDay(day.date);
return {
'is-outside': !day.inMonth,
'is-today': this.isToday(date),
'is-selected': this.isInSelectedRange(date),
'is-range-start': this.isRangeStart(date),
'is-range-end': this.isRangeEnd(date),
};
}
// --- Utilities ---------------------------------------------------------------
private buildSelectionLabel(): string | null {
const { start, end } = this.selectedRange();
if (!start || !end) {
return null;
}
const format = (date: Date) =>
new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: 'short',
year: 'numeric',
}).format(date);
const startLabel = format(start);
const endLabel = format(end);
return start.getTime() === end.getTime()
? startLabel
: `${startLabel} – ${endLabel}`;
}
private isToday(date: Date): boolean {
const today = this.normalizeDay(new Date());
return date.getTime() === today.getTime();
}
private normalizeDay(date: Date): Date {
const normalized = new Date(date);
normalized.setHours(0, 0, 0, 0);
return normalized;
}
private normalizeRange(start: Date, end: Date): DateRange {
const normalizedStart = this.normalizeDay(start);
const normalizedEnd = this.normalizeDay(end);
if (normalizedStart.getTime() <= normalizedEnd.getTime()) {
return { start: normalizedStart, end: normalizedEnd };
}
return { start: normalizedEnd, end: normalizedStart };
}
private getBucketForDate(date: Date): DayBucket | undefined {
const key = this.normalizeDay(date).toDateString();
return this.dayBuckets().get(key);
}
private emitSelectionSummary(): void {
this.selectionSummaryChange.emit(this.selectionLabel());
}
}