@hed-hog/lms 0.0.279 → 0.0.286

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,4 @@
1
+ ```markdown
1
2
  # @hed-hog/lms
2
3
 
3
4
  ## 1. Visão geral do módulo
@@ -676,3 +677,4 @@ const certificado = await prisma.certificate.create({
676
677
  ---
677
678
 
678
679
  Para mais detalhes sobre integração e uso, consulte a documentação geral do HedHog e os serviços que consomem o módulo `@hed-hog/lms`.
680
+ ```
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { Page, PageHeader } from '@/components/entity-list';
3
+ import { EmptyState, Page, PageHeader } from '@/components/entity-list';
4
4
  import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
5
5
  import { Badge } from '@/components/ui/badge';
6
6
  import { Button } from '@/components/ui/button';
@@ -215,9 +215,10 @@ function generateAulas(): Aula[] {
215
215
  for (let i = -10; i < 20; i++) {
216
216
  const dia = addDays(today, i);
217
217
  if (dia.getDay() === 0 || dia.getDay() === 6) continue;
218
+ const titulo = titulos[aulas.length % titulos.length]!;
218
219
  aulas.push({
219
220
  id: aulas.length + 1,
220
- titulo: titulos[aulas.length % titulos.length],
221
+ titulo,
221
222
  data: dia,
222
223
  horaInicio: '19:00',
223
224
  horaFim: '22:00',
@@ -330,13 +331,15 @@ export default function TurmaDetalhePage() {
330
331
  // Calendar events
331
332
  const calendarEvents = useMemo(() => {
332
333
  return aulas.map((aula) => {
333
- const [hi, mi] = aula.horaInicio.split(':').map(Number);
334
- const [hf, mf] = aula.horaFim.split(':').map(Number);
334
+ const [startHour = 0, startMinute = 0] = aula.horaInicio
335
+ .split(':')
336
+ .map(Number);
337
+ const [endHour = 0, endMinute = 0] = aula.horaFim.split(':').map(Number);
335
338
  return {
336
339
  id: aula.id,
337
340
  title: aula.titulo,
338
- start: setMinutes(setHours(aula.data, hi), mi),
339
- end: setMinutes(setHours(aula.data, hf), mf),
341
+ start: setMinutes(setHours(aula.data, startHour), startMinute),
342
+ end: setMinutes(setHours(aula.data, endHour), endMinute),
340
343
  resource: aula,
341
344
  };
342
345
  });
@@ -546,7 +549,7 @@ export default function TurmaDetalhePage() {
546
549
  },
547
550
  ];
548
551
 
549
- const STATUS_MAP: Record<string, { label: string; color: string }> = {
552
+ const STATUS_MAP = {
550
553
  aberta: {
551
554
  label: tClasses('status.aberta'),
552
555
  color: 'bg-blue-100 text-blue-700 border-blue-200',
@@ -563,7 +566,9 @@ export default function TurmaDetalhePage() {
563
566
  label: tClasses('status.cancelada'),
564
567
  color: 'bg-red-100 text-red-700 border-red-200',
565
568
  },
566
- };
569
+ } as const;
570
+ const turmaStatus =
571
+ STATUS_MAP[turma.status as keyof typeof STATUS_MAP] ?? STATUS_MAP.aberta;
567
572
 
568
573
  const fadeUp = {
569
574
  hidden: { opacity: 0, y: 20 },
@@ -616,8 +621,8 @@ export default function TurmaDetalhePage() {
616
621
  >
617
622
  <div>
618
623
  <div className="flex flex-wrap items-center gap-2 mb-1">
619
- <Badge className={`${STATUS_MAP[turma.status].color} border`}>
620
- {STATUS_MAP[turma.status].label}
624
+ <Badge className={`${turmaStatus.color} border`}>
625
+ {turmaStatus.label}
621
626
  </Badge>
622
627
  </div>
623
628
  <p className="text-muted-foreground">
@@ -851,16 +856,32 @@ export default function TurmaDetalhePage() {
851
856
  ))}
852
857
  </div>
853
858
  ) : filteredAlunos.length === 0 ? (
854
- <Card>
855
- <CardContent className="flex flex-col items-center justify-center py-12 text-center">
856
- <Users className="mb-4 size-12 text-muted-foreground/50" />
857
- <p className="text-muted-foreground">
858
- {alunoSearch
859
- ? t('students.empty.notFound')
860
- : t('students.empty.notEnrolled')}
861
- </p>
862
- </CardContent>
863
- </Card>
859
+ <EmptyState
860
+ icon={<Users className="h-12 w-12" />}
861
+ title={
862
+ alunoSearch
863
+ ? t('students.empty.notFound')
864
+ : t('students.empty.notEnrolled')
865
+ }
866
+ description={
867
+ alunoSearch
868
+ ? t('students.empty.notFoundDescription')
869
+ : t('students.empty.notEnrolledDescription')
870
+ }
871
+ actionLabel={
872
+ alunoSearch
873
+ ? t('students.empty.clearSearch')
874
+ : t('students.actions.addStudent')
875
+ }
876
+ onAction={() => {
877
+ if (alunoSearch) {
878
+ setAlunoSearch('');
879
+ return;
880
+ }
881
+
882
+ setAddAlunoDialogOpen(true);
883
+ }}
884
+ />
864
885
  ) : (
865
886
  <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
866
887
  {filteredAlunos.map((aluno) => {
@@ -876,7 +897,7 @@ export default function TurmaDetalhePage() {
876
897
  <div className="relative">
877
898
  <Avatar className="size-12">
878
899
  <AvatarImage src={aluno.avatar} />
879
- <AvatarFallback className="bg-gradient-to-br from-blue-100 to-blue-200 text-blue-700 font-medium">
900
+ <AvatarFallback className="bg-linear-to-br from-blue-100 to-blue-200 text-blue-700 font-medium">
880
901
  {aluno.nome
881
902
  .split(' ')
882
903
  .map((n) => n[0])
@@ -1,6 +1,11 @@
1
1
  'use client';
2
2
 
3
- import { Page, PageHeader } from '@/components/entity-list';
3
+ import {
4
+ EmptyState,
5
+ Page,
6
+ PageHeader,
7
+ PaginationFooter,
8
+ } from '@/components/entity-list';
4
9
  import { Badge } from '@/components/ui/badge';
5
10
  import { Button } from '@/components/ui/button';
6
11
  import { Card, CardContent } from '@/components/ui/card';
@@ -43,10 +48,6 @@ import {
43
48
  AlertTriangle,
44
49
  BarChart3,
45
50
  Calendar,
46
- ChevronLeft,
47
- ChevronRight,
48
- ChevronsLeft,
49
- ChevronsRight,
50
51
  Clock,
51
52
  Eye,
52
53
  Laptop,
@@ -661,7 +662,7 @@ export default function TurmasPage() {
661
662
  </div>
662
663
 
663
664
  {/* Search bar */}
664
- <form onSubmit={handleSearch} className="mt-4 mb-2">
665
+ <form onSubmit={handleSearch} className="mb-6 mt-0">
665
666
  <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
666
667
  <div className="relative flex-1">
667
668
  <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
@@ -711,7 +712,7 @@ export default function TurmasPage() {
711
712
  value={filtroCursoInput}
712
713
  onValueChange={setFiltroCursoInput}
713
714
  >
714
- <SelectTrigger className="h-9 w-[160px] text-sm">
715
+ <SelectTrigger className="h-9 w-40 text-sm">
715
716
  <SelectValue placeholder={t('filters.course')} />
716
717
  </SelectTrigger>
717
718
  <SelectContent>
@@ -759,17 +760,14 @@ export default function TurmasPage() {
759
760
  ))}
760
761
  </div>
761
762
  ) : filteredTurmas.length === 0 ? (
762
- <div className="flex flex-col items-center justify-center py-20 text-center">
763
- <Users className="mb-4 size-12 text-muted-foreground/40" />
764
- <p className="text-lg font-medium">{t('empty.title')}</p>
765
- <p className="mt-1 text-sm text-muted-foreground">
766
- {t('empty.description')}
767
- </p>
768
- <Button className="mt-6 gap-2" onClick={openCreateSheet}>
769
- <Plus className="size-4" />
770
- {t('empty.action')}
771
- </Button>
772
- </div>
763
+ <EmptyState
764
+ icon={<Users className="h-12 w-12" />}
765
+ title={t('empty.title')}
766
+ description={t('empty.description')}
767
+ actionLabel={t('empty.action')}
768
+ onAction={openCreateSheet}
769
+ actionIcon={<Plus className="mr-2 h-4 w-4" />}
770
+ />
773
771
  ) : (
774
772
  <motion.div
775
773
  className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"
@@ -946,87 +944,18 @@ export default function TurmasPage() {
946
944
 
947
945
  {/* Pagination footer */}
948
946
  {!loading && filteredTurmas.length > 0 && (
949
- <div className="mt-6 flex flex-col items-center justify-between gap-4 sm:flex-row">
950
- <p className="text-sm text-muted-foreground">
951
- {filteredTurmas.length}{' '}
952
- {filteredTurmas.length === 1
953
- ? t('pagination.class')
954
- : t('pagination.classes')}{' '}
955
- {filteredTurmas.length === 1
956
- ? t('pagination.found')
957
- : t('pagination.foundPlural')}
958
- </p>
959
- <div className="flex items-center gap-1">
960
- <Button
961
- variant="outline"
962
- size="icon"
963
- className="size-8"
964
- onClick={() => setCurrentPage(1)}
965
- disabled={safePage === 1}
966
- aria-label={t('pagination.firstPage')}
967
- >
968
- <ChevronsLeft className="size-4" />
969
- </Button>
970
- <Button
971
- variant="outline"
972
- size="icon"
973
- className="size-8"
974
- onClick={() => setCurrentPage((p) => p - 1)}
975
- disabled={safePage === 1}
976
- aria-label={t('pagination.previousPage')}
977
- >
978
- <ChevronLeft className="size-4" />
979
- </Button>
980
- <span className="px-3 text-sm">
981
- {t('pagination.page')}{' '}
982
- <span className="font-semibold">{safePage}</span>{' '}
983
- {t('pagination.of')}{' '}
984
- <span className="font-semibold">{totalPages}</span>
985
- </span>
986
- <Button
987
- variant="outline"
988
- size="icon"
989
- className="size-8"
990
- onClick={() => setCurrentPage((p) => p + 1)}
991
- disabled={safePage === totalPages}
992
- aria-label={t('pagination.nextPage')}
993
- >
994
- <ChevronRight className="size-4" />
995
- </Button>
996
- <Button
997
- variant="outline"
998
- size="icon"
999
- className="size-8"
1000
- onClick={() => setCurrentPage(totalPages)}
1001
- disabled={safePage === totalPages}
1002
- aria-label={t('pagination.lastPage')}
1003
- >
1004
- <ChevronsRight className="size-4" />
1005
- </Button>
1006
- </div>
1007
- <div className="flex items-center gap-2 text-sm">
1008
- <span className="text-muted-foreground">
1009
- {t('pagination.itemsPerPage')}
1010
- </span>
1011
- <Select
1012
- value={String(pageSize)}
1013
- onValueChange={(v) => {
1014
- setPageSize(Number(v));
1015
- setCurrentPage(1);
1016
- }}
1017
- >
1018
- <SelectTrigger className="h-8 w-16 text-sm">
1019
- <SelectValue />
1020
- </SelectTrigger>
1021
- <SelectContent>
1022
- {PAGE_SIZES.map((s) => (
1023
- <SelectItem key={s} value={String(s)}>
1024
- {s}
1025
- </SelectItem>
1026
- ))}
1027
- </SelectContent>
1028
- </Select>
1029
- </div>
947
+ <div className="mt-6">
948
+ <PaginationFooter
949
+ currentPage={safePage}
950
+ pageSize={pageSize}
951
+ totalItems={filteredTurmas.length}
952
+ onPageChange={setCurrentPage}
953
+ onPageSizeChange={(nextSize) => {
954
+ setPageSize(nextSize);
955
+ setCurrentPage(1);
956
+ }}
957
+ pageSizeOptions={PAGE_SIZES}
958
+ />
1030
959
  </div>
1031
960
  )}
1032
961
 
@@ -78,6 +78,120 @@ import {
78
78
  import { toast } from 'sonner';
79
79
  import { z } from 'zod';
80
80
 
81
+ type MediaUploadFieldProps = {
82
+ label: string;
83
+ description: string;
84
+ uploadLabel: string;
85
+ changeLabel: string;
86
+ removeLabel: string;
87
+ emptyTitle: string;
88
+ preview: string | null;
89
+ previewAlt: string;
90
+ inputRef: React.RefObject<HTMLInputElement | null>;
91
+ onInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
92
+ onClear: () => void;
93
+ icon: React.ComponentType<{ className?: string }>;
94
+ aspectClassName: string;
95
+ compactPreview?: boolean;
96
+ };
97
+
98
+ function MediaUploadField({
99
+ label,
100
+ description,
101
+ uploadLabel,
102
+ changeLabel,
103
+ removeLabel,
104
+ emptyTitle,
105
+ preview,
106
+ previewAlt,
107
+ inputRef,
108
+ onInputChange,
109
+ onClear,
110
+ icon: Icon,
111
+ aspectClassName,
112
+ compactPreview = false,
113
+ }: MediaUploadFieldProps) {
114
+ return (
115
+ <Field>
116
+ <FieldLabel>{label}</FieldLabel>
117
+ <FieldDescription>{description}</FieldDescription>
118
+ <input
119
+ ref={inputRef}
120
+ type="file"
121
+ accept="image/*"
122
+ className="hidden"
123
+ onChange={onInputChange}
124
+ />
125
+ <div className="rounded-xl border border-border/70 bg-muted/20 p-4">
126
+ <div
127
+ className={
128
+ compactPreview
129
+ ? 'flex flex-col gap-4 sm:flex-row'
130
+ : 'flex flex-col gap-4'
131
+ }
132
+ >
133
+ <div
134
+ className={[
135
+ 'overflow-hidden rounded-xl border border-dashed border-border/80 bg-background',
136
+ compactPreview ? 'w-full shrink-0 sm:w-28' : 'w-full',
137
+ ].join(' ')}
138
+ >
139
+ {preview ? (
140
+ <img
141
+ src={preview}
142
+ alt={previewAlt}
143
+ className={`${aspectClassName} w-full object-cover`}
144
+ />
145
+ ) : (
146
+ <div
147
+ className={`${aspectClassName} flex w-full flex-col items-center justify-center gap-2 px-4 text-center`}
148
+ >
149
+ <div className="flex size-10 items-center justify-center rounded-full bg-muted text-muted-foreground">
150
+ <Icon className="size-5" />
151
+ </div>
152
+ <div className="space-y-1">
153
+ <p className="text-sm font-medium">{emptyTitle}</p>
154
+ <p className="text-xs text-muted-foreground">{uploadLabel}</p>
155
+ </div>
156
+ </div>
157
+ )}
158
+ </div>
159
+
160
+ <div className="flex min-w-0 flex-1 flex-col justify-between gap-3">
161
+ <div className="space-y-1">
162
+ <p className="text-sm font-medium text-foreground">{label}</p>
163
+ <p className="text-sm text-muted-foreground">{description}</p>
164
+ </div>
165
+
166
+ <div className="flex flex-col gap-2 sm:flex-row">
167
+ <Button
168
+ type="button"
169
+ variant={preview ? 'outline' : 'default'}
170
+ className="w-full gap-2 sm:w-auto"
171
+ onClick={() => inputRef.current?.click()}
172
+ >
173
+ <Upload className="size-4" />
174
+ {preview ? changeLabel : uploadLabel}
175
+ </Button>
176
+ {preview && (
177
+ <Button
178
+ type="button"
179
+ variant="outline"
180
+ className="w-full gap-2 text-destructive hover:text-destructive sm:w-auto"
181
+ onClick={onClear}
182
+ >
183
+ <XCircle className="size-4" />
184
+ {removeLabel}
185
+ </Button>
186
+ )}
187
+ </div>
188
+ </div>
189
+ </div>
190
+ </div>
191
+ </Field>
192
+ );
193
+ }
194
+
81
195
  // ── Navigation ──────────────────────────────────────────────────────────────────
82
196
 
83
197
  const NAV_ITEMS = [
@@ -773,7 +887,7 @@ export default function CursoEditPage({
773
887
  return (
774
888
  <label
775
889
  key={cat}
776
- className="flex cursor-pointer items-center gap-2.5 rounded-md border px-3 py-2.5 text-sm transition-colors hover:bg-muted/50 has-[:checked]:border-foreground has-[:checked]:bg-muted/50"
890
+ className="flex cursor-pointer items-center gap-2.5 rounded-md border px-3 py-2.5 text-sm transition-colors hover:bg-muted/50 has-checked:border-foreground has-checked:bg-muted/50"
777
891
  >
778
892
  <Checkbox
779
893
  checked={checked}
@@ -824,7 +938,7 @@ export default function CursoEditPage({
824
938
  return (
825
939
  <label
826
940
  key={inst.id}
827
- className="flex cursor-pointer items-center gap-2.5 rounded-md border px-3 py-2.5 text-sm transition-colors hover:bg-muted/50 has-[:checked]:border-foreground has-[:checked]:bg-muted/50"
941
+ className="flex cursor-pointer items-center gap-2.5 rounded-md border px-3 py-2.5 text-sm transition-colors hover:bg-muted/50 has-checked:border-foreground has-checked:bg-muted/50"
828
942
  >
829
943
  <Checkbox
830
944
  checked={checked}
@@ -882,114 +996,43 @@ export default function CursoEditPage({
882
996
  <Separator />
883
997
 
884
998
  {/* ── Upload Logo + Banner ─────────────────────────────── */}
885
- <div className="grid gap-4 sm:grid-cols-2">
886
- {/* Logo Upload */}
887
- <Field>
888
- <FieldLabel>{t('form.fields.logo.label')}</FieldLabel>
889
- <FieldDescription>
890
- {t('form.fields.logo.description')}
891
- </FieldDescription>
892
- <input
893
- ref={logoInputRef}
894
- type="file"
895
- accept="image/*"
896
- className="hidden"
897
- onChange={(e) => handleFileSelect(e, setLogoPreview)}
898
- />
899
- {logoPreview ? (
900
- <div className="group relative overflow-hidden rounded-lg border">
901
- <img
902
- src={logoPreview}
903
- alt="Logo preview"
904
- className="aspect-square w-full object-cover"
905
- />
906
- <div className="absolute inset-0 flex items-center justify-center gap-2 bg-background/80 opacity-0 transition-opacity group-hover:opacity-100">
907
- <Button
908
- type="button"
909
- variant="outline"
910
- size="sm"
911
- onClick={() => logoInputRef.current?.click()}
912
- >
913
- {t('form.fields.logo.change')}
914
- </Button>
915
- <Button
916
- type="button"
917
- variant="outline"
918
- size="sm"
919
- onClick={() => setLogoPreview(null)}
920
- >
921
- <XCircle className="size-4" />
922
- </Button>
923
- </div>
924
- </div>
925
- ) : (
926
- <button
927
- type="button"
928
- onClick={() => logoInputRef.current?.click()}
929
- className="flex aspect-square w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed transition-colors hover:border-foreground/30 hover:bg-muted/50"
930
- >
931
- <Upload className="size-8 text-muted-foreground/50" />
932
- <span className="text-xs text-muted-foreground">
933
- {t('form.fields.logo.clickToUpload')}
934
- </span>
935
- </button>
936
- )}
937
- </Field>
999
+ <div className="grid gap-4 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.25fr)]">
1000
+ <MediaUploadField
1001
+ label={t('form.fields.logo.label')}
1002
+ description={t('form.fields.logo.description')}
1003
+ uploadLabel={t('form.fields.logo.clickToUpload')}
1004
+ changeLabel={t('form.fields.logo.change')}
1005
+ removeLabel={t('form.fields.logo.remove')}
1006
+ emptyTitle={t('form.fields.logo.emptyTitle')}
1007
+ preview={logoPreview}
1008
+ previewAlt={t('form.fields.logo.previewAlt')}
1009
+ inputRef={logoInputRef}
1010
+ onInputChange={(e) =>
1011
+ handleFileSelect(e, setLogoPreview)
1012
+ }
1013
+ onClear={() => setLogoPreview(null)}
1014
+ icon={ImageIcon}
1015
+ aspectClassName="aspect-square"
1016
+ compactPreview
1017
+ />
938
1018
 
939
- {/* Banner Upload */}
940
- <Field>
941
- <FieldLabel>{t('form.fields.banner.label')}</FieldLabel>
942
- <FieldDescription>
943
- {t('form.fields.banner.description')}
944
- </FieldDescription>
945
- <input
946
- ref={bannerInputRef}
947
- type="file"
948
- accept="image/*"
949
- className="hidden"
950
- onChange={(e) =>
951
- handleFileSelect(e, setBannerPreview)
952
- }
953
- />
954
- {bannerPreview ? (
955
- <div className="group relative overflow-hidden rounded-lg border">
956
- <img
957
- src={bannerPreview}
958
- alt="Banner preview"
959
- className="aspect-video w-full object-cover"
960
- />
961
- <div className="absolute inset-0 flex items-center justify-center gap-2 bg-background/80 opacity-0 transition-opacity group-hover:opacity-100">
962
- <Button
963
- type="button"
964
- variant="outline"
965
- size="sm"
966
- onClick={() => bannerInputRef.current?.click()}
967
- >
968
- {t('form.fields.banner.change')}
969
- </Button>
970
- <Button
971
- type="button"
972
- variant="outline"
973
- size="sm"
974
- onClick={() => setBannerPreview(null)}
975
- >
976
- <XCircle className="size-4" />
977
- </Button>
978
- </div>
979
- </div>
980
- ) : (
981
- <button
982
- type="button"
983
- onClick={() => bannerInputRef.current?.click()}
984
- className="flex aspect-video w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed transition-colors hover:border-foreground/30 hover:bg-muted/50"
985
- >
986
- <ImageIcon className="size-8 text-muted-foreground/50" />
987
- <span className="text-xs text-muted-foreground">
988
- {t('form.fields.banner.clickToUpload')}
989
- </span>
990
- </button>
991
- )}
992
- </Field>
1019
+ <MediaUploadField
1020
+ label={t('form.fields.banner.label')}
1021
+ description={t('form.fields.banner.description')}
1022
+ uploadLabel={t('form.fields.banner.clickToUpload')}
1023
+ changeLabel={t('form.fields.banner.change')}
1024
+ removeLabel={t('form.fields.banner.remove')}
1025
+ emptyTitle={t('form.fields.banner.emptyTitle')}
1026
+ preview={bannerPreview}
1027
+ previewAlt={t('form.fields.banner.previewAlt')}
1028
+ inputRef={bannerInputRef}
1029
+ onInputChange={(e) =>
1030
+ handleFileSelect(e, setBannerPreview)
1031
+ }
1032
+ onClear={() => setBannerPreview(null)}
1033
+ icon={ImageIcon}
1034
+ aspectClassName="aspect-[16/9]"
1035
+ />
993
1036
  </div>
994
1037
 
995
1038
  <Separator />