diff --git a/README.md b/README.md index ecee9c9..be0ea3d 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,8 @@ YouTube-лекции, карточка лекции покажет ошибку тело `application/json` `{filename}`); возвращает `{Key, URL, ExpiresIn}`. - `CreateTask(ctx, params)` — создание задачи (`POST /tasks`, **multipart/form-data**); ровно один источник — `S3Key` или `VideoURL`, плюс - опциональные `Media` и `NoSlides`. multipart-тело собирается вручную, т.к. + опциональные `Media`, `NoSlides` и презентация `SlidesName`/`SlidesContent` + (поле API `slides`, PDF/PPTX). multipart-тело собирается вручную, т.к. oapi-codegen для multipart даёт только сырой `…WithBodyWithResponse`. - `GetTaskStatus(ctx, taskID)` — статус задачи (`GET /tasks/{id}`); на 404 возвращает `ErrTaskNotFound`. Nullable-поля (`Stage`, `Error`, `ErrorCode`, diff --git a/internal/coreclient/client.go b/internal/coreclient/client.go index 282e5a1..b6fdc89 100644 --- a/internal/coreclient/client.go +++ b/internal/coreclient/client.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "io" "mime/multipart" "net/http" "strconv" @@ -34,10 +35,12 @@ type UploadResult struct { // Должен быть задан РОВНО ОДИН источник: S3Key ИЛИ VideoURL. Сценарии B1 // покрывают именно эти два источника (audio/video-файлы напрямую — вне scope). type CreateTaskParams struct { - S3Key string // источник: ключ уже загруженного объекта (uploads/...) - VideoURL string // источник: URL видео (напр. ссылка YouTube) - Media string // опционально: "audio" | "video" (по умолчанию на стороне ядра — audio) - NoSlides bool // опционально: отключить извлечение слайдов + S3Key string // источник: ключ уже загруженного объекта (uploads/...) + VideoURL string // источник: URL видео (напр. ссылка YouTube) + Media string // опционально: "audio" | "video" (по умолчанию на стороне ядра — audio) + NoSlides bool // опционально: отключить извлечение слайдов + SlidesName string // имя приложенного PDF/PPTX + SlidesContent io.Reader // содержимое приложенного PDF/PPTX } // CreateUpload запрашивает у ядра presigned-PUT URL для загрузки контента. @@ -77,6 +80,9 @@ func (c *CoreClient) CreateTask(ctx context.Context, params CreateTaskParams) (s if hasS3 == hasURL { return "", fmt.Errorf("coreclient: нужен ровно один источник (s3_key ИЛИ video_url)") } + if (params.SlidesContent == nil) != (params.SlidesName == "") { + return "", fmt.Errorf("coreclient: slides требуют одновременно имя и содержимое") + } var buf bytes.Buffer w := multipart.NewWriter(&buf) @@ -100,6 +106,15 @@ func (c *CoreClient) CreateTask(ctx context.Context, params CreateTaskParams) (s return "", fmt.Errorf("coreclient: запись no_slides: %w", err) } } + if params.SlidesContent != nil { + part, err := w.CreateFormFile("slides", params.SlidesName) + if err != nil { + return "", fmt.Errorf("coreclient: создание части slides: %w", err) + } + if _, err := io.Copy(part, params.SlidesContent); err != nil { + return "", fmt.Errorf("coreclient: запись slides: %w", err) + } + } if err := w.Close(); err != nil { return "", fmt.Errorf("coreclient: закрытие multipart: %w", err) } diff --git a/internal/coreclient/client_test.go b/internal/coreclient/client_test.go index 9f5fc01..112662b 100644 --- a/internal/coreclient/client_test.go +++ b/internal/coreclient/client_test.go @@ -20,6 +20,8 @@ type mockCore struct { // Захваченные данные последнего запроса для ассертов в тестах. lastUploadFilename string lastTaskForm map[string]string + lastSlidesFilename string + lastSlidesBody []byte lastTaskCT string lastCookieCT string lastCookieBody []byte @@ -80,6 +82,16 @@ func newMockCore(t *testing.T) *mockCore { m.lastTaskForm[k] = v[0] } } + if files := r.MultipartForm.File["slides"]; len(files) > 0 { + m.lastSlidesFilename = files[0].Filename + file, err := files[0].Open() + if err != nil { + http.Error(w, "bad slides", http.StatusBadRequest) + return + } + m.lastSlidesBody, _ = io.ReadAll(file) + _ = file.Close() + } // Ветка 400: нет ни одного источника. _, hasS3 := m.lastTaskForm["s3_key"] @@ -239,6 +251,30 @@ func TestCreateTask_VideoURL(t *testing.T) { } } +func TestCreateTask_WithDocumentSlides(t *testing.T) { + m := newMockCore(t) + c := newTestClient(t, m.srv.URL) + + id, err := c.CreateTask(context.Background(), CreateTaskParams{ + VideoURL: "https://youtu.be/x", + NoSlides: true, + SlidesName: "deck.pptx", + SlidesContent: strings.NewReader("pptx-content"), + }) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + if id != "task-123" { + t.Errorf("task id = %q, want task-123", id) + } + if m.lastSlidesFilename != "deck.pptx" { + t.Errorf("slides filename = %q, want deck.pptx", m.lastSlidesFilename) + } + if string(m.lastSlidesBody) != "pptx-content" { + t.Errorf("slides body = %q, want pptx-content", m.lastSlidesBody) + } +} + func TestCreateTask_NoSource(t *testing.T) { m := newMockCore(t) c := newTestClient(t, m.srv.URL) @@ -249,6 +285,19 @@ func TestCreateTask_NoSource(t *testing.T) { } } +func TestCreateTask_SlidesRequireNameAndContent(t *testing.T) { + m := newMockCore(t) + c := newTestClient(t, m.srv.URL) + + _, err := c.CreateTask(context.Background(), CreateTaskParams{ + VideoURL: "https://youtu.be/x", + SlidesContent: strings.NewReader("pdf"), + }) + if err == nil { + t.Fatal("expected error for slides without filename") + } +} + func TestGetTaskStatus(t *testing.T) { m := newMockCore(t) c := newTestClient(t, m.srv.URL) diff --git a/internal/upload/handlers.go b/internal/upload/handlers.go index 7fd9430..c9f4933 100644 --- a/internal/upload/handlers.go +++ b/internal/upload/handlers.go @@ -5,7 +5,9 @@ import ( "errors" "io" "log" + "mime/multipart" "net/http" + "strings" "github.com/LectureLog/lecturelog-web/internal/auth" "github.com/go-chi/chi/v5" @@ -79,16 +81,25 @@ func (s *Service) handleConfirm(w http.ResponseWriter, r *http.Request) { return } - if err := r.ParseForm(); err != nil { - http.Error(w, "неверный запрос", http.StatusBadRequest) + if !parseTaskForm(w, r) { + return + } + defer cleanupMultipartForm(r) + + slides, slidesFile, err := slidesFromRequest(r) + if err != nil { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) return } + if slidesFile != nil { + defer slidesFile.Close() + } - _, err := s.ConfirmFileUpload(r.Context(), user.ID, ConfirmInput{ + _, err = s.ConfirmFileUpload(r.Context(), user.ID, ConfirmInput{ Token: r.FormValue("token"), S3Key: r.FormValue("s3_key"), Title: r.FormValue("title"), - HasPDF: parseUploadCheckbox(r.FormValue("has_pdf")), + Slides: slides, ExtractSlides: parseUploadCheckbox(r.FormValue("extract_slides")), }) if err != nil { @@ -117,15 +128,24 @@ func (s *Service) handleYouTube(w http.ResponseWriter, r *http.Request) { return } - if err := r.ParseForm(); err != nil { - http.Error(w, "неверный запрос", http.StatusBadRequest) + if !parseTaskForm(w, r) { return } + defer cleanupMultipartForm(r) - _, err := s.CreateYouTube(r.Context(), user.ID, YouTubeInput{ + slides, slidesFile, err := slidesFromRequest(r) + if err != nil { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + if slidesFile != nil { + defer slidesFile.Close() + } + + _, err = s.CreateYouTube(r.Context(), user.ID, YouTubeInput{ URL: r.FormValue("url"), Title: r.FormValue("title"), - HasPDF: parseUploadCheckbox(r.FormValue("has_pdf")), + Slides: slides, ExtractSlides: parseUploadCheckbox(r.FormValue("extract_slides")), }) if err != nil { @@ -151,8 +171,61 @@ func uploadErrStatus(err error) int { errors.Is(err, ErrTooLarge) || errors.Is(err, ErrEmptyFilename) || errors.Is(err, ErrMediaMismatch) || - errors.Is(err, ErrInvalidURL) { + errors.Is(err, ErrInvalidURL) || + errors.Is(err, ErrSlidesRequired) || + errors.Is(err, ErrUnsupportedSlides) || + errors.Is(err, ErrSlidesTooLarge) { return http.StatusUnprocessableEntity } return http.StatusInternalServerError } + +func parseTaskForm(w http.ResponseWriter, r *http.Request) bool { + if !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") { + if err := r.ParseForm(); err != nil { + http.Error(w, "неверный запрос", http.StatusBadRequest) + return false + } + return true + } + r.Body = http.MaxBytesReader(w, r.Body, maxSlidesBytes+(1<<20)) + if err := r.ParseMultipartForm(1 << 20); err != nil { + if errors.As(err, new(*http.MaxBytesError)) { + http.Error(w, ErrSlidesTooLarge.Error(), http.StatusUnprocessableEntity) + } else { + http.Error(w, "неверный запрос", http.StatusBadRequest) + } + return false + } + return true +} + +func slidesFromRequest(r *http.Request) (*SlidesUpload, multipart.File, error) { + if r.MultipartForm == nil { + if parseUploadCheckbox(r.FormValue("has_pdf")) { + return nil, nil, ErrSlidesRequired + } + return nil, nil, nil + } + file, header, err := r.FormFile("slides") + if errors.Is(err, http.ErrMissingFile) { + if parseUploadCheckbox(r.FormValue("has_pdf")) { + return nil, nil, ErrSlidesRequired + } + return nil, nil, nil + } + if err != nil { + return nil, nil, ErrSlidesRequired + } + if err := ValidateSlidesMeta(header.Filename, header.Size); err != nil { + _ = file.Close() + return nil, nil, err + } + return &SlidesUpload{Filename: header.Filename, Content: file}, file, nil +} + +func cleanupMultipartForm(r *http.Request) { + if r.MultipartForm != nil { + _ = r.MultipartForm.RemoveAll() + } +} diff --git a/internal/upload/handlers_test.go b/internal/upload/handlers_test.go index 62a8fbd..5727f4c 100644 --- a/internal/upload/handlers_test.go +++ b/internal/upload/handlers_test.go @@ -4,6 +4,8 @@ import ( "bytes" "context" "encoding/json" + "io" + "mime/multipart" "net/http" "net/http/httptest" "net/url" @@ -239,11 +241,56 @@ func TestConfirm_ExtractSlidesUnchecked(t *testing.T) { assertConfirmNoSlidesFromForm(t, url.Values{}, true) } -func TestConfirm_HasPDFForcesNoSlides(t *testing.T) { - assertConfirmNoSlidesFromForm(t, url.Values{ - "has_pdf": {"on"}, - "extract_slides": {"on"}, - }, true) +func TestConfirm_HasPDFWithoutFileRejected(t *testing.T) { + s3Key := "uploads/user-test-uuid/lecture.mp4" + signer := newTestServiceSigner() + handler := mountTestRouter(NewService(&mockCore{}, &mockRepo{}, signer, time.Hour)) + form := url.Values{ + "token": {signer.Sign(testUser.ID, s3Key, "video", time.Hour)}, + "s3_key": {s3Key}, + "title": {"Lecture"}, + "has_pdf": {"on"}, + } + req := addSessionCookie(newFormRequest(http.MethodPost, "/upload/confirm", form)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("POST /upload/confirm without slides = %d, want 422", rec.Code) + } +} + +func TestConfirm_WithSlidesPassesDocumentToCore(t *testing.T) { + s3Key := "uploads/user-test-uuid/lecture.mp4" + signer := newTestServiceSigner() + core := &mockCore{ + createTaskFunc: func(_ context.Context, p coreclient.CreateTaskParams) (string, error) { + if p.SlidesName != "deck.pdf" { + t.Fatalf("SlidesName = %q, want deck.pdf", p.SlidesName) + } + got, err := io.ReadAll(p.SlidesContent) + if err != nil || string(got) != "%PDF-test" { + t.Fatalf("SlidesContent = %q, err=%v", got, err) + } + if p.NoSlides { + t.Fatal("NoSlides = true would disable the attached document in core") + } + return "task-confirm", nil + }, + } + handler := mountTestRouter(NewService(core, &mockRepo{}, signer, time.Hour)) + fields := url.Values{ + "token": {signer.Sign(testUser.ID, s3Key, "video", time.Hour)}, + "s3_key": {s3Key}, + "title": {"Lecture"}, + "has_pdf": {"true"}, + "extract_slides": {"true"}, + } + req := addSessionCookie(newMultipartRequest(t, http.MethodPost, "/upload/confirm", fields, "deck.pdf", "%PDF-test")) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("POST /upload/confirm with slides = %d, want 200: %s", rec.Code, rec.Body.String()) + } } func TestConfirm_ExtractSlidesCheckedEnablesSlides(t *testing.T) { @@ -337,12 +384,91 @@ func TestYouTube_BadURL(t *testing.T) { } } +func TestYouTube_WithSlidesPassesDocumentToCore(t *testing.T) { + core := &mockCore{ + createTaskFunc: func(_ context.Context, p coreclient.CreateTaskParams) (string, error) { + if p.VideoURL != "https://youtu.be/video" || p.SlidesName != "deck.pptx" { + t.Fatalf("params = %+v", p) + } + got, err := io.ReadAll(p.SlidesContent) + if err != nil || string(got) != "pptx-test" { + t.Fatalf("SlidesContent = %q, err=%v", got, err) + } + if p.NoSlides { + t.Fatal("NoSlides = true would disable the attached document") + } + return "task-youtube", nil + }, + } + handler := mountTestRouter(newTestHTTPService(core, &mockRepo{})) + fields := url.Values{ + "url": {"https://youtu.be/video"}, + "title": {"Lecture"}, + "has_pdf": {"true"}, + } + req := addSessionCookie(newMultipartRequest(t, http.MethodPost, "/upload/youtube", fields, "deck.pptx", "pptx-test")) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("POST /upload/youtube with slides = %d, want 200: %s", rec.Code, rec.Body.String()) + } +} + +func TestYouTube_RejectsUnsupportedSlides(t *testing.T) { + handler := mountTestRouter(newTestHTTPService(&mockCore{}, &mockRepo{})) + fields := url.Values{ + "url": {"https://youtu.be/video"}, + "has_pdf": {"true"}, + } + req := addSessionCookie(newMultipartRequest(t, http.MethodPost, "/upload/youtube", fields, "deck.key", "key-test")) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("POST /upload/youtube unsupported slides = %d, want 422", rec.Code) + } +} + func newFormRequest(method, target string, form url.Values) *http.Request { req := httptest.NewRequest(method, target, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") return req } +func newMultipartRequest( + t *testing.T, + method string, + target string, + fields url.Values, + filename string, + content string, +) *http.Request { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + for key, values := range fields { + for _, value := range values { + if err := writer.WriteField(key, value); err != nil { + t.Fatal(err) + } + } + } + if filename != "" { + part, err := writer.CreateFormFile("slides", filename) + if err != nil { + t.Fatal(err) + } + if _, err := part.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(method, target, &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + return req +} + func assertConfirmNoSlidesFromForm(t *testing.T, fields url.Values, want bool) { t.Helper() diff --git a/internal/upload/service.go b/internal/upload/service.go index 55f99c9..f0ff398 100644 --- a/internal/upload/service.go +++ b/internal/upload/service.go @@ -3,6 +3,7 @@ package upload import ( "context" "errors" + "io" "path/filepath" "strings" "time" @@ -43,14 +44,14 @@ type ConfirmInput struct { Token string S3Key string Title string - HasPDF bool + Slides *SlidesUpload ExtractSlides bool } type YouTubeInput struct { URL string Title string - HasPDF bool + Slides *SlidesUpload ExtractSlides bool } @@ -98,9 +99,11 @@ func (s *Service) ConfirmFileUpload(ctx context.Context, userID string, in Confi } taskID, err := s.core.CreateTask(ctx, coreclient.CreateTaskParams{ - S3Key: in.S3Key, - Media: media, - NoSlides: noSlides(in.HasPDF, in.ExtractSlides), + S3Key: in.S3Key, + Media: media, + NoSlides: noSlides(in.Slides != nil, in.ExtractSlides), + SlidesName: slidesName(in.Slides), + SlidesContent: slidesContent(in.Slides), }) if err != nil { return "", err @@ -120,11 +123,11 @@ func (s *Service) CreateYouTube(ctx context.Context, userID string, in YouTubeIn return "", err } - // Долг: PDF-слайды пока не передаются в ядро, потому что CreateTaskParams - // не принимает файл слайдов; HasPDF только отключает извлечение слайдов. taskID, err := s.core.CreateTask(ctx, coreclient.CreateTaskParams{ - VideoURL: in.URL, - NoSlides: noSlides(in.HasPDF, in.ExtractSlides), + VideoURL: in.URL, + NoSlides: noSlides(in.Slides != nil, in.ExtractSlides), + SlidesName: slidesName(in.Slides), + SlidesContent: slidesContent(in.Slides), }) if err != nil { return "", err @@ -139,11 +142,22 @@ func (s *Service) CreateYouTube(ctx context.Context, userID string, in YouTubeIn }) } -func noSlides(hasPDF, extractSlides bool) bool { - if hasPDF { - return true +func noSlides(hasDocument, extractSlides bool) bool { + return !hasDocument && !extractSlides +} + +func slidesName(slides *SlidesUpload) string { + if slides == nil { + return "" + } + return filepath.Base(slides.Filename) +} + +func slidesContent(slides *SlidesUpload) io.Reader { + if slides == nil { + return nil } - return !extractSlides + return slides.Content } func titleFromFilename(filename string) string { diff --git a/internal/upload/service_test.go b/internal/upload/service_test.go index 426047f..0d743bd 100644 --- a/internal/upload/service_test.go +++ b/internal/upload/service_test.go @@ -3,6 +3,7 @@ package upload import ( "context" "errors" + "strings" "testing" "time" @@ -188,16 +189,19 @@ func TestConfirmFileUpload_CoreError(t *testing.T) { } } -func TestConfirmFileUpload_PDFForcesNoSlides(t *testing.T) { - assertConfirmNoSlides(t, ConfirmInput{HasPDF: true, ExtractSlides: true}, true) +func TestConfirmFileUpload_DocumentKeepsSlidesEnabled(t *testing.T) { + assertConfirmNoSlides(t, ConfirmInput{ + Slides: &SlidesUpload{Filename: "deck.pdf", Content: strings.NewReader("pdf")}, + ExtractSlides: true, + }, false) } func TestConfirmFileUpload_ExtractToggleOff(t *testing.T) { - assertConfirmNoSlides(t, ConfirmInput{HasPDF: false, ExtractSlides: false}, true) + assertConfirmNoSlides(t, ConfirmInput{ExtractSlides: false}, true) } func TestConfirmFileUpload_ExtractOn(t *testing.T) { - assertConfirmNoSlides(t, ConfirmInput{HasPDF: false, ExtractSlides: true}, false) + assertConfirmNoSlides(t, ConfirmInput{ExtractSlides: true}, false) } func TestCreateYouTube_Success(t *testing.T) { @@ -258,15 +262,18 @@ func TestCreateYouTube_Success(t *testing.T) { } func TestCreateYouTube_ExtractSlidesOn(t *testing.T) { - assertYouTubeNoSlides(t, YouTubeInput{HasPDF: false, ExtractSlides: true}, false) + assertYouTubeNoSlides(t, YouTubeInput{ExtractSlides: true}, false) } -func TestCreateYouTube_PDFForcesNoSlides(t *testing.T) { - assertYouTubeNoSlides(t, YouTubeInput{HasPDF: true, ExtractSlides: true}, true) +func TestCreateYouTube_DocumentKeepsSlidesEnabled(t *testing.T) { + assertYouTubeNoSlides(t, YouTubeInput{ + Slides: &SlidesUpload{Filename: "deck.pptx", Content: strings.NewReader("pptx")}, + ExtractSlides: true, + }, false) } func TestCreateYouTube_NoSlidesAtAll(t *testing.T) { - assertYouTubeNoSlides(t, YouTubeInput{HasPDF: false, ExtractSlides: false}, true) + assertYouTubeNoSlides(t, YouTubeInput{ExtractSlides: false}, true) } func TestCreateYouTube_InvalidURL(t *testing.T) { diff --git a/internal/upload/validate.go b/internal/upload/validate.go index d04b4c6..a71754d 100644 --- a/internal/upload/validate.go +++ b/internal/upload/validate.go @@ -2,6 +2,7 @@ package upload import ( "errors" + "io" "net/url" "path/filepath" "strings" @@ -15,17 +16,44 @@ const ( // Грубый потолок беты. maxUploadBytes int64 = 5 << 30 + maxSlidesBytes int64 = 100 << 20 ) var ( - ErrUnsupportedMedia = errors.New("неподдерживаемый тип медиа") - ErrEmptyFile = errors.New("пустой файл") - ErrTooLarge = errors.New("файл слишком большой") - ErrEmptyFilename = errors.New("пустое имя файла") - ErrInvalidURL = errors.New("некорректная ссылка") - ErrMediaMismatch = errors.New("тип медиа не соответствует расширению файла") + ErrUnsupportedMedia = errors.New("неподдерживаемый тип медиа") + ErrEmptyFile = errors.New("пустой файл") + ErrTooLarge = errors.New("файл слишком большой") + ErrEmptyFilename = errors.New("пустое имя файла") + ErrInvalidURL = errors.New("некорректная ссылка") + ErrMediaMismatch = errors.New("тип медиа не соответствует расширению файла") + ErrSlidesRequired = errors.New("выберите файл презентации") + ErrUnsupportedSlides = errors.New("поддерживаются только PDF и PPTX") + ErrSlidesTooLarge = errors.New("презентация слишком большая") ) +type SlidesUpload struct { + Filename string + Content io.Reader +} + +func ValidateSlidesMeta(filename string, size int64) error { + if filename == "" { + return ErrSlidesRequired + } + switch strings.ToLower(filepath.Ext(filename)) { + case ".pdf", ".pptx": + default: + return ErrUnsupportedSlides + } + if size <= 0 { + return ErrEmptyFile + } + if size > maxSlidesBytes { + return ErrSlidesTooLarge + } + return nil +} + func DetectMedia(filename string) (media string, ok bool) { switch strings.ToLower(filepath.Ext(filename)) { case ".mp4", ".mov", ".mkv", ".webm", ".avi": diff --git a/internal/upload/validate_test.go b/internal/upload/validate_test.go index b84ed19..83aa8e5 100644 --- a/internal/upload/validate_test.go +++ b/internal/upload/validate_test.go @@ -89,3 +89,26 @@ func TestValidateYouTubeURL(t *testing.T) { }) } } + +func TestValidateSlidesMeta(t *testing.T) { + tests := []struct { + name string + filename string + size int64 + wantErr error + }{ + {name: "pdf", filename: "deck.pdf", size: 10, wantErr: nil}, + {name: "pptx uppercase", filename: "DECK.PPTX", size: 10, wantErr: nil}, + {name: "unsupported", filename: "deck.key", size: 10, wantErr: ErrUnsupportedSlides}, + {name: "empty", filename: "deck.pdf", size: 0, wantErr: ErrEmptyFile}, + {name: "too large", filename: "deck.pdf", size: maxSlidesBytes + 1, wantErr: ErrSlidesTooLarge}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateSlidesMeta(tt.filename, tt.size) + if !errors.Is(err, tt.wantErr) { + t.Fatalf("ValidateSlidesMeta() error = %v, want %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/web/assets/tailwind.css b/internal/web/assets/tailwind.css index 293f3eb..fbc25b9 100644 --- a/internal/web/assets/tailwind.css +++ b/internal/web/assets/tailwind.css @@ -999,6 +999,63 @@ outline-offset: 2px; } + .ll-upload-slides-option { + display: grid; + gap: 12px; + } + + .ll-upload-slides { + margin-left: 37px; + padding: 14px 16px; + border: 1px dashed var(--border-2); + border-radius: var(--r-card); + background: var(--surface-2); + } + + .ll-upload-slides-pick, + .ll-upload-slides-file button { + border: 1px solid var(--border-2); + border-radius: var(--r-btn); + background: var(--surface); + color: var(--ink); + font: 600 13px var(--font-sans); + cursor: pointer; + } + + .ll-upload-slides-pick { + min-height: 38px; + padding: 0 16px; + } + + .ll-upload-slides-file { + align-items: center; + justify-content: space-between; + gap: 12px; + font-size: 13px; + color: var(--ink); + } + + .ll-upload-slides-file:not(.ll-upload-hidden) { + display: flex; + } + + .ll-upload-slides-file span { + min-width: 0; + overflow-wrap: anywhere; + } + + .ll-upload-slides-file button { + flex: none; + padding: 7px 11px; + color: var(--text-2); + } + + .ll-upload-slides > p { + margin: 8px 0 0; + font-size: 12px; + color: var(--text-3); + } + .ll-upload-opt-title, .ll-upload-opt-text { display: block; diff --git a/internal/web/page_upload.templ b/internal/web/page_upload.templ index ca81046..8dc063b 100644 --- a/internal/web/page_upload.templ +++ b/internal/web/page_upload.templ @@ -50,14 +50,7 @@ templ UploadPage(data LayoutData) {
- ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -155,6 +158,35 @@ func UploadPage(data LayoutData) templ.Component { }) } +func slidesUploadOption() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var4 := templ.GetChildren(ctx) + if templ_7745c5c3_Var4 == nil { + templ_7745c5c3_Var4 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + func iconUploadFile() templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context @@ -176,7 +208,7 @@ func iconUploadFile() templ.Component { templ_7745c5c3_Var5 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -205,7 +237,7 @@ func iconUploadLink() templ.Component { templ_7745c5c3_Var6 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -234,7 +266,7 @@ func iconUploadArrow() templ.Component { templ_7745c5c3_Var7 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -263,7 +295,7 @@ func iconUploadMedia() templ.Component { templ_7745c5c3_Var8 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -292,7 +324,7 @@ func iconUploadX() templ.Component { templ_7745c5c3_Var9 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -321,7 +353,7 @@ func iconUploadPlay() templ.Component { templ_7745c5c3_Var10 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/internal/web/static/css/app.css b/internal/web/static/css/app.css index 0a8e1c6..d1214c1 100644 --- a/internal/web/static/css/app.css +++ b/internal/web/static/css/app.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.1.10 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial}}}@layer theme{:root,:host{--font-sans:var(--font-sans);--font-serif:var(--font-serif);--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}:root{color-scheme:light}[data-theme=dark]{color-scheme:dark}body{background:var(--bg);color:var(--ink);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;flex-direction:column;min-height:100vh;display:flex}::selection{background:var(--mark);color:var(--ink)}*{scrollbar-width:thin;scrollbar-color:var(--border-2)transparent}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-thumb{background:var(--border-2);background-clip:content-box;border:2px solid #0000;border-radius:5px}::-webkit-scrollbar-track{background:0 0}html{scroll-behavior:smooth}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}}@layer components{.ll-reader{font-family:var(--font-sans);color:var(--ink)}.ll-reader-progress{z-index:55;background:var(--rail);width:100%;height:3px;position:fixed;top:58px;left:0}.ll-reader-progress>div{background:var(--accent);width:0;height:100%;transition:width .12s linear}.ll-reader-export,.ll-reader-text-button{border:1px solid var(--border);border-radius:var(--r-btn);background:var(--surface);color:var(--text-2);font:600 13px var(--font-sans);cursor:pointer;padding:8px 12px;text-decoration:none}.ll-reader-export:hover,.ll-reader-text-button:hover{color:var(--ink);border-color:var(--border-2)}.ll-reader-search{z-index:70;border:1px solid var(--border);border-radius:var(--r-card);background:var(--surface);width:min(480px,100vw - 26px);box-shadow:var(--shadow);align-items:center;gap:8px;padding:9px 10px;display:flex;position:fixed;top:66px;right:22px}.ll-reader-search-field{flex:1}.ll-reader-search input{border:1px solid var(--border-2);border-radius:var(--r-btn);background:var(--surface);width:100%;color:var(--ink);font:14px var(--font-sans);outline:none;padding:10px 13px}.ll-reader-search input:focus{border-color:var(--accent)}.ll-reader-search-count{min-width:58px;color:var(--text-3);text-align:right;font-variant-numeric:tabular-nums;font-size:12px}.ll-reader-layout{align-items:flex-start;gap:54px;display:flex}.ll-reader-sidebar{flex:0 0 280px;max-height:calc(100vh - 100px);padding:4px 12px 24px 0;position:sticky;top:82px;overflow:auto}.ll-reader-source-card{border:1px solid var(--border);border-radius:var(--r-card);background:var(--surface);color:var(--text-2);flex-direction:column;gap:8px;padding:16px;font-size:13px;display:flex}.ll-reader-source-card strong{color:var(--ink);font-size:14px}.ll-reader-owner{color:var(--accent);font-weight:600}.ll-reader-reading{color:var(--text-3);margin:20px 0 25px;font-size:12px}.ll-reader-reading strong{float:right;color:var(--text-2);font-variant-numeric:tabular-nums}.ll-reader-reading div{clear:both;border-radius:var(--r-track);background:var(--rail);height:4px;margin-top:9px;overflow:hidden}.ll-reader-reading i{background:var(--accent);width:0;height:100%;display:block}.ll-reader-toc>p{color:var(--text-3);letter-spacing:.14em;text-transform:uppercase;margin:0 0 12px;font-size:10px;font-weight:700}.ll-reader-toc-section+.ll-reader-toc-section{margin-top:10px}.ll-reader-toc-section>div{color:var(--text-2);gap:9px;font-size:13px;display:flex}.ll-reader-toc-section>div span,.ll-reader-toc-link span{color:var(--text-3);font-variant-numeric:tabular-nums;flex:none}.ll-reader-toc-section ul{border-left:1px solid var(--border);margin:6px 0 0 9px;padding:0 0 0 12px;list-style:none}.ll-reader-toc-link{color:var(--text-2);border-left:2px solid #0000;gap:8px;padding:6px 0 6px 10px;font-size:13px;line-height:1.4;text-decoration:none;display:flex}.ll-reader-toc-link:hover,.ll-reader-toc-link.active{border-left-color:var(--accent);color:var(--accent)}.ll-reader-article{width:min(100%,720px);padding-bottom:60px}.ll-reader-doc-head{border-bottom:1px solid var(--border);padding:12px 0 46px}.ll-reader-doc-head p{color:var(--accent);letter-spacing:.14em;text-transform:uppercase;margin:0 0 10px;font-size:11px;font-weight:700}.ll-reader-doc-head h1{color:var(--ink);font:600 clamp(34px,5vw,52px)/1.08 var(--font-serif);letter-spacing:-.025em;margin:0}.ll-reader-doc-head div{color:var(--text-3);margin-top:17px;font-size:14px}.ll-reader-timeline{border-left:1px solid var(--rail);margin-top:45px;padding-left:32px;position:relative}.ll-reader-section+.ll-reader-section{margin-top:58px}.ll-reader-section-head{align-items:baseline;gap:15px;margin-left:-33px;display:flex}.ll-reader-section-head span{color:var(--accent);font-variant-numeric:tabular-nums;font-size:13px;font-weight:700}.ll-reader-section-head h2{font:600 27px/1.22 var(--font-serif);letter-spacing:-.015em;margin:0}.ll-reader-sub{margin-top:35px;scroll-margin-top:82px;position:relative}.ll-reader-sub:before{border:2px solid var(--bg);background:var(--accent);content:"";border-radius:50%;width:9px;height:9px;position:absolute;top:9px;left:-37px}.ll-reader-sub>header{align-items:baseline;gap:12px;display:flex}.ll-reader-sub>header span{color:var(--text-3);font-variant-numeric:tabular-nums;font-size:13px}.ll-reader-sub h3{color:var(--ink);font:600 22px/1.3 var(--font-serif);margin:0}.ll-reader-prose{color:var(--ink);font:18px/1.72 var(--font-serif)}.ll-reader-prose>:first-child{margin-top:15px}.ll-reader-prose a{color:var(--accent)}.ll-reader-prose blockquote,.ll-reader-prose details{border-left:3px solid var(--accent);border-radius:0 var(--r-card)var(--r-card)0;background:var(--accent-soft);margin:18px 0;padding:14px 17px}.ll-reader-player{border:1px solid var(--border);border-radius:var(--r-card);background:var(--surface);margin-top:18px;padding:13px}.ll-reader-player>div{color:var(--text-3);font-variant-numeric:tabular-nums;justify-content:space-between;margin-bottom:9px;font-size:12px;display:flex}.ll-reader-media-kind{color:var(--accent);font-weight:700}.ll-reader-player audio,.ll-reader-player video{width:100%;max-height:330px;display:block}.ll-reader-slide{border:1px solid var(--border);border-radius:var(--r-slide);background:var(--surface);margin:22px 0;overflow:hidden}.ll-reader-slide img{cursor:zoom-in;width:100%;display:block}.ll-reader-slide figcaption{color:var(--text-3);padding:9px 13px;font-size:12px}.ll-reader-foot{border-top:1px solid var(--border);color:var(--text-3);margin-top:58px;padding-top:18px;font-size:12px}.ll-reader-lightbox[hidden]{display:none}.ll-reader-lightbox{z-index:90;background:var(--ink);place-items:center;padding:4vw;display:grid;position:fixed;inset:0}@supports (color:color-mix(in lab, red, red)){.ll-reader-lightbox{background:color-mix(in srgb,var(--ink)76%,transparent)}}.ll-reader-lightbox{cursor:zoom-out}.ll-reader-lightbox img{border-radius:var(--r-slide);max-width:100%;max-height:90vh;box-shadow:var(--shadow)}.ll-reader-lightbox-close{border:1px solid var(--bg);border-radius:var(--r-btn);min-width:40px;min-height:40px;color:var(--bg);cursor:pointer;font-size:28px;line-height:1;position:absolute;top:22px;right:22px}.ll-reader-lightbox-close:focus-visible{outline:2px solid var(--bg);outline-offset:3px}.ll-reader-prose mark{background:var(--mark);color:var(--ink);-webkit-box-decoration-break:clone;box-decoration-break:clone;border-radius:3px;margin:-.05em;padding:.05em .15em}.ll-reader-prose mark.ll-reader-mark-current{background:var(--mark-cur);box-shadow:0 0 0 2px var(--mark-cur)}@media (max-width:880px){.ll-reader-layout{display:block}.ll-reader-sidebar{width:100%;max-height:none;margin-bottom:34px;padding:0;position:static}.ll-reader-article{width:100%}}@media (max-width:620px){.ll-reader-search{right:13px}.ll-reader-search-count{display:none}.ll-reader-layout{gap:0}.ll-reader-timeline{padding-left:26px}.ll-reader-sub:before{left:-31px}.ll-reader-doc-head h1{font-size:35px}}.ll-topbar{z-index:50;background:var(--bg-2);height:58px;position:sticky;top:0}@supports (color:color-mix(in lab, red, red)){.ll-topbar{background:color-mix(in srgb,var(--bg-2)86%,transparent)}}.ll-topbar{-webkit-backdrop-filter:saturate(1.05)blur(12px);backdrop-filter:saturate(1.05)blur(12px);border-bottom:1px solid var(--border)}.ll-topbar-inner{max-width:1240px;height:58px;font-family:var(--font-sans);align-items:center;gap:14px;margin:0 auto;padding:0 22px;display:flex}.ll-brand{flex:1;align-items:center;gap:11px;min-width:0;display:flex}.ll-brand-mark{background:var(--accent);width:24px;height:24px;color:var(--bg);border-radius:6px;flex:none;justify-content:center;align-items:center;display:flex}.ll-brand-mark svg{width:15px;height:15px}.ll-brand-t{color:var(--ink);white-space:nowrap;letter-spacing:-.02em;font-size:15px;font-weight:600}.ll-top-actions{flex:none;align-items:center;gap:8px;display:flex}.ll-icon-btn{width:37px;height:37px;color:var(--text-2);cursor:pointer;background:0 0;border:1px solid #0000;border-radius:10px;justify-content:center;align-items:center;transition:all .16s;display:inline-flex}.ll-icon-btn:hover{background:var(--surface-2);color:var(--ink)}.ll-icon-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.ll-icon-btn svg{width:18px;height:18px;display:block}.ll-icon-moon,.ll-icon-sun{display:block}[data-theme=light] .ll-icon-sun,:root:not([data-theme=dark]) .ll-icon-sun,[data-theme=dark] .ll-icon-moon{display:none}[data-theme=dark] .ll-icon-sun{display:block}.ll-main{max-width:1240px;margin:0 auto;padding:34px 22px 120px}.ll-demo-content{max-width:768px;font-family:var(--font-serif);color:var(--ink);margin:0 auto}.ll-demo-content h1{letter-spacing:-.02em;margin-bottom:16px;font-size:43px;font-weight:600;line-height:1.12}.ll-demo-content p{color:var(--text-2);font-size:19px;line-height:1.78}.ll-lectures-section{width:100%}.ll-lec-grid{grid-template-columns:repeat(auto-fill,minmax(252px,1fr));gap:16px;display:grid}.ll-lec-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r-card);flex-direction:column;gap:10px;padding:16px;transition:border-color .16s,box-shadow .16s,transform .16s;display:flex}.ll-lec-card:hover{border-color:var(--accent);box-shadow:var(--shadow);transform:translateY(-1px)}.ll-hub-card{color:inherit;text-decoration:none}.ll-hub-card:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.ll-lec-card-top{justify-content:space-between;align-items:center;gap:8px;display:flex}.ll-lec-card-actions{align-items:center;gap:4px;display:flex}.ll-lec-card-body{flex-direction:column;flex:1;gap:6px;display:flex}.ll-lec-card-footer{justify-content:space-between;align-items:center;gap:8px;margin-top:auto;display:flex}.ll-badge{border-radius:var(--r-pill);font-family:var(--font-sans);background:var(--accent-soft);color:var(--accent);align-items:center;padding:2px 8px;font-size:12px;font-weight:500;display:inline-flex}.ll-badge--muted{background:var(--surface-2);color:var(--text-2)}.ll-status{font-family:var(--font-sans);font-size:12.5px;font-weight:500}.ll-status--processing{color:var(--text-2);animation:1.8s ease-in-out infinite ll-pulse}@keyframes ll-pulse{0%,to{opacity:1}50%{opacity:.5}}.ll-status--ready{color:var(--accent);background:var(--accent-soft);border-radius:var(--r-pill);padding:2px 6px;display:inline-block}.ll-status--failed{color:var(--text-3)}.ll-lec-bar{border-radius:var(--r-track);background:var(--surface-2);border:1px solid var(--border);height:4px;margin-top:6px;overflow:hidden}.ll-lec-bar>i{background:var(--accent);height:100%;transition:width .3s;display:block}.ll-lec-error{font-family:var(--font-sans);color:var(--text-3);font-size:12px;line-height:1.4}.ll-lec-title{font-family:var(--font-serif);color:var(--ink);-webkit-line-clamp:2;resize:none;background:0 0;border:none;outline:none;-webkit-box-orient:vertical;width:100%;padding:0;font-size:16.5px;font-weight:600;line-height:1.35;display:-webkit-box;overflow:hidden}.ll-lec-title:focus{outline:1px solid var(--border-2);-webkit-line-clamp:unset;border-radius:3px}.ll-lec-title-form{align-items:flex-start;gap:4px;display:flex}.ll-lec-rename-btn{border:1px solid var(--border);background:var(--surface-2);width:28px;height:28px;color:var(--text-2);cursor:pointer;opacity:0;border-radius:6px;flex:none;justify-content:center;align-items:center;transition:opacity .12s;display:inline-flex}.ll-lec-title-form:focus-within .ll-lec-rename-btn{opacity:1}.ll-lec-rename-btn svg{width:14px;height:14px}.ll-lec-meta{font-family:var(--font-sans);color:var(--text-3);font-variant-numeric:tabular-nums;font-size:12.5px}.ll-hub-author{min-width:0;font-family:var(--font-sans);color:var(--text-2);align-items:center;gap:7px;font-size:12.5px;display:inline-flex}.ll-hub-avatar{border:1px solid var(--border);object-fit:cover;border-radius:50%;flex:none;width:22px;height:22px}.ll-toggle{border-radius:var(--r-pill);border:1px solid var(--border);background:var(--surface-2);color:var(--text-2);font-family:var(--font-sans);cursor:pointer;align-items:center;min-height:26px;padding:3px 10px;font-size:12px;font-weight:500;transition:all .16s;display:inline-flex}.ll-toggle:hover:not(:disabled){background:var(--surface);border-color:var(--accent);color:var(--accent)}.ll-toggle:disabled{opacity:.5;cursor:not-allowed}.ll-toggle--public{background:var(--accent-soft);border-color:var(--accent-soft);color:var(--accent)}.ll-empty{text-align:center;flex-direction:column;justify-content:center;align-items:center;gap:16px;padding:80px 24px;display:flex}.ll-empty-icon{width:48px;height:48px;color:var(--text-3)}.ll-empty-title{font-family:var(--font-serif);color:var(--ink);margin:0;font-size:22px;font-weight:600}.ll-empty-text{font-family:var(--font-sans);color:var(--text-2);max-width:360px;margin:0;font-size:15px;line-height:1.6}.ll-btn{border-radius:var(--r-btn);font-family:var(--font-sans);cursor:pointer;border:1px solid #0000;justify-content:center;align-items:center;min-height:40px;padding:9px 20px;font-size:14px;font-weight:500;text-decoration:none;transition:all .16s;display:inline-flex}.ll-btn--accent{background:var(--accent);color:var(--bg)}.ll-btn--accent:hover{background:var(--accent-2)}.ll-upload{width:100%;max-width:660px;font-family:var(--font-sans);color:var(--ink);margin:0 auto}.ll-upload-hidden{display:none!important}.ll-upload-head{margin-bottom:22px}.ll-upload-kicker{letter-spacing:.16em;text-transform:uppercase;color:var(--accent);margin-bottom:16px;font-size:11.5px;font-weight:600}.ll-upload-title{font-family:var(--font-serif);letter-spacing:0;color:var(--ink);margin:0 0 16px;font-size:43px;font-weight:600;line-height:1.12}.ll-upload-dek{font-family:var(--font-serif);color:var(--text-2);max-width:54ch;margin:0;font-size:20px;font-style:italic;line-height:1.55}.ll-upload-seg{background:var(--surface-2);border:1px solid var(--border);border-radius:var(--r-card);gap:3px;margin-bottom:22px;padding:3px;display:inline-flex}.ll-upload-seg-btn{font-family:var(--font-sans);color:var(--text-2);border-radius:var(--r-pill);cursor:pointer;background:0 0;border:none;align-items:center;gap:7px;padding:8px 18px;font-size:13px;font-weight:500;transition:all .14s;display:inline-flex}.ll-upload-seg-btn:hover,.ll-upload-seg-btn:focus-visible{color:var(--ink)}.ll-upload-seg-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.ll-upload-seg-btn svg{width:15px;height:15px}.ll-upload-seg-btn--active{background:var(--surface);color:var(--ink);box-shadow:var(--shadow)}.ll-upload-drop{border:1.5px dashed var(--border-2);border-radius:var(--r-card);background:var(--surface);text-align:center;padding:52px 40px;transition:border-color .18s,background .18s}.ll-upload-drop--over{border-color:var(--accent);background:var(--accent-soft)}.ll-upload-drop--error{border-color:var(--border-2)}.ll-upload-drop-icon{background:var(--accent-soft);width:58px;height:58px;color:var(--accent);border-radius:50%;justify-content:center;align-items:center;margin-bottom:18px;transition:transform .18s;display:inline-flex}.ll-upload-drop--over .ll-upload-drop-icon{transform:translateY(-3px)}.ll-upload-drop-icon svg{width:26px;height:26px}.ll-upload-drop-title{font-family:var(--font-serif);color:var(--ink);letter-spacing:0;margin:0 0 4px;font-size:21px;font-weight:600}.ll-upload-drop-sub{color:var(--text-2);margin:0 0 18px;font-size:13.5px}.ll-upload-pick{border:1px solid var(--border-2);background:var(--bg);border-radius:var(--r-btn);min-height:38px;color:var(--ink);cursor:pointer;font-family:var(--font-sans);align-items:center;gap:8px;padding:0 18px;font-size:13px;font-weight:500;transition:all .16s;display:inline-flex}.ll-upload-pick:hover,.ll-upload-pick:focus-visible{border-color:var(--accent);color:var(--accent)}.ll-upload-pick:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.ll-upload-pick svg{width:15px;height:15px}.ll-upload-formats{color:var(--text-3);font-variant-numeric:tabular-nums;margin:18px 0 0;font-size:12px}.ll-upload-file{text-align:left;align-items:center;gap:14px;display:flex}.ll-upload-file-icon{border-radius:var(--r-btn);background:var(--accent-soft);width:46px;height:46px;color:var(--accent);flex:none;justify-content:center;align-items:center;display:inline-flex}.ll-upload-file-icon svg{width:22px;height:22px}.ll-upload-file-meta{flex:1;min-width:0}.ll-upload-file-name{font-family:var(--font-serif);color:var(--ink);letter-spacing:0;white-space:nowrap;text-overflow:ellipsis;font-size:18px;font-weight:600;overflow:hidden}.ll-upload-file-sub{color:var(--text-3);font-variant-numeric:tabular-nums;margin-top:3px;font-size:12.5px}.ll-upload-file-bar{border-radius:var(--r-track);background:var(--surface-2);border:1px solid var(--border);height:4px;margin-top:13px;overflow:hidden}.ll-upload-file-bar>i{background:var(--accent);width:100%;height:100%;display:block}.ll-upload-file-remove{border-radius:var(--r-btn);border:1px solid var(--border-2);width:40px;height:40px;color:var(--text-2);cursor:pointer;background:0 0;flex:none;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.ll-upload-file-remove:hover,.ll-upload-file-remove:focus-visible{border-color:var(--accent);color:var(--accent)}.ll-upload-file-remove:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.ll-upload-file-remove svg{width:15px;height:15px}.ll-upload-errnote{color:var(--text-2);background:var(--surface);border:1px solid var(--border-2);border-radius:var(--r-btn);text-align:left;margin-top:16px;padding:10px 13px;font-size:13px;line-height:1.45;display:none}.ll-upload-errnote--show{display:block}.ll-upload-url-field{background:var(--surface);border:1px solid var(--border-2);border-radius:var(--r-card);align-items:center;gap:11px;min-height:54px;padding:0 15px;display:flex}.ll-upload-url-field svg{width:18px;height:18px;color:var(--text-3);flex:none}.ll-upload-url-field input{min-width:0;color:var(--ink);font-family:var(--font-sans);background:0 0;border:none;outline:none;flex:1;font-size:15px}.ll-upload-url-field input::placeholder{color:var(--text-3)}.ll-upload-url-hint{color:var(--text-3);margin:12px 2px 0;font-size:12.5px}.ll-upload-options{border-top:1px solid var(--border);gap:18px;margin-top:26px;padding-top:22px;display:grid}.ll-upload-check,.ll-upload-switch-row{cursor:pointer;align-items:flex-start;gap:14px;display:flex}.ll-upload-check input{opacity:0;pointer-events:none;position:absolute}.ll-upload-check-box{border:1px solid var(--border-2);border-radius:var(--r-pill);background:var(--surface-2);flex:none;width:23px;height:23px;margin-top:2px}.ll-upload-check input:checked+.ll-upload-check-box{background:var(--accent);border-color:var(--accent);box-shadow:inset 0 0 0 5px var(--surface)}.ll-upload-check input:focus-visible+.ll-upload-check-box{outline:2px solid var(--accent);outline-offset:2px}.ll-upload-opt-title,.ll-upload-opt-text{display:block}.ll-upload-opt-title{font-family:var(--font-serif);color:var(--ink);letter-spacing:0;font-size:17px;font-weight:600}.ll-upload-opt-text{color:var(--text-2);max-width:46ch;margin-top:3px;font-size:13px;line-height:1.5}.ll-upload-switch-row{justify-content:space-between}.ll-upload-switch-input{opacity:0;pointer-events:none;position:absolute}.ll-upload-switch{border-radius:var(--r-pill);background:var(--surface-2);border:1px solid var(--border-2);flex:none;width:44px;height:25px;margin-top:2px;transition:all .18s;position:relative}.ll-upload-switch:after{content:"";background:var(--bg);width:19px;height:19px;box-shadow:var(--shadow);border-radius:50%;transition:all .18s;position:absolute;top:2px;left:2px}.ll-upload-switch-input:checked+.ll-upload-switch{background:var(--accent);border-color:var(--accent)}.ll-upload-switch-input:checked+.ll-upload-switch:after{background:var(--surface);left:21px}.ll-upload-switch-input:disabled+.ll-upload-switch{opacity:.5;cursor:not-allowed}.ll-upload-switch-input:focus-visible+.ll-upload-switch{outline:2px solid var(--accent);outline-offset:2px}.ll-upload-cta-row{align-items:center;gap:18px;margin-top:34px;display:flex}.ll-upload-cta{border-radius:var(--r-btn);background:var(--accent);min-height:50px;color:var(--bg);cursor:pointer;font-family:var(--font-sans);border:none;justify-content:center;align-items:center;gap:10px;padding:0 30px;font-size:14.5px;font-weight:600;transition:all .16s;display:inline-flex}.ll-upload-cta:hover:not(:disabled),.ll-upload-cta:focus-visible:not(:disabled){background:var(--accent-2)}.ll-upload-cta:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.ll-upload-cta:disabled{opacity:.42;cursor:not-allowed;background:var(--accent)}.ll-upload-cta svg{width:17px;height:17px}.ll-upload-cta-note{color:var(--text-3);font-size:12.5px;line-height:1.45}@media (max-width:620px){.ll-upload-title{font-size:30px}.ll-upload-dek{font-size:18px}.ll-upload-drop{padding:40px 22px}.ll-upload-cta-row{flex-direction:column;align-items:stretch}.ll-upload-cta{width:100%}}.ll-settings{width:100%;max-width:660px;font-family:var(--font-sans);color:var(--ink);margin:0 auto}.ll-settings-head{margin-bottom:22px}.ll-settings-kicker{letter-spacing:.16em;text-transform:uppercase;color:var(--accent);margin-bottom:16px;font-size:11.5px;font-weight:600}.ll-settings-title{font-family:var(--font-serif);letter-spacing:0;color:var(--ink);margin:0;font-size:43px;font-weight:600;line-height:1.12}.ll-settings-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r-card);padding:24px 26px}.ll-settings-card-title{font-family:var(--font-serif);letter-spacing:-.015em;color:var(--ink);margin:0 0 6px;font-size:28px;font-weight:600;line-height:1.18}.ll-settings-card-sub{color:var(--text-2);margin:0 0 20px;font-size:14px;line-height:1.5}.ll-settings-warning{background:var(--surface-2);border-left:3px solid var(--border-2);border-radius:0 var(--r-btn)var(--r-btn)0;align-items:flex-start;gap:12px;margin-bottom:18px;padding:14px 16px;display:flex}.ll-settings-warning-icon{width:18px;height:18px;color:var(--text-2);flex:none;margin-top:1px}.ll-settings-warning-icon svg{width:18px;height:18px;display:block}.ll-settings-warning-kicker{letter-spacing:.14em;text-transform:uppercase;color:var(--text-2);margin-bottom:4px;font-size:11px;font-weight:700}.ll-settings-warning-text{color:var(--text-2);margin:0;font-size:13.5px;line-height:1.55}.ll-settings-status{border-bottom:1px solid var(--border);align-items:flex-start;gap:12px;margin-bottom:20px;padding:14px 0;display:flex}.ll-settings-status-dot{border:1.5px solid var(--border-2);background:0 0;border-radius:50%;flex:none;width:12px;height:12px;margin-top:4px}.ll-settings-status-dot--filled{border-color:var(--accent);background:var(--accent)}.ll-settings-status-body{flex:1;min-width:0}.ll-settings-status-title{font-family:var(--font-serif);color:var(--ink);font-size:17px;font-weight:600}.ll-settings-status-meta{color:var(--text-3);font-variant-numeric:tabular-nums;margin-top:3px;font-size:12.5px}.ll-settings-delete{border:1px solid var(--border-2);border-radius:var(--r-btn);min-height:37px;color:var(--text-2);font-family:var(--font-sans);cursor:pointer;background:0 0;flex:none;align-items:center;gap:7px;padding:0 14px;font-size:13px;font-weight:500;transition:all .16s;display:inline-flex}.ll-settings-delete:hover,.ll-settings-delete:focus-visible{border-color:var(--accent);color:var(--accent)}.ll-settings-delete:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.ll-settings-delete svg{width:15px;height:15px}.ll-settings-status--error{border-bottom-color:var(--border)}.ll-settings-status-icon{width:18px;height:18px;color:var(--text-2);flex:none;margin-top:2px}.ll-settings-status-icon svg{width:18px;height:18px;display:block}.ll-settings-status--error .ll-settings-status-title{font-family:var(--font-sans);color:var(--ink);font-size:14px;font-weight:500}.ll-settings-form{padding-top:4px}.ll-settings-file-label{color:var(--ink);margin-bottom:10px;font-size:13px;font-weight:600;display:block}.ll-settings-file-row{flex-wrap:wrap;align-items:center;gap:12px;display:flex}.ll-settings-file-pick{border:1px solid var(--border-2);background:var(--bg);border-radius:var(--r-btn);min-height:38px;color:var(--ink);cursor:pointer;font-family:var(--font-sans);align-items:center;gap:8px;padding:0 18px;font-size:13px;font-weight:500;transition:all .16s;display:inline-flex;position:relative}.ll-settings-file-pick:hover{border-color:var(--accent);color:var(--accent)}.ll-settings-file-pick svg{width:15px;height:15px}.ll-settings-file-input{opacity:0;cursor:pointer;width:100%;height:100%;position:absolute;inset:0}.ll-settings-file-input:focus-visible+span{outline:2px solid var(--accent);outline-offset:2px}.ll-settings-file-name{color:var(--text-3);text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:13px;overflow:hidden}.ll-settings-file-hint{color:var(--text-2);max-width:58ch;margin:14px 0 0;font-size:12.5px;line-height:1.55}.ll-settings-file-formats{color:var(--text-3);font-variant-numeric:tabular-nums;margin:8px 0 0;font-size:12px}.ll-settings-cta-row{align-items:center;gap:14px;margin-top:20px;display:flex}.ll-settings-cta{border-radius:var(--r-btn);background:var(--accent);min-height:44px;color:var(--bg);cursor:pointer;font-family:var(--font-sans);border:none;padding:0 24px;font-size:13.5px;font-weight:600;transition:all .16s}.ll-settings-cta:hover:not(:disabled),.ll-settings-cta:focus-visible:not(:disabled){background:var(--accent-2)}.ll-settings-cta:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.ll-settings-cta:disabled{opacity:.55;cursor:not-allowed}.ll-settings-indicator{color:var(--text-2);opacity:0;align-items:center;gap:7px;font-size:12.5px;display:inline-flex}.ll-settings-indicator.htmx-request{opacity:1}.ll-settings-spinner{width:14px;height:14px;animation:.8s linear infinite ll-spin}@keyframes ll-spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}@media (max-width:620px){.ll-settings-title{font-size:30px}.ll-settings-card{padding:20px}.ll-settings-status{flex-wrap:wrap}.ll-settings-delete{justify-content:center;width:100%}}.ll-main{flex:1 0 auto;width:100%}.ll-brand{color:inherit;border-radius:var(--r-btn);flex:none;margin-left:-2px;padding:5px 8px 5px 2px;text-decoration:none}.ll-brand:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.ll-brand:hover .ll-brand-t{color:var(--accent)}.ll-brand-t{transition:color .16s}.ll-nav{min-width:0;font-family:var(--font-sans);scrollbar-width:none;flex:1;align-items:center;gap:2px;margin-left:8px;display:flex;overflow-x:auto}.ll-nav::-webkit-scrollbar{display:none}.ll-nav-link{border-radius:var(--r-btn);color:var(--text-2);white-space:nowrap;padding:8px 11px;font-size:13.5px;font-weight:500;text-decoration:none;transition:color .16s,background .16s;position:relative}.ll-nav-link:hover{color:var(--ink);background:var(--surface-2)}.ll-nav-link:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.ll-nav-link--active{color:var(--ink)}.ll-nav-link--active:after{content:"";background:var(--accent);border-radius:1px;height:2px;position:absolute;bottom:3px;left:11px;right:11px}.ll-btn--login{border:1px solid var(--border-2);background:var(--surface);min-height:34px;color:var(--ink);padding:5px 15px;font-size:13.5px}.ll-btn--login:hover{border-color:var(--accent);color:var(--accent)}.ll-logout-form{display:contents}.ll-btn{gap:8px}.ll-btn svg{width:16px;height:16px}.ll-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.ll-btn--lg{min-height:50px;padding:0 28px;font-size:14.5px;font-weight:600}.ll-footer{border-top:1px solid var(--border);font-family:var(--font-sans)}.ll-footer-inner{flex-wrap:wrap;align-items:center;gap:18px;max-width:1240px;margin:0 auto;padding:24px 22px 32px;display:flex}.ll-footer-brand{color:var(--ink);align-items:center;gap:9px;font-size:13.5px;font-weight:600;display:inline-flex}.ll-footer-brand .ll-brand-mark{border-radius:5px;width:20px;height:20px}.ll-footer-brand .ll-brand-mark svg{width:12px;height:12px}.ll-footer-tag{min-width:240px;color:var(--text-3);flex:1;margin:0;font-size:12.5px}.ll-footer-link{color:var(--text-2);border-bottom:1px solid #0000;font-size:12.5px;text-decoration:none;transition:all .16s}.ll-footer-link:hover{color:var(--accent);border-bottom-color:var(--accent)}.ll-hero{text-align:center;margin-bottom:84px;padding-top:44px}.ll-hero-kicker{font-family:var(--font-sans);letter-spacing:.18em;text-transform:uppercase;color:var(--accent);margin:0 0 18px;font-size:11.5px;font-weight:600}.ll-hero-title{font-family:var(--font-serif);letter-spacing:-.025em;color:var(--ink);max-width:21ch;margin:0 auto 20px;font-size:clamp(36px,5.2vw,56px);font-weight:600;line-height:1.08}.ll-hero-dek{font-family:var(--font-serif);color:var(--text-2);max-width:52ch;margin:0 auto 30px;font-size:20px;font-style:italic;line-height:1.6}.ll-hero-cta{flex-wrap:wrap;justify-content:center;align-items:center;gap:16px;margin-bottom:64px;display:flex}.ll-hero-note{font-family:var(--font-sans);color:var(--text-3);font-variant-numeric:tabular-nums;font-size:12.5px}.ll-hero-steps{border-top:1px solid var(--border);text-align:left;grid-template-columns:repeat(3,1fr);gap:28px;margin:0;padding:30px 0 0;list-style:none;display:grid}.ll-hero-step-num{font-family:var(--font-sans);color:var(--accent);font-variant-numeric:tabular-nums;margin-bottom:8px;font-size:13px;font-weight:700;display:block}.ll-hero-step-title{font-family:var(--font-serif);color:var(--ink);margin-bottom:6px;font-size:18px;font-weight:600;display:block}.ll-hero-step-text{font-family:var(--font-sans);color:var(--text-2);margin:0;font-size:13.5px;line-height:1.55}.ll-section-head{align-items:baseline;gap:12px;margin-bottom:18px;display:flex}.ll-section-title{font-family:var(--font-serif);letter-spacing:-.01em;color:var(--ink);margin:0;font-size:22px;font-weight:600;line-height:1.2}.ll-section-count{font-family:var(--font-sans);color:var(--accent);background:var(--accent-soft);border-radius:var(--r-pill);font-variant-numeric:tabular-nums;padding:2px 9px;font-size:12.5px;font-weight:600}.ll-page-head{flex-wrap:wrap;justify-content:space-between;align-items:flex-end;gap:18px;margin-bottom:26px;display:flex}.ll-page-kicker{font-family:var(--font-sans);letter-spacing:.16em;text-transform:uppercase;color:var(--accent);margin:0 0 10px;font-size:11.5px;font-weight:600}.ll-page-title{font-family:var(--font-serif);letter-spacing:-.02em;color:var(--ink);margin:0;font-size:36px;font-weight:600;line-height:1.1}.ll-page-sub{font-family:var(--font-sans);color:var(--text-3);font-variant-numeric:tabular-nums;margin:8px 0 0;font-size:13px}.ll-page-head-cta{flex:none}.ll-hub-card-arrow{width:26px;height:26px;color:var(--accent);opacity:0;border-radius:50%;justify-content:center;align-items:center;transition:opacity .18s,transform .18s;display:inline-flex;transform:translate(-4px)}.ll-hub-card-arrow svg{width:16px;height:16px}.ll-hub-card:hover .ll-hub-card-arrow,.ll-hub-card:focus-visible .ll-hub-card-arrow{opacity:1;transform:none}.ll-hub-avatar--ph{background:var(--accent-soft);color:var(--accent);justify-content:center;align-items:center;font-size:11px;font-weight:600;line-height:1;display:inline-flex}.ll-hub-author-name{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.ll-empty-icon{background:var(--surface);border:1px solid var(--border);width:74px;height:74px;color:var(--text-3);border-radius:50%;justify-content:center;align-items:center;display:flex}.ll-empty-icon svg{width:30px;height:30px}.ll-empty--404{padding-top:70px}.ll-404-code{font-family:var(--font-serif);letter-spacing:-.04em;color:var(--border-2);font-variant-numeric:tabular-nums;font-size:96px;font-weight:600;line-height:1}@media (max-width:640px){.ll-topbar-inner{gap:8px;padding:0 13px}.ll-brand-t{display:none}.ll-nav{margin-left:0}.ll-nav-link{padding:8px 9px;font-size:13px}.ll-main,.ll-footer-inner{padding-left:13px;padding-right:13px}.ll-hero{text-align:left;margin-bottom:60px;padding-top:26px}.ll-hero-title,.ll-hero-dek{margin-left:0;margin-right:0}.ll-hero-cta{justify-content:flex-start;margin-bottom:44px}.ll-hero-steps{grid-template-columns:1fr;gap:22px}.ll-page-title{font-size:30px}.ll-page-head-cta{justify-content:center;width:100%}}.ll-lec-title{text-overflow:ellipsis}.ll-status-row{justify-content:space-between;align-items:center;gap:10px;display:flex}.ll-lec-read{font-family:var(--font-sans);color:var(--accent);border-radius:var(--r-pill);align-items:center;gap:5px;font-size:13px;font-weight:600;text-decoration:none;transition:gap .16s,color .16s;display:inline-flex}.ll-lec-read svg{width:14px;height:14px;transition:transform .16s}.ll-lec-read:hover{color:var(--accent-2)}.ll-lec-read:hover svg{transform:translate(2px)}.ll-lec-read:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.ll-landing{max-width:980px;margin:0 auto}.ll-btn--ghost{border:1px solid var(--border-2);background:var(--surface);color:var(--ink)}.ll-btn--ghost:hover{border-color:var(--accent);color:var(--accent)}.ll-land-section{border-top:1px solid var(--border);padding:46px 0 54px}.ll-landing .ll-hero{margin-bottom:0;padding-bottom:64px}.ll-landing .ll-hero-cta{margin-bottom:0}.ll-land-title{font-family:var(--font-serif);letter-spacing:-.015em;color:var(--ink);margin:0 0 26px;font-size:26px;font-weight:600;line-height:1.2}.ll-land-formats{grid-template-columns:repeat(3,1fr);gap:16px;display:grid}.ll-land-format{background:var(--surface);border:1px solid var(--border);border-radius:var(--r-card);font-family:var(--font-sans);flex-direction:column;gap:7px;padding:20px;display:flex}.ll-land-format-icon{border-radius:var(--r-btn);background:var(--accent-soft);width:40px;height:40px;color:var(--accent);justify-content:center;align-items:center;margin-bottom:6px;display:inline-flex}.ll-land-format-icon svg{width:20px;height:20px}.ll-land-format strong{font-family:var(--font-serif);color:var(--ink);font-size:18px;font-weight:600}.ll-land-format p{color:var(--text-2);flex:1;margin:0;font-size:13.5px;line-height:1.5}.ll-land-format-meta{color:var(--text-3);font-variant-numeric:tabular-nums;letter-spacing:.02em;font-size:12px}.ll-land-options{grid-template-columns:repeat(2,1fr);gap:16px;margin-top:16px;display:grid}.ll-land-option{border:1px dashed var(--border-2);border-radius:var(--r-card);font-family:var(--font-sans);align-items:flex-start;gap:12px;padding:16px 18px;display:flex}.ll-land-option .ll-badge{flex:none;margin-top:2px}.ll-land-option strong{font-family:var(--font-serif);color:var(--ink);margin-bottom:3px;font-size:16px;font-weight:600;display:block}.ll-land-option p{color:var(--text-2);margin:0;font-size:13px;line-height:1.5}.ll-hero-steps--landing{border-top:none;padding-top:0}.ll-land-final{border-top:1px solid var(--border);text-align:center;flex-direction:column;align-items:center;gap:20px;padding:52px 0 72px;display:flex}.ll-land-final-title{font-family:var(--font-serif);letter-spacing:-.02em;color:var(--ink);margin:0;font-size:30px;font-weight:600;line-height:1.15}.ll-land-final-link{font-family:var(--font-sans);color:var(--text-2);font-size:13.5px;text-decoration:none}.ll-land-final-link:hover{color:var(--accent)}@media (max-width:900px){.ll-land-formats,.ll-land-options{grid-template-columns:1fr}}.ll-reader-back{font-family:var(--font-sans);color:var(--text-2);margin-bottom:22px;font-size:13px;font-weight:500;text-decoration:none;transition:color .16s;display:inline-block}.ll-reader-back:hover{color:var(--accent)}.ll-reader-back:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.ll-reader-toc-box>summary{cursor:pointer;list-style:none}.ll-reader-toc-box>summary::-webkit-details-marker{display:none}.ll-reader-toc-summary{border:1px solid var(--border);border-radius:var(--r-btn);background:var(--surface);font-family:var(--font-sans);color:var(--ink);justify-content:space-between;align-items:center;gap:10px;padding:11px 14px;font-size:13px;font-weight:600;display:none}.ll-reader-toc-summary svg{width:15px;height:15px;color:var(--text-3);transition:transform .2s}.ll-reader-toc-box[open] .ll-reader-toc-summary svg{transform:rotate(180deg)}.ll-reader-toc-label-desktop{color:var(--text-3);font-family:var(--font-sans);letter-spacing:.14em;text-transform:uppercase;margin:0 0 12px;font-size:10px;font-weight:700}@media (max-width:880px){.ll-reader-toc-summary{display:flex}.ll-reader-toc-label-desktop{display:none}.ll-reader-toc-box .ll-reader-toc{border:1px solid var(--border);border-radius:var(--r-card);background:var(--surface);margin-top:12px;padding:14px 14px 10px}}.ll-reader-slides{gap:12px;margin:22px 0;display:grid}.ll-reader-slides--multi{grid-template-columns:repeat(2,1fr)}.ll-reader-slides .ll-reader-slide{margin:0}.ll-reader-slide{transition:border-color .16s}.ll-reader-slide:hover{border-color:var(--accent)}@media (max-width:620px){.ll-reader-slides--multi{grid-template-columns:1fr}}.ll-reader-prose .ll-callout{border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:0 var(--r-card)var(--r-card)0;background:var(--surface);margin:18px 0;padding:0}.ll-reader-prose .ll-callout>summary{cursor:pointer;font-family:var(--font-sans);letter-spacing:.14em;text-transform:uppercase;color:var(--accent);justify-content:space-between;align-items:center;gap:10px;padding:11px 15px;font-size:11.5px;font-weight:700;list-style:none;display:flex}.ll-reader-prose .ll-callout>summary::-webkit-details-marker{display:none}.ll-reader-prose .ll-callout>summary svg{width:15px;height:15px;color:var(--text-3);flex:none;transition:transform .22s}.ll-reader-prose .ll-callout[open]>summary{border-bottom:1px solid var(--border)}.ll-reader-prose .ll-callout[open]>summary svg{transform:rotate(180deg)}.ll-callout-body{padding:4px 17px 8px;font-size:17px;font-style:italic;line-height:1.66}.ll-share .ll-share-icon,.ll-share .ll-share-done{display:inline-flex}.ll-share .ll-share-done,.ll-share--done .ll-share-icon{display:none}.ll-share--done .ll-share-done{color:var(--accent);display:inline-flex}.ll-share--done{background:var(--accent-soft)}}@layer utilities{.visible{visibility:visible}.sr-only{clip:rect(0,0,0,0);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.static{position:static}.hidden{display:none}.resize{resize:both}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,visibility,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}}:root{--bg:#f4f2eb;--bg-2:#efece3;--surface:#fbf9f3;--surface-2:#ece9df;--ink:#26241e;--text-2:#6b665a;--text-3:#9c968a;--border:#e3dfd2;--border-2:#d3cdbc;--accent:#2f5a4c;--accent-2:#3c7060;--accent-soft:#e5ece7;--rail:#d8d3c5;--mark:#f0d896;--mark-cur:#e6bd55;--shadow:0 1px 2px #2822140a,0 10px 30px #2822140d;--font-serif:"Source Serif 4",Georgia,serif;--font-sans:"Onest",system-ui,sans-serif;--r-card:13px;--r-slide:11px;--r-btn:10px;--r-pill:7px;--r-track:5px}[data-theme=dark]{--bg:#16151a;--bg-2:#1c1b20;--surface:#222127;--surface-2:#2a2931;--ink:#ece8e0;--text-2:#a6a39d;--text-3:#726f6a;--border:#2f2e36;--border-2:#3f3e48;--accent:#7fcab0;--accent-2:#97d8c1;--accent-soft:#1d2a2a;--rail:#2f2e36;--mark:#574d2c;--mark-cur:#9a8338;--shadow:0 1px 2px #00000057,0 14px 38px #0000006b}@media print{.ll-topbar,.ll-footer,.ll-reader-progress,.ll-reader-search,.ll-reader-sidebar,.ll-reader-player,.ll-reader-lightbox,.ll-reader-export,.ll-reader-text-button{display:none!important}body{background:#fff;display:block}.ll-main{max-width:none;padding:0}.ll-reader-layout{display:block}.ll-reader-article{width:100%}.ll-reader-timeline{border-left:none;padding-left:0}.ll-reader-sub:before{display:none}.ll-reader-section-head{margin-left:0}.ll-reader-prose{color:#000}}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial}}}@layer theme{:root,:host{--font-sans:var(--font-sans);--font-serif:var(--font-serif);--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}:root{color-scheme:light}[data-theme=dark]{color-scheme:dark}body{background:var(--bg);color:var(--ink);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;flex-direction:column;min-height:100vh;display:flex}::selection{background:var(--mark);color:var(--ink)}*{scrollbar-width:thin;scrollbar-color:var(--border-2)transparent}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-thumb{background:var(--border-2);background-clip:content-box;border:2px solid #0000;border-radius:5px}::-webkit-scrollbar-track{background:0 0}html{scroll-behavior:smooth}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}}@layer components{.ll-reader{font-family:var(--font-sans);color:var(--ink)}.ll-reader-progress{z-index:55;background:var(--rail);width:100%;height:3px;position:fixed;top:58px;left:0}.ll-reader-progress>div{background:var(--accent);width:0;height:100%;transition:width .12s linear}.ll-reader-export,.ll-reader-text-button{border:1px solid var(--border);border-radius:var(--r-btn);background:var(--surface);color:var(--text-2);font:600 13px var(--font-sans);cursor:pointer;padding:8px 12px;text-decoration:none}.ll-reader-export:hover,.ll-reader-text-button:hover{color:var(--ink);border-color:var(--border-2)}.ll-reader-search{z-index:70;border:1px solid var(--border);border-radius:var(--r-card);background:var(--surface);width:min(480px,100vw - 26px);box-shadow:var(--shadow);align-items:center;gap:8px;padding:9px 10px;display:flex;position:fixed;top:66px;right:22px}.ll-reader-search-field{flex:1}.ll-reader-search input{border:1px solid var(--border-2);border-radius:var(--r-btn);background:var(--surface);width:100%;color:var(--ink);font:14px var(--font-sans);outline:none;padding:10px 13px}.ll-reader-search input:focus{border-color:var(--accent)}.ll-reader-search-count{min-width:58px;color:var(--text-3);text-align:right;font-variant-numeric:tabular-nums;font-size:12px}.ll-reader-layout{align-items:flex-start;gap:54px;display:flex}.ll-reader-sidebar{flex:0 0 280px;max-height:calc(100vh - 100px);padding:4px 12px 24px 0;position:sticky;top:82px;overflow:auto}.ll-reader-source-card{border:1px solid var(--border);border-radius:var(--r-card);background:var(--surface);color:var(--text-2);flex-direction:column;gap:8px;padding:16px;font-size:13px;display:flex}.ll-reader-source-card strong{color:var(--ink);font-size:14px}.ll-reader-owner{color:var(--accent);font-weight:600}.ll-reader-reading{color:var(--text-3);margin:20px 0 25px;font-size:12px}.ll-reader-reading strong{float:right;color:var(--text-2);font-variant-numeric:tabular-nums}.ll-reader-reading div{clear:both;border-radius:var(--r-track);background:var(--rail);height:4px;margin-top:9px;overflow:hidden}.ll-reader-reading i{background:var(--accent);width:0;height:100%;display:block}.ll-reader-toc>p{color:var(--text-3);letter-spacing:.14em;text-transform:uppercase;margin:0 0 12px;font-size:10px;font-weight:700}.ll-reader-toc-section+.ll-reader-toc-section{margin-top:10px}.ll-reader-toc-section>div{color:var(--text-2);gap:9px;font-size:13px;display:flex}.ll-reader-toc-section>div span,.ll-reader-toc-link span{color:var(--text-3);font-variant-numeric:tabular-nums;flex:none}.ll-reader-toc-section ul{border-left:1px solid var(--border);margin:6px 0 0 9px;padding:0 0 0 12px;list-style:none}.ll-reader-toc-link{color:var(--text-2);border-left:2px solid #0000;gap:8px;padding:6px 0 6px 10px;font-size:13px;line-height:1.4;text-decoration:none;display:flex}.ll-reader-toc-link:hover,.ll-reader-toc-link.active{border-left-color:var(--accent);color:var(--accent)}.ll-reader-article{width:min(100%,720px);padding-bottom:60px}.ll-reader-doc-head{border-bottom:1px solid var(--border);padding:12px 0 46px}.ll-reader-doc-head p{color:var(--accent);letter-spacing:.14em;text-transform:uppercase;margin:0 0 10px;font-size:11px;font-weight:700}.ll-reader-doc-head h1{color:var(--ink);font:600 clamp(34px,5vw,52px)/1.08 var(--font-serif);letter-spacing:-.025em;margin:0}.ll-reader-doc-head div{color:var(--text-3);margin-top:17px;font-size:14px}.ll-reader-timeline{border-left:1px solid var(--rail);margin-top:45px;padding-left:32px;position:relative}.ll-reader-section+.ll-reader-section{margin-top:58px}.ll-reader-section-head{align-items:baseline;gap:15px;margin-left:-33px;display:flex}.ll-reader-section-head span{color:var(--accent);font-variant-numeric:tabular-nums;font-size:13px;font-weight:700}.ll-reader-section-head h2{font:600 27px/1.22 var(--font-serif);letter-spacing:-.015em;margin:0}.ll-reader-sub{margin-top:35px;scroll-margin-top:82px;position:relative}.ll-reader-sub:before{border:2px solid var(--bg);background:var(--accent);content:"";border-radius:50%;width:9px;height:9px;position:absolute;top:9px;left:-37px}.ll-reader-sub>header{align-items:baseline;gap:12px;display:flex}.ll-reader-sub>header span{color:var(--text-3);font-variant-numeric:tabular-nums;font-size:13px}.ll-reader-sub h3{color:var(--ink);font:600 22px/1.3 var(--font-serif);margin:0}.ll-reader-prose{color:var(--ink);font:18px/1.72 var(--font-serif)}.ll-reader-prose>:first-child{margin-top:15px}.ll-reader-prose a{color:var(--accent)}.ll-reader-prose blockquote,.ll-reader-prose details{border-left:3px solid var(--accent);border-radius:0 var(--r-card)var(--r-card)0;background:var(--accent-soft);margin:18px 0;padding:14px 17px}.ll-reader-player{border:1px solid var(--border);border-radius:var(--r-card);background:var(--surface);margin-top:18px;padding:13px}.ll-reader-player>div{color:var(--text-3);font-variant-numeric:tabular-nums;justify-content:space-between;margin-bottom:9px;font-size:12px;display:flex}.ll-reader-media-kind{color:var(--accent);font-weight:700}.ll-reader-player audio,.ll-reader-player video{width:100%;max-height:330px;display:block}.ll-reader-slide{border:1px solid var(--border);border-radius:var(--r-slide);background:var(--surface);margin:22px 0;overflow:hidden}.ll-reader-slide img{cursor:zoom-in;width:100%;display:block}.ll-reader-slide figcaption{color:var(--text-3);padding:9px 13px;font-size:12px}.ll-reader-foot{border-top:1px solid var(--border);color:var(--text-3);margin-top:58px;padding-top:18px;font-size:12px}.ll-reader-lightbox[hidden]{display:none}.ll-reader-lightbox{z-index:90;background:var(--ink);place-items:center;padding:4vw;display:grid;position:fixed;inset:0}@supports (color:color-mix(in lab, red, red)){.ll-reader-lightbox{background:color-mix(in srgb,var(--ink)76%,transparent)}}.ll-reader-lightbox{cursor:zoom-out}.ll-reader-lightbox img{border-radius:var(--r-slide);max-width:100%;max-height:90vh;box-shadow:var(--shadow)}.ll-reader-lightbox-close{border:1px solid var(--bg);border-radius:var(--r-btn);min-width:40px;min-height:40px;color:var(--bg);cursor:pointer;font-size:28px;line-height:1;position:absolute;top:22px;right:22px}.ll-reader-lightbox-close:focus-visible{outline:2px solid var(--bg);outline-offset:3px}.ll-reader-prose mark{background:var(--mark);color:var(--ink);-webkit-box-decoration-break:clone;box-decoration-break:clone;border-radius:3px;margin:-.05em;padding:.05em .15em}.ll-reader-prose mark.ll-reader-mark-current{background:var(--mark-cur);box-shadow:0 0 0 2px var(--mark-cur)}@media (max-width:880px){.ll-reader-layout{display:block}.ll-reader-sidebar{width:100%;max-height:none;margin-bottom:34px;padding:0;position:static}.ll-reader-article{width:100%}}@media (max-width:620px){.ll-reader-search{right:13px}.ll-reader-search-count{display:none}.ll-reader-layout{gap:0}.ll-reader-timeline{padding-left:26px}.ll-reader-sub:before{left:-31px}.ll-reader-doc-head h1{font-size:35px}}.ll-topbar{z-index:50;background:var(--bg-2);height:58px;position:sticky;top:0}@supports (color:color-mix(in lab, red, red)){.ll-topbar{background:color-mix(in srgb,var(--bg-2)86%,transparent)}}.ll-topbar{-webkit-backdrop-filter:saturate(1.05)blur(12px);backdrop-filter:saturate(1.05)blur(12px);border-bottom:1px solid var(--border)}.ll-topbar-inner{max-width:1240px;height:58px;font-family:var(--font-sans);align-items:center;gap:14px;margin:0 auto;padding:0 22px;display:flex}.ll-brand{flex:1;align-items:center;gap:11px;min-width:0;display:flex}.ll-brand-mark{background:var(--accent);width:24px;height:24px;color:var(--bg);border-radius:6px;flex:none;justify-content:center;align-items:center;display:flex}.ll-brand-mark svg{width:15px;height:15px}.ll-brand-t{color:var(--ink);white-space:nowrap;letter-spacing:-.02em;font-size:15px;font-weight:600}.ll-top-actions{flex:none;align-items:center;gap:8px;display:flex}.ll-icon-btn{width:37px;height:37px;color:var(--text-2);cursor:pointer;background:0 0;border:1px solid #0000;border-radius:10px;justify-content:center;align-items:center;transition:all .16s;display:inline-flex}.ll-icon-btn:hover{background:var(--surface-2);color:var(--ink)}.ll-icon-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.ll-icon-btn svg{width:18px;height:18px;display:block}.ll-icon-moon,.ll-icon-sun{display:block}[data-theme=light] .ll-icon-sun,:root:not([data-theme=dark]) .ll-icon-sun,[data-theme=dark] .ll-icon-moon{display:none}[data-theme=dark] .ll-icon-sun{display:block}.ll-main{max-width:1240px;margin:0 auto;padding:34px 22px 120px}.ll-demo-content{max-width:768px;font-family:var(--font-serif);color:var(--ink);margin:0 auto}.ll-demo-content h1{letter-spacing:-.02em;margin-bottom:16px;font-size:43px;font-weight:600;line-height:1.12}.ll-demo-content p{color:var(--text-2);font-size:19px;line-height:1.78}.ll-lectures-section{width:100%}.ll-lec-grid{grid-template-columns:repeat(auto-fill,minmax(252px,1fr));gap:16px;display:grid}.ll-lec-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r-card);flex-direction:column;gap:10px;padding:16px;transition:border-color .16s,box-shadow .16s,transform .16s;display:flex}.ll-lec-card:hover{border-color:var(--accent);box-shadow:var(--shadow);transform:translateY(-1px)}.ll-hub-card{color:inherit;text-decoration:none}.ll-hub-card:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.ll-lec-card-top{justify-content:space-between;align-items:center;gap:8px;display:flex}.ll-lec-card-actions{align-items:center;gap:4px;display:flex}.ll-lec-card-body{flex-direction:column;flex:1;gap:6px;display:flex}.ll-lec-card-footer{justify-content:space-between;align-items:center;gap:8px;margin-top:auto;display:flex}.ll-badge{border-radius:var(--r-pill);font-family:var(--font-sans);background:var(--accent-soft);color:var(--accent);align-items:center;padding:2px 8px;font-size:12px;font-weight:500;display:inline-flex}.ll-badge--muted{background:var(--surface-2);color:var(--text-2)}.ll-status{font-family:var(--font-sans);font-size:12.5px;font-weight:500}.ll-status--processing{color:var(--text-2);animation:1.8s ease-in-out infinite ll-pulse}@keyframes ll-pulse{0%,to{opacity:1}50%{opacity:.5}}.ll-status--ready{color:var(--accent);background:var(--accent-soft);border-radius:var(--r-pill);padding:2px 6px;display:inline-block}.ll-status--failed{color:var(--text-3)}.ll-lec-bar{border-radius:var(--r-track);background:var(--surface-2);border:1px solid var(--border);height:4px;margin-top:6px;overflow:hidden}.ll-lec-bar>i{background:var(--accent);height:100%;transition:width .3s;display:block}.ll-lec-error{font-family:var(--font-sans);color:var(--text-3);font-size:12px;line-height:1.4}.ll-lec-title{font-family:var(--font-serif);color:var(--ink);-webkit-line-clamp:2;resize:none;background:0 0;border:none;outline:none;-webkit-box-orient:vertical;width:100%;padding:0;font-size:16.5px;font-weight:600;line-height:1.35;display:-webkit-box;overflow:hidden}.ll-lec-title:focus{outline:1px solid var(--border-2);-webkit-line-clamp:unset;border-radius:3px}.ll-lec-title-form{align-items:flex-start;gap:4px;display:flex}.ll-lec-rename-btn{border:1px solid var(--border);background:var(--surface-2);width:28px;height:28px;color:var(--text-2);cursor:pointer;opacity:0;border-radius:6px;flex:none;justify-content:center;align-items:center;transition:opacity .12s;display:inline-flex}.ll-lec-title-form:focus-within .ll-lec-rename-btn{opacity:1}.ll-lec-rename-btn svg{width:14px;height:14px}.ll-lec-meta{font-family:var(--font-sans);color:var(--text-3);font-variant-numeric:tabular-nums;font-size:12.5px}.ll-hub-author{min-width:0;font-family:var(--font-sans);color:var(--text-2);align-items:center;gap:7px;font-size:12.5px;display:inline-flex}.ll-hub-avatar{border:1px solid var(--border);object-fit:cover;border-radius:50%;flex:none;width:22px;height:22px}.ll-toggle{border-radius:var(--r-pill);border:1px solid var(--border);background:var(--surface-2);color:var(--text-2);font-family:var(--font-sans);cursor:pointer;align-items:center;min-height:26px;padding:3px 10px;font-size:12px;font-weight:500;transition:all .16s;display:inline-flex}.ll-toggle:hover:not(:disabled){background:var(--surface);border-color:var(--accent);color:var(--accent)}.ll-toggle:disabled{opacity:.5;cursor:not-allowed}.ll-toggle--public{background:var(--accent-soft);border-color:var(--accent-soft);color:var(--accent)}.ll-empty{text-align:center;flex-direction:column;justify-content:center;align-items:center;gap:16px;padding:80px 24px;display:flex}.ll-empty-icon{width:48px;height:48px;color:var(--text-3)}.ll-empty-title{font-family:var(--font-serif);color:var(--ink);margin:0;font-size:22px;font-weight:600}.ll-empty-text{font-family:var(--font-sans);color:var(--text-2);max-width:360px;margin:0;font-size:15px;line-height:1.6}.ll-btn{border-radius:var(--r-btn);font-family:var(--font-sans);cursor:pointer;border:1px solid #0000;justify-content:center;align-items:center;min-height:40px;padding:9px 20px;font-size:14px;font-weight:500;text-decoration:none;transition:all .16s;display:inline-flex}.ll-btn--accent{background:var(--accent);color:var(--bg)}.ll-btn--accent:hover{background:var(--accent-2)}.ll-upload{width:100%;max-width:660px;font-family:var(--font-sans);color:var(--ink);margin:0 auto}.ll-upload-hidden{display:none!important}.ll-upload-head{margin-bottom:22px}.ll-upload-kicker{letter-spacing:.16em;text-transform:uppercase;color:var(--accent);margin-bottom:16px;font-size:11.5px;font-weight:600}.ll-upload-title{font-family:var(--font-serif);letter-spacing:0;color:var(--ink);margin:0 0 16px;font-size:43px;font-weight:600;line-height:1.12}.ll-upload-dek{font-family:var(--font-serif);color:var(--text-2);max-width:54ch;margin:0;font-size:20px;font-style:italic;line-height:1.55}.ll-upload-seg{background:var(--surface-2);border:1px solid var(--border);border-radius:var(--r-card);gap:3px;margin-bottom:22px;padding:3px;display:inline-flex}.ll-upload-seg-btn{font-family:var(--font-sans);color:var(--text-2);border-radius:var(--r-pill);cursor:pointer;background:0 0;border:none;align-items:center;gap:7px;padding:8px 18px;font-size:13px;font-weight:500;transition:all .14s;display:inline-flex}.ll-upload-seg-btn:hover,.ll-upload-seg-btn:focus-visible{color:var(--ink)}.ll-upload-seg-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.ll-upload-seg-btn svg{width:15px;height:15px}.ll-upload-seg-btn--active{background:var(--surface);color:var(--ink);box-shadow:var(--shadow)}.ll-upload-drop{border:1.5px dashed var(--border-2);border-radius:var(--r-card);background:var(--surface);text-align:center;padding:52px 40px;transition:border-color .18s,background .18s}.ll-upload-drop--over{border-color:var(--accent);background:var(--accent-soft)}.ll-upload-drop--error{border-color:var(--border-2)}.ll-upload-drop-icon{background:var(--accent-soft);width:58px;height:58px;color:var(--accent);border-radius:50%;justify-content:center;align-items:center;margin-bottom:18px;transition:transform .18s;display:inline-flex}.ll-upload-drop--over .ll-upload-drop-icon{transform:translateY(-3px)}.ll-upload-drop-icon svg{width:26px;height:26px}.ll-upload-drop-title{font-family:var(--font-serif);color:var(--ink);letter-spacing:0;margin:0 0 4px;font-size:21px;font-weight:600}.ll-upload-drop-sub{color:var(--text-2);margin:0 0 18px;font-size:13.5px}.ll-upload-pick{border:1px solid var(--border-2);background:var(--bg);border-radius:var(--r-btn);min-height:38px;color:var(--ink);cursor:pointer;font-family:var(--font-sans);align-items:center;gap:8px;padding:0 18px;font-size:13px;font-weight:500;transition:all .16s;display:inline-flex}.ll-upload-pick:hover,.ll-upload-pick:focus-visible{border-color:var(--accent);color:var(--accent)}.ll-upload-pick:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.ll-upload-pick svg{width:15px;height:15px}.ll-upload-formats{color:var(--text-3);font-variant-numeric:tabular-nums;margin:18px 0 0;font-size:12px}.ll-upload-file{text-align:left;align-items:center;gap:14px;display:flex}.ll-upload-file-icon{border-radius:var(--r-btn);background:var(--accent-soft);width:46px;height:46px;color:var(--accent);flex:none;justify-content:center;align-items:center;display:inline-flex}.ll-upload-file-icon svg{width:22px;height:22px}.ll-upload-file-meta{flex:1;min-width:0}.ll-upload-file-name{font-family:var(--font-serif);color:var(--ink);letter-spacing:0;white-space:nowrap;text-overflow:ellipsis;font-size:18px;font-weight:600;overflow:hidden}.ll-upload-file-sub{color:var(--text-3);font-variant-numeric:tabular-nums;margin-top:3px;font-size:12.5px}.ll-upload-file-bar{border-radius:var(--r-track);background:var(--surface-2);border:1px solid var(--border);height:4px;margin-top:13px;overflow:hidden}.ll-upload-file-bar>i{background:var(--accent);width:100%;height:100%;display:block}.ll-upload-file-remove{border-radius:var(--r-btn);border:1px solid var(--border-2);width:40px;height:40px;color:var(--text-2);cursor:pointer;background:0 0;flex:none;justify-content:center;align-items:center;transition:all .14s;display:inline-flex}.ll-upload-file-remove:hover,.ll-upload-file-remove:focus-visible{border-color:var(--accent);color:var(--accent)}.ll-upload-file-remove:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.ll-upload-file-remove svg{width:15px;height:15px}.ll-upload-errnote{color:var(--text-2);background:var(--surface);border:1px solid var(--border-2);border-radius:var(--r-btn);text-align:left;margin-top:16px;padding:10px 13px;font-size:13px;line-height:1.45;display:none}.ll-upload-errnote--show{display:block}.ll-upload-url-field{background:var(--surface);border:1px solid var(--border-2);border-radius:var(--r-card);align-items:center;gap:11px;min-height:54px;padding:0 15px;display:flex}.ll-upload-url-field svg{width:18px;height:18px;color:var(--text-3);flex:none}.ll-upload-url-field input{min-width:0;color:var(--ink);font-family:var(--font-sans);background:0 0;border:none;outline:none;flex:1;font-size:15px}.ll-upload-url-field input::placeholder{color:var(--text-3)}.ll-upload-url-hint{color:var(--text-3);margin:12px 2px 0;font-size:12.5px}.ll-upload-options{border-top:1px solid var(--border);gap:18px;margin-top:26px;padding-top:22px;display:grid}.ll-upload-check,.ll-upload-switch-row{cursor:pointer;align-items:flex-start;gap:14px;display:flex}.ll-upload-check input{opacity:0;pointer-events:none;position:absolute}.ll-upload-check-box{border:1px solid var(--border-2);border-radius:var(--r-pill);background:var(--surface-2);flex:none;width:23px;height:23px;margin-top:2px}.ll-upload-check input:checked+.ll-upload-check-box{background:var(--accent);border-color:var(--accent);box-shadow:inset 0 0 0 5px var(--surface)}.ll-upload-check input:focus-visible+.ll-upload-check-box{outline:2px solid var(--accent);outline-offset:2px}.ll-upload-slides-option{gap:12px;display:grid}.ll-upload-slides{border:1px dashed var(--border-2);border-radius:var(--r-card);background:var(--surface-2);margin-left:37px;padding:14px 16px}.ll-upload-slides-pick,.ll-upload-slides-file button{border:1px solid var(--border-2);border-radius:var(--r-btn);background:var(--surface);color:var(--ink);font:600 13px var(--font-sans);cursor:pointer}.ll-upload-slides-pick{min-height:38px;padding:0 16px}.ll-upload-slides-file{color:var(--ink);justify-content:space-between;align-items:center;gap:12px;font-size:13px}.ll-upload-slides-file:not(.ll-upload-hidden){display:flex}.ll-upload-slides-file span{overflow-wrap:anywhere;min-width:0}.ll-upload-slides-file button{color:var(--text-2);flex:none;padding:7px 11px}.ll-upload-slides>p{color:var(--text-3);margin:8px 0 0;font-size:12px}.ll-upload-opt-title,.ll-upload-opt-text{display:block}.ll-upload-opt-title{font-family:var(--font-serif);color:var(--ink);letter-spacing:0;font-size:17px;font-weight:600}.ll-upload-opt-text{color:var(--text-2);max-width:46ch;margin-top:3px;font-size:13px;line-height:1.5}.ll-upload-switch-row{justify-content:space-between}.ll-upload-switch-input{opacity:0;pointer-events:none;position:absolute}.ll-upload-switch{border-radius:var(--r-pill);background:var(--surface-2);border:1px solid var(--border-2);flex:none;width:44px;height:25px;margin-top:2px;transition:all .18s;position:relative}.ll-upload-switch:after{content:"";background:var(--bg);width:19px;height:19px;box-shadow:var(--shadow);border-radius:50%;transition:all .18s;position:absolute;top:2px;left:2px}.ll-upload-switch-input:checked+.ll-upload-switch{background:var(--accent);border-color:var(--accent)}.ll-upload-switch-input:checked+.ll-upload-switch:after{background:var(--surface);left:21px}.ll-upload-switch-input:disabled+.ll-upload-switch{opacity:.5;cursor:not-allowed}.ll-upload-switch-input:focus-visible+.ll-upload-switch{outline:2px solid var(--accent);outline-offset:2px}.ll-upload-cta-row{align-items:center;gap:18px;margin-top:34px;display:flex}.ll-upload-cta{border-radius:var(--r-btn);background:var(--accent);min-height:50px;color:var(--bg);cursor:pointer;font-family:var(--font-sans);border:none;justify-content:center;align-items:center;gap:10px;padding:0 30px;font-size:14.5px;font-weight:600;transition:all .16s;display:inline-flex}.ll-upload-cta:hover:not(:disabled),.ll-upload-cta:focus-visible:not(:disabled){background:var(--accent-2)}.ll-upload-cta:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.ll-upload-cta:disabled{opacity:.42;cursor:not-allowed;background:var(--accent)}.ll-upload-cta svg{width:17px;height:17px}.ll-upload-cta-note{color:var(--text-3);font-size:12.5px;line-height:1.45}@media (max-width:620px){.ll-upload-title{font-size:30px}.ll-upload-dek{font-size:18px}.ll-upload-drop{padding:40px 22px}.ll-upload-cta-row{flex-direction:column;align-items:stretch}.ll-upload-cta{width:100%}}.ll-settings{width:100%;max-width:660px;font-family:var(--font-sans);color:var(--ink);margin:0 auto}.ll-settings-head{margin-bottom:22px}.ll-settings-kicker{letter-spacing:.16em;text-transform:uppercase;color:var(--accent);margin-bottom:16px;font-size:11.5px;font-weight:600}.ll-settings-title{font-family:var(--font-serif);letter-spacing:0;color:var(--ink);margin:0;font-size:43px;font-weight:600;line-height:1.12}.ll-settings-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r-card);padding:24px 26px}.ll-settings-card-title{font-family:var(--font-serif);letter-spacing:-.015em;color:var(--ink);margin:0 0 6px;font-size:28px;font-weight:600;line-height:1.18}.ll-settings-card-sub{color:var(--text-2);margin:0 0 20px;font-size:14px;line-height:1.5}.ll-settings-warning{background:var(--surface-2);border-left:3px solid var(--border-2);border-radius:0 var(--r-btn)var(--r-btn)0;align-items:flex-start;gap:12px;margin-bottom:18px;padding:14px 16px;display:flex}.ll-settings-warning-icon{width:18px;height:18px;color:var(--text-2);flex:none;margin-top:1px}.ll-settings-warning-icon svg{width:18px;height:18px;display:block}.ll-settings-warning-kicker{letter-spacing:.14em;text-transform:uppercase;color:var(--text-2);margin-bottom:4px;font-size:11px;font-weight:700}.ll-settings-warning-text{color:var(--text-2);margin:0;font-size:13.5px;line-height:1.55}.ll-settings-status{border-bottom:1px solid var(--border);align-items:flex-start;gap:12px;margin-bottom:20px;padding:14px 0;display:flex}.ll-settings-status-dot{border:1.5px solid var(--border-2);background:0 0;border-radius:50%;flex:none;width:12px;height:12px;margin-top:4px}.ll-settings-status-dot--filled{border-color:var(--accent);background:var(--accent)}.ll-settings-status-body{flex:1;min-width:0}.ll-settings-status-title{font-family:var(--font-serif);color:var(--ink);font-size:17px;font-weight:600}.ll-settings-status-meta{color:var(--text-3);font-variant-numeric:tabular-nums;margin-top:3px;font-size:12.5px}.ll-settings-delete{border:1px solid var(--border-2);border-radius:var(--r-btn);min-height:37px;color:var(--text-2);font-family:var(--font-sans);cursor:pointer;background:0 0;flex:none;align-items:center;gap:7px;padding:0 14px;font-size:13px;font-weight:500;transition:all .16s;display:inline-flex}.ll-settings-delete:hover,.ll-settings-delete:focus-visible{border-color:var(--accent);color:var(--accent)}.ll-settings-delete:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.ll-settings-delete svg{width:15px;height:15px}.ll-settings-status--error{border-bottom-color:var(--border)}.ll-settings-status-icon{width:18px;height:18px;color:var(--text-2);flex:none;margin-top:2px}.ll-settings-status-icon svg{width:18px;height:18px;display:block}.ll-settings-status--error .ll-settings-status-title{font-family:var(--font-sans);color:var(--ink);font-size:14px;font-weight:500}.ll-settings-form{padding-top:4px}.ll-settings-file-label{color:var(--ink);margin-bottom:10px;font-size:13px;font-weight:600;display:block}.ll-settings-file-row{flex-wrap:wrap;align-items:center;gap:12px;display:flex}.ll-settings-file-pick{border:1px solid var(--border-2);background:var(--bg);border-radius:var(--r-btn);min-height:38px;color:var(--ink);cursor:pointer;font-family:var(--font-sans);align-items:center;gap:8px;padding:0 18px;font-size:13px;font-weight:500;transition:all .16s;display:inline-flex;position:relative}.ll-settings-file-pick:hover{border-color:var(--accent);color:var(--accent)}.ll-settings-file-pick svg{width:15px;height:15px}.ll-settings-file-input{opacity:0;cursor:pointer;width:100%;height:100%;position:absolute;inset:0}.ll-settings-file-input:focus-visible+span{outline:2px solid var(--accent);outline-offset:2px}.ll-settings-file-name{color:var(--text-3);text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:13px;overflow:hidden}.ll-settings-file-hint{color:var(--text-2);max-width:58ch;margin:14px 0 0;font-size:12.5px;line-height:1.55}.ll-settings-file-formats{color:var(--text-3);font-variant-numeric:tabular-nums;margin:8px 0 0;font-size:12px}.ll-settings-cta-row{align-items:center;gap:14px;margin-top:20px;display:flex}.ll-settings-cta{border-radius:var(--r-btn);background:var(--accent);min-height:44px;color:var(--bg);cursor:pointer;font-family:var(--font-sans);border:none;padding:0 24px;font-size:13.5px;font-weight:600;transition:all .16s}.ll-settings-cta:hover:not(:disabled),.ll-settings-cta:focus-visible:not(:disabled){background:var(--accent-2)}.ll-settings-cta:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.ll-settings-cta:disabled{opacity:.55;cursor:not-allowed}.ll-settings-indicator{color:var(--text-2);opacity:0;align-items:center;gap:7px;font-size:12.5px;display:inline-flex}.ll-settings-indicator.htmx-request{opacity:1}.ll-settings-spinner{width:14px;height:14px;animation:.8s linear infinite ll-spin}@keyframes ll-spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}@media (max-width:620px){.ll-settings-title{font-size:30px}.ll-settings-card{padding:20px}.ll-settings-status{flex-wrap:wrap}.ll-settings-delete{justify-content:center;width:100%}}.ll-main{flex:1 0 auto;width:100%}.ll-brand{color:inherit;border-radius:var(--r-btn);flex:none;margin-left:-2px;padding:5px 8px 5px 2px;text-decoration:none}.ll-brand:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.ll-brand:hover .ll-brand-t{color:var(--accent)}.ll-brand-t{transition:color .16s}.ll-nav{min-width:0;font-family:var(--font-sans);scrollbar-width:none;flex:1;align-items:center;gap:2px;margin-left:8px;display:flex;overflow-x:auto}.ll-nav::-webkit-scrollbar{display:none}.ll-nav-link{border-radius:var(--r-btn);color:var(--text-2);white-space:nowrap;padding:8px 11px;font-size:13.5px;font-weight:500;text-decoration:none;transition:color .16s,background .16s;position:relative}.ll-nav-link:hover{color:var(--ink);background:var(--surface-2)}.ll-nav-link:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.ll-nav-link--active{color:var(--ink)}.ll-nav-link--active:after{content:"";background:var(--accent);border-radius:1px;height:2px;position:absolute;bottom:3px;left:11px;right:11px}.ll-btn--login{border:1px solid var(--border-2);background:var(--surface);min-height:34px;color:var(--ink);padding:5px 15px;font-size:13.5px}.ll-btn--login:hover{border-color:var(--accent);color:var(--accent)}.ll-logout-form{display:contents}.ll-btn{gap:8px}.ll-btn svg{width:16px;height:16px}.ll-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.ll-btn--lg{min-height:50px;padding:0 28px;font-size:14.5px;font-weight:600}.ll-footer{border-top:1px solid var(--border);font-family:var(--font-sans)}.ll-footer-inner{flex-wrap:wrap;align-items:center;gap:18px;max-width:1240px;margin:0 auto;padding:24px 22px 32px;display:flex}.ll-footer-brand{color:var(--ink);align-items:center;gap:9px;font-size:13.5px;font-weight:600;display:inline-flex}.ll-footer-brand .ll-brand-mark{border-radius:5px;width:20px;height:20px}.ll-footer-brand .ll-brand-mark svg{width:12px;height:12px}.ll-footer-tag{min-width:240px;color:var(--text-3);flex:1;margin:0;font-size:12.5px}.ll-footer-link{color:var(--text-2);border-bottom:1px solid #0000;font-size:12.5px;text-decoration:none;transition:all .16s}.ll-footer-link:hover{color:var(--accent);border-bottom-color:var(--accent)}.ll-hero{text-align:center;margin-bottom:84px;padding-top:44px}.ll-hero-kicker{font-family:var(--font-sans);letter-spacing:.18em;text-transform:uppercase;color:var(--accent);margin:0 0 18px;font-size:11.5px;font-weight:600}.ll-hero-title{font-family:var(--font-serif);letter-spacing:-.025em;color:var(--ink);max-width:21ch;margin:0 auto 20px;font-size:clamp(36px,5.2vw,56px);font-weight:600;line-height:1.08}.ll-hero-dek{font-family:var(--font-serif);color:var(--text-2);max-width:52ch;margin:0 auto 30px;font-size:20px;font-style:italic;line-height:1.6}.ll-hero-cta{flex-wrap:wrap;justify-content:center;align-items:center;gap:16px;margin-bottom:64px;display:flex}.ll-hero-note{font-family:var(--font-sans);color:var(--text-3);font-variant-numeric:tabular-nums;font-size:12.5px}.ll-hero-steps{border-top:1px solid var(--border);text-align:left;grid-template-columns:repeat(3,1fr);gap:28px;margin:0;padding:30px 0 0;list-style:none;display:grid}.ll-hero-step-num{font-family:var(--font-sans);color:var(--accent);font-variant-numeric:tabular-nums;margin-bottom:8px;font-size:13px;font-weight:700;display:block}.ll-hero-step-title{font-family:var(--font-serif);color:var(--ink);margin-bottom:6px;font-size:18px;font-weight:600;display:block}.ll-hero-step-text{font-family:var(--font-sans);color:var(--text-2);margin:0;font-size:13.5px;line-height:1.55}.ll-section-head{align-items:baseline;gap:12px;margin-bottom:18px;display:flex}.ll-section-title{font-family:var(--font-serif);letter-spacing:-.01em;color:var(--ink);margin:0;font-size:22px;font-weight:600;line-height:1.2}.ll-section-count{font-family:var(--font-sans);color:var(--accent);background:var(--accent-soft);border-radius:var(--r-pill);font-variant-numeric:tabular-nums;padding:2px 9px;font-size:12.5px;font-weight:600}.ll-page-head{flex-wrap:wrap;justify-content:space-between;align-items:flex-end;gap:18px;margin-bottom:26px;display:flex}.ll-page-kicker{font-family:var(--font-sans);letter-spacing:.16em;text-transform:uppercase;color:var(--accent);margin:0 0 10px;font-size:11.5px;font-weight:600}.ll-page-title{font-family:var(--font-serif);letter-spacing:-.02em;color:var(--ink);margin:0;font-size:36px;font-weight:600;line-height:1.1}.ll-page-sub{font-family:var(--font-sans);color:var(--text-3);font-variant-numeric:tabular-nums;margin:8px 0 0;font-size:13px}.ll-page-head-cta{flex:none}.ll-hub-card-arrow{width:26px;height:26px;color:var(--accent);opacity:0;border-radius:50%;justify-content:center;align-items:center;transition:opacity .18s,transform .18s;display:inline-flex;transform:translate(-4px)}.ll-hub-card-arrow svg{width:16px;height:16px}.ll-hub-card:hover .ll-hub-card-arrow,.ll-hub-card:focus-visible .ll-hub-card-arrow{opacity:1;transform:none}.ll-hub-avatar--ph{background:var(--accent-soft);color:var(--accent);justify-content:center;align-items:center;font-size:11px;font-weight:600;line-height:1;display:inline-flex}.ll-hub-author-name{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.ll-empty-icon{background:var(--surface);border:1px solid var(--border);width:74px;height:74px;color:var(--text-3);border-radius:50%;justify-content:center;align-items:center;display:flex}.ll-empty-icon svg{width:30px;height:30px}.ll-empty--404{padding-top:70px}.ll-404-code{font-family:var(--font-serif);letter-spacing:-.04em;color:var(--border-2);font-variant-numeric:tabular-nums;font-size:96px;font-weight:600;line-height:1}@media (max-width:640px){.ll-topbar-inner{gap:8px;padding:0 13px}.ll-brand-t{display:none}.ll-nav{margin-left:0}.ll-nav-link{padding:8px 9px;font-size:13px}.ll-main,.ll-footer-inner{padding-left:13px;padding-right:13px}.ll-hero{text-align:left;margin-bottom:60px;padding-top:26px}.ll-hero-title,.ll-hero-dek{margin-left:0;margin-right:0}.ll-hero-cta{justify-content:flex-start;margin-bottom:44px}.ll-hero-steps{grid-template-columns:1fr;gap:22px}.ll-page-title{font-size:30px}.ll-page-head-cta{justify-content:center;width:100%}}.ll-lec-title{text-overflow:ellipsis}.ll-status-row{justify-content:space-between;align-items:center;gap:10px;display:flex}.ll-lec-read{font-family:var(--font-sans);color:var(--accent);border-radius:var(--r-pill);align-items:center;gap:5px;font-size:13px;font-weight:600;text-decoration:none;transition:gap .16s,color .16s;display:inline-flex}.ll-lec-read svg{width:14px;height:14px;transition:transform .16s}.ll-lec-read:hover{color:var(--accent-2)}.ll-lec-read:hover svg{transform:translate(2px)}.ll-lec-read:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.ll-landing{max-width:980px;margin:0 auto}.ll-btn--ghost{border:1px solid var(--border-2);background:var(--surface);color:var(--ink)}.ll-btn--ghost:hover{border-color:var(--accent);color:var(--accent)}.ll-land-section{border-top:1px solid var(--border);padding:46px 0 54px}.ll-landing .ll-hero{margin-bottom:0;padding-bottom:64px}.ll-landing .ll-hero-cta{margin-bottom:0}.ll-land-title{font-family:var(--font-serif);letter-spacing:-.015em;color:var(--ink);margin:0 0 26px;font-size:26px;font-weight:600;line-height:1.2}.ll-land-formats{grid-template-columns:repeat(3,1fr);gap:16px;display:grid}.ll-land-format{background:var(--surface);border:1px solid var(--border);border-radius:var(--r-card);font-family:var(--font-sans);flex-direction:column;gap:7px;padding:20px;display:flex}.ll-land-format-icon{border-radius:var(--r-btn);background:var(--accent-soft);width:40px;height:40px;color:var(--accent);justify-content:center;align-items:center;margin-bottom:6px;display:inline-flex}.ll-land-format-icon svg{width:20px;height:20px}.ll-land-format strong{font-family:var(--font-serif);color:var(--ink);font-size:18px;font-weight:600}.ll-land-format p{color:var(--text-2);flex:1;margin:0;font-size:13.5px;line-height:1.5}.ll-land-format-meta{color:var(--text-3);font-variant-numeric:tabular-nums;letter-spacing:.02em;font-size:12px}.ll-land-options{grid-template-columns:repeat(2,1fr);gap:16px;margin-top:16px;display:grid}.ll-land-option{border:1px dashed var(--border-2);border-radius:var(--r-card);font-family:var(--font-sans);align-items:flex-start;gap:12px;padding:16px 18px;display:flex}.ll-land-option .ll-badge{flex:none;margin-top:2px}.ll-land-option strong{font-family:var(--font-serif);color:var(--ink);margin-bottom:3px;font-size:16px;font-weight:600;display:block}.ll-land-option p{color:var(--text-2);margin:0;font-size:13px;line-height:1.5}.ll-hero-steps--landing{border-top:none;padding-top:0}.ll-land-final{border-top:1px solid var(--border);text-align:center;flex-direction:column;align-items:center;gap:20px;padding:52px 0 72px;display:flex}.ll-land-final-title{font-family:var(--font-serif);letter-spacing:-.02em;color:var(--ink);margin:0;font-size:30px;font-weight:600;line-height:1.15}.ll-land-final-link{font-family:var(--font-sans);color:var(--text-2);font-size:13.5px;text-decoration:none}.ll-land-final-link:hover{color:var(--accent)}@media (max-width:900px){.ll-land-formats,.ll-land-options{grid-template-columns:1fr}}.ll-reader-back{font-family:var(--font-sans);color:var(--text-2);margin-bottom:22px;font-size:13px;font-weight:500;text-decoration:none;transition:color .16s;display:inline-block}.ll-reader-back:hover{color:var(--accent)}.ll-reader-back:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.ll-reader-toc-box>summary{cursor:pointer;list-style:none}.ll-reader-toc-box>summary::-webkit-details-marker{display:none}.ll-reader-toc-summary{border:1px solid var(--border);border-radius:var(--r-btn);background:var(--surface);font-family:var(--font-sans);color:var(--ink);justify-content:space-between;align-items:center;gap:10px;padding:11px 14px;font-size:13px;font-weight:600;display:none}.ll-reader-toc-summary svg{width:15px;height:15px;color:var(--text-3);transition:transform .2s}.ll-reader-toc-box[open] .ll-reader-toc-summary svg{transform:rotate(180deg)}.ll-reader-toc-label-desktop{color:var(--text-3);font-family:var(--font-sans);letter-spacing:.14em;text-transform:uppercase;margin:0 0 12px;font-size:10px;font-weight:700}@media (max-width:880px){.ll-reader-toc-summary{display:flex}.ll-reader-toc-label-desktop{display:none}.ll-reader-toc-box .ll-reader-toc{border:1px solid var(--border);border-radius:var(--r-card);background:var(--surface);margin-top:12px;padding:14px 14px 10px}}.ll-reader-slides{gap:12px;margin:22px 0;display:grid}.ll-reader-slides--multi{grid-template-columns:repeat(2,1fr)}.ll-reader-slides .ll-reader-slide{margin:0}.ll-reader-slide{transition:border-color .16s}.ll-reader-slide:hover{border-color:var(--accent)}@media (max-width:620px){.ll-reader-slides--multi{grid-template-columns:1fr}}.ll-reader-prose .ll-callout{border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:0 var(--r-card)var(--r-card)0;background:var(--surface);margin:18px 0;padding:0}.ll-reader-prose .ll-callout>summary{cursor:pointer;font-family:var(--font-sans);letter-spacing:.14em;text-transform:uppercase;color:var(--accent);justify-content:space-between;align-items:center;gap:10px;padding:11px 15px;font-size:11.5px;font-weight:700;list-style:none;display:flex}.ll-reader-prose .ll-callout>summary::-webkit-details-marker{display:none}.ll-reader-prose .ll-callout>summary svg{width:15px;height:15px;color:var(--text-3);flex:none;transition:transform .22s}.ll-reader-prose .ll-callout[open]>summary{border-bottom:1px solid var(--border)}.ll-reader-prose .ll-callout[open]>summary svg{transform:rotate(180deg)}.ll-callout-body{padding:4px 17px 8px;font-size:17px;font-style:italic;line-height:1.66}.ll-share .ll-share-icon,.ll-share .ll-share-done{display:inline-flex}.ll-share .ll-share-done,.ll-share--done .ll-share-icon{display:none}.ll-share--done .ll-share-done{color:var(--accent);display:inline-flex}.ll-share--done{background:var(--accent-soft)}}@layer utilities{.visible{visibility:visible}.sr-only{clip:rect(0,0,0,0);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.static{position:static}.hidden{display:none}.resize{resize:both}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,visibility,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}}:root{--bg:#f4f2eb;--bg-2:#efece3;--surface:#fbf9f3;--surface-2:#ece9df;--ink:#26241e;--text-2:#6b665a;--text-3:#9c968a;--border:#e3dfd2;--border-2:#d3cdbc;--accent:#2f5a4c;--accent-2:#3c7060;--accent-soft:#e5ece7;--rail:#d8d3c5;--mark:#f0d896;--mark-cur:#e6bd55;--shadow:0 1px 2px #2822140a,0 10px 30px #2822140d;--font-serif:"Source Serif 4",Georgia,serif;--font-sans:"Onest",system-ui,sans-serif;--r-card:13px;--r-slide:11px;--r-btn:10px;--r-pill:7px;--r-track:5px}[data-theme=dark]{--bg:#16151a;--bg-2:#1c1b20;--surface:#222127;--surface-2:#2a2931;--ink:#ece8e0;--text-2:#a6a39d;--text-3:#726f6a;--border:#2f2e36;--border-2:#3f3e48;--accent:#7fcab0;--accent-2:#97d8c1;--accent-soft:#1d2a2a;--rail:#2f2e36;--mark:#574d2c;--mark-cur:#9a8338;--shadow:0 1px 2px #00000057,0 14px 38px #0000006b}@media print{.ll-topbar,.ll-footer,.ll-reader-progress,.ll-reader-search,.ll-reader-sidebar,.ll-reader-player,.ll-reader-lightbox,.ll-reader-export,.ll-reader-text-button{display:none!important}body{background:#fff;display:block}.ll-main{max-width:none;padding:0}.ll-reader-layout{display:block}.ll-reader-article{width:100%}.ll-reader-timeline{border-left:none;padding-left:0}.ll-reader-sub:before{display:none}.ll-reader-section-head{margin-left:0}.ll-reader-prose{color:#000}}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false} \ No newline at end of file diff --git a/internal/web/static/js/upload.js b/internal/web/static/js/upload.js index a9b1a96..24d8896 100644 --- a/internal/web/static/js/upload.js +++ b/internal/web/static/js/upload.js @@ -10,6 +10,7 @@ var csrf = root.getAttribute('data-csrf') || ''; var filePanel = root.querySelector('[data-panel="file"]'); var urlPanel = root.querySelector('[data-panel="url"]'); + var urlForm = root.querySelector('[data-url-form]'); var modeButtons = Array.prototype.slice.call(root.querySelectorAll('[data-mode]')); var drop = root.querySelector('[data-drop]'); var dropEmpty = root.querySelector('[data-drop-empty]'); @@ -22,10 +23,17 @@ var fileSubmit = root.querySelector('[data-file-submit]'); var submitLabel = root.querySelector('[data-submit-label]'); var status = root.querySelector('[data-status]'); - var error = root.querySelector('[data-error]'); + var errors = Array.prototype.slice.call(root.querySelectorAll('[data-error]')); var hasPDFInputs = Array.prototype.slice.call(root.querySelectorAll('[data-has-pdf]')); var extractInputs = Array.prototype.slice.call(root.querySelectorAll('[data-extract-slides]')); + var slidesAreas = Array.prototype.slice.call(root.querySelectorAll('[data-slides-area]')); + var slidesInputs = Array.prototype.slice.call(root.querySelectorAll('[data-slides-input]')); + var slidesPickers = Array.prototype.slice.call(root.querySelectorAll('[data-slides-pick]')); + var slidesFiles = Array.prototype.slice.call(root.querySelectorAll('[data-slides-file]')); + var slidesNames = Array.prototype.slice.call(root.querySelectorAll('[data-slides-name]')); + var slidesClearers = Array.prototype.slice.call(root.querySelectorAll('[data-slides-clear]')); var selectedFile = null; + var selectedSlides = null; var isSubmitting = false; var dragDepth = 0; var defaultSubmitText = submitLabel ? submitLabel.textContent : ''; @@ -69,16 +77,17 @@ } function clearError() { - if (error) { + errors.forEach(function (error) { error.textContent = ''; error.classList.remove('ll-upload-errnote--show'); - } + }); if (drop) { drop.classList.remove('ll-upload-drop--error'); } } function showError(message) { + var error = root.querySelector('[data-panel]:not(.ll-upload-hidden) [data-error]') || errors[0]; if (error) { error.textContent = message; error.classList.add('ll-upload-errnote--show'); @@ -191,13 +200,22 @@ } function postForm(url, fields) { + var body = new FormData(); + Object.keys(fields).forEach(function (key) { + if (fields[key] !== '') { + body.append(key, fields[key]); + } + }); + if (selectedSlides) { + body.append('slides', selectedSlides, selectedSlides.name); + } return fetch(url, { method: 'POST', credentials: 'same-origin', headers: { 'X-CSRF-Token': csrf }, - body: new URLSearchParams(fields) + body: body }); } @@ -220,6 +238,10 @@ showError('Выберите аудио- или видеофайл.'); return; } + if (hasSlidesChecked() && !selectedSlides) { + showError('Выберите PDF или PPTX с презентацией.'); + return; + } isSubmitting = true; clearError(); @@ -292,6 +314,89 @@ input.checked = false; } }); + slidesAreas.forEach(function (area) { + area.classList.toggle('ll-upload-hidden', !checked); + }); + if (!checked) { + clearSlides(); + } + } + + function hasSlidesChecked() { + return hasPDFInputs.some(function (input) { return input.checked; }); + } + + function isSlidesFile(file) { + return file && /\.(pdf|pptx)$/i.test(file.name || '') && file.size > 0 && file.size <= 100 * 1024 * 1024; + } + + function renderSlides() { + slidesNames.forEach(function (name) { + name.textContent = selectedSlides ? selectedSlides.name + ' · ' + formatSize(selectedSlides.size) : ''; + }); + slidesFiles.forEach(function (file) { + file.classList.toggle('ll-upload-hidden', !selectedSlides); + }); + slidesPickers.forEach(function (picker) { + picker.classList.toggle('ll-upload-hidden', !!selectedSlides); + }); + } + + function takeSlides(file) { + if (!isSlidesFile(file)) { + showError('Нужен непустой PDF или PPTX размером до 100 МБ.'); + return; + } + selectedSlides = file; + hasPDFInputs.forEach(function (input) { input.checked = true; }); + syncSlidesOptions(hasPDFInputs[0]); + renderSlides(); + clearError(); + } + + function clearSlides() { + selectedSlides = null; + slidesInputs.forEach(function (input) { input.value = ''; }); + renderSlides(); + } + + function submitURL(event) { + event.preventDefault(); + if (isSubmitting) { + return; + } + if (hasSlidesChecked() && !selectedSlides) { + showError('Выберите PDF или PPTX с презентацией.'); + return; + } + var urlInput = urlForm.querySelector('[data-url-input]'); + if (!urlInput || !urlInput.checkValidity()) { + if (urlInput) { + urlInput.reportValidity(); + } + return; + } + + var extract = urlForm.querySelector('[data-extract-slides]'); + isSubmitting = true; + clearError(); + postForm('/upload/youtube', { + url: urlInput.value, + title: '', + has_pdf: hasSlidesChecked() ? 'true' : '', + extract_slides: extract && extract.checked && !hasSlidesChecked() ? 'true' : '' + }).then(function (res) { + if (!res.ok) { + return res.text().then(function (text) { + throw new Error(errorMessage(res, text, 'Не удалось создать задачу обработки.')); + }); + } + window.location.assign(res.headers.get('HX-Redirect') || '/lectures'); + }).catch(function (err) { + showError(err.message || 'Не удалось создать задачу обработки.'); + }).finally(function () { + isSubmitting = false; + }); } modeButtons.forEach(function (button) { @@ -306,6 +411,23 @@ }); }); + slidesPickers.forEach(function (picker, index) { + picker.addEventListener('click', function () { + slidesInputs[index].click(); + }); + }); + slidesInputs.forEach(function (input) { + input.addEventListener('change', function () { + takeSlides(input.files && input.files[0]); + }); + }); + slidesClearers.forEach(function (clearer) { + clearer.addEventListener('click', function () { + hasPDFInputs.forEach(function (input) { input.checked = false; }); + syncSlidesOptions(hasPDFInputs[0]); + }); + }); + if (pickFile && fileInput) { pickFile.addEventListener('click', function () { if (isSubmitting) { @@ -359,6 +481,9 @@ if (fileSubmit) { fileSubmit.addEventListener('click', submitFile); } + if (urlForm) { + urlForm.addEventListener('submit', submitURL); + } syncSlidesOptions(hasPDFInputs[0] || { checked: false }); setMode('file'); diff --git a/internal/web/web_test.go b/internal/web/web_test.go index ef7fc8e..0ac4244 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -425,8 +425,8 @@ func TestUploadPage_DropZone(t *testing.T) { func TestUploadPage_YouTubeForm(t *testing.T) { html := renderUploadPage(t) - if !strings.Contains(html, `hx-post="/upload/youtube"`) { - t.Error("ожидается htmx-форма POST /upload/youtube") + if !strings.Contains(html, `data-url-form`) || !strings.Contains(html, `enctype="multipart/form-data"`) { + t.Error("ожидается JS-форма URL с multipart/form-data") } if !strings.Contains(html, `name="url"`) { t.Error("ожидается поле url") @@ -439,6 +439,19 @@ func TestUploadPage_YouTubeForm(t *testing.T) { } } +func TestUploadPage_DocumentSlidesInputs(t *testing.T) { + html := renderUploadPage(t) + if strings.Count(html, `name="slides"`) != 2 { + t.Error("ожидается по одному input slides в file и URL режимах") + } + if !strings.Contains(html, `accept=".pdf,.pptx`) { + t.Error("slides input должен принимать PDF и PPTX") + } + if !strings.Contains(html, `data-slides-area`) || !strings.Contains(html, "До 100 МБ") { + t.Error("ожидается скрываемая область выбора презентации с лимитом") + } +} + func TestUploadPage_ExtractToggle(t *testing.T) { html := renderUploadPage(t)