@hed-hog/lms 0.0.279 → 0.0.285

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';
@@ -851,16 +851,32 @@ export default function TurmaDetalhePage() {
851
851
  ))}
852
852
  </div>
853
853
  ) : 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>
854
+ <EmptyState
855
+ icon={<Users className="h-12 w-12" />}
856
+ title={
857
+ alunoSearch
858
+ ? t('students.empty.notFound')
859
+ : t('students.empty.notEnrolled')
860
+ }
861
+ description={
862
+ alunoSearch
863
+ ? t('students.empty.notFoundDescription')
864
+ : t('students.empty.notEnrolledDescription')
865
+ }
866
+ actionLabel={
867
+ alunoSearch
868
+ ? t('students.empty.clearSearch')
869
+ : t('students.actions.addStudent')
870
+ }
871
+ onAction={() => {
872
+ if (alunoSearch) {
873
+ setAlunoSearch('');
874
+ return;
875
+ }
876
+
877
+ setAddAlunoDialogOpen(true);
878
+ }}
879
+ />
864
880
  ) : (
865
881
  <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
866
882
  {filteredAlunos.map((aluno) => {
@@ -876,7 +892,7 @@ export default function TurmaDetalhePage() {
876
892
  <div className="relative">
877
893
  <Avatar className="size-12">
878
894
  <AvatarImage src={aluno.avatar} />
879
- <AvatarFallback className="bg-gradient-to-br from-blue-100 to-blue-200 text-blue-700 font-medium">
895
+ <AvatarFallback className="bg-linear-to-br from-blue-100 to-blue-200 text-blue-700 font-medium">
880
896
  {aluno.nome
881
897
  .split(' ')
882
898
  .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 />
@@ -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 { Badge } from '@/components/ui/badge';
5
5
  import { Button } from '@/components/ui/button';
6
6
  import { Card, CardContent } from '@/components/ui/card';
@@ -1310,23 +1310,16 @@ export default function EstruturaPage({
1310
1310
 
1311
1311
  {/* Empty state */}
1312
1312
  {sessoes.length === 0 && (
1313
- <motion.div
1314
- variants={fadeUp}
1315
- className="mt-8 flex flex-col items-center gap-4 py-16 text-center"
1316
- >
1317
- <div className="flex size-16 items-center justify-center rounded-full bg-muted">
1318
- <Layers className="size-8 text-muted-foreground" />
1319
- </div>
1320
- <div>
1321
- <h3 className="text-lg font-semibold">{t('empty.title')}</h3>
1322
- <p className="text-sm text-muted-foreground">
1323
- {t('empty.description')}
1324
- </p>
1325
- </div>
1326
- <Button className="gap-2" onClick={openCreateSessao}>
1327
- <Plus className="size-4" />
1328
- {t('empty.action')}
1329
- </Button>
1313
+ <motion.div variants={fadeUp} className="mt-8">
1314
+ <EmptyState
1315
+ icon={<Layers className="h-12 w-12" />}
1316
+ title={t('empty.title')}
1317
+ description={t('empty.description')}
1318
+ actionLabel={t('empty.action')}
1319
+ actionIcon={<Plus className="size-4" />}
1320
+ onAction={openCreateSessao}
1321
+ className="py-16"
1322
+ />
1330
1323
  </motion.div>
1331
1324
  )}
1332
1325
  </motion.div>
@@ -2429,20 +2422,15 @@ function SortableSessao({
2429
2422
  >
2430
2423
  <div className="flex flex-col gap-0 p-2">
2431
2424
  {aulas.length === 0 ? (
2432
- <div className="flex flex-col items-center gap-2 py-6 text-center">
2433
- <p className="text-xs text-muted-foreground">
2434
- {t('session.noLessons')}
2435
- </p>
2436
- <Button
2437
- variant="outline"
2438
- size="sm"
2439
- className="gap-1.5 text-xs"
2440
- onClick={onCreateAula}
2441
- >
2442
- <Plus className="size-3" />
2443
- {t('session.addLesson')}
2444
- </Button>
2445
- </div>
2425
+ <EmptyState
2426
+ icon={<Layers className="h-10 w-10" />}
2427
+ title={t('session.noLessons')}
2428
+ description={t('session.noLessonsDescription')}
2429
+ actionLabel={t('session.addLesson')}
2430
+ actionIcon={<Plus className="size-3.5" />}
2431
+ onAction={onCreateAula}
2432
+ className="py-8"
2433
+ />
2446
2434
  ) : (
2447
2435
  aulas.map((aula) => (
2448
2436
  <SortableAula