diff --git a/docs/learn/concepts.mdx b/docs/learn/concepts.mdx index 455c68afc..a1a7cab03 100644 --- a/docs/learn/concepts.mdx +++ b/docs/learn/concepts.mdx @@ -6,17 +6,13 @@ import Intro from '@site/src/components/Intro'; import Steps from '@site/src/components/Steps'; import StepNumber from '@site/src/components/StepNumber'; import Step from '@site/src/components/Step'; -import ReactPlayer from 'react-player'; +import GettingStartedSlides from '@site/docs/slides/_getting-started.mdx'; Your platform ensures consistent service delivery every time. A well-designed platform seamlessly integrates with your monitoring, security, and compliance systems, building on your established foundation. Automated software delivery pipelines deploy new services quickly and easily. The reference architecture supports AWS EKS, Amazon ECS, and Lambda functions. - -
- -
AI generated voice
-
+ diff --git a/docs/resources/legacy/setup-videos/development.mdx b/docs/resources/legacy/setup-videos/development.mdx new file mode 100644 index 000000000..9e6b868e9 --- /dev/null +++ b/docs/resources/legacy/setup-videos/development.mdx @@ -0,0 +1,38 @@ +--- +title: "Development Videos" +sidebar_label: "Development" +description: "Overview videos for component development" +--- +import Intro from '@site/src/components/Intro'; +import Steps from '@site/src/components/Steps'; +import Step from '@site/src/components/Step'; +import StepNumber from '@site/src/components/StepNumber'; +import PrimaryCTA from '@site/src/components/PrimaryCTA'; +import ReactPlayer from 'react-player'; + +:::warning Legacy Content +These videos are preserved for reference but may not reflect current implementation details. +See the [Component Development](/learn/component-development) documentation for up-to-date information. +::: + + +This video covers how to develop custom Terraform components using Cloud Posse's +conventions and integrate them with the reference architecture. + + + + + ## Component Development + + Learn how to build custom Terraform components that integrate with the Cloud Posse + reference architecture. This covers component structure, using Cloud Posse modules, + integrating with Atmos stacks, and following conventions for consistent infrastructure. + +
+ +
AI generated voice
+
+ + View Current Documentation +
+
diff --git a/docs/resources/legacy/setup-videos/foundation-setup.mdx b/docs/resources/legacy/setup-videos/foundation-setup.mdx new file mode 100644 index 000000000..47471e229 --- /dev/null +++ b/docs/resources/legacy/setup-videos/foundation-setup.mdx @@ -0,0 +1,87 @@ +--- +title: "Foundation Setup Videos" +sidebar_label: "Foundation Setup" +description: "Overview videos for setting up your AWS foundation" +--- +import Intro from '@site/src/components/Intro'; +import Steps from '@site/src/components/Steps'; +import Step from '@site/src/components/Step'; +import StepNumber from '@site/src/components/StepNumber'; +import PrimaryCTA from '@site/src/components/PrimaryCTA'; +import ReactPlayer from 'react-player'; + +:::warning Legacy Content +These videos are preserved for reference but may not reflect current implementation details. +See the [Foundation Layer](/layers/foundation) for up-to-date documentation. +::: + + +These videos cover the foundational elements of the Cloud Posse Reference Architecture, +including project setup, account management, identity configuration, and network architecture. + + + + + ## Introduction to Toolchain + + Learn about the essential tools Cloud Posse uses to manage infrastructure as code. + This guide covers the Geodesic Toolbox Container for standardizing development environments, + the Atmos framework for implementing conventions and workflows, Terraform for managing + cloud infrastructure, and GitHub Actions for CI/CD automation. + +
+ +
AI generated voice
+
+ + View Current Documentation +
+ + + ## Account Management + + Review how Cloud Posse designs and manages AWS Account architectures using Atmos and Terraform, + aligning with the AWS Well-Architected Framework. This covers provisioning the Terraform state + backend, organizing accounts into Organizational Units (OUs), applying Service Control Policies + (SCPs), and configuring account-level settings. + +
+ +
AI generated voice
+
+ + View Current Documentation +
+ + + ## Identity and Authentication + + Learn how Cloud Posse sets up fine-grained access control for an entire organization using + Permission Sets, IAM roles, and AWS IAM Identity Center (SSO). This addresses the challenges + of managing access across multiple AWS accounts with a solution that ensures precise control, + easy role switching, and compatibility with different identity providers. + +
+ +
AI generated voice
+
+ + View Current Documentation +
+ + + ## Network and DNS + + Understand Cloud Posse's approach to designing robust and scalable Network and DNS architectures + on AWS, with a focus on symmetry, account-level isolation, security, and reusability. Covers + account isolation, connecting multiple accounts using Transit Gateways, deploying AWS Client VPN + for remote network access, and differentiating between DNS service discovery and branded vanity domains. + +
+ +
AI generated voice
+
+ + View Current Documentation +
+
diff --git a/docs/resources/legacy/setup-videos/platform-setup.mdx b/docs/resources/legacy/setup-videos/platform-setup.mdx new file mode 100644 index 000000000..68b964173 --- /dev/null +++ b/docs/resources/legacy/setup-videos/platform-setup.mdx @@ -0,0 +1,83 @@ +--- +title: "Platform Setup Videos" +sidebar_label: "Platform Setup" +description: "Overview videos for setting up your AWS platform" +--- +import Intro from '@site/src/components/Intro'; +import Steps from '@site/src/components/Steps'; +import Step from '@site/src/components/Step'; +import StepNumber from '@site/src/components/StepNumber'; +import PrimaryCTA from '@site/src/components/PrimaryCTA'; +import ReactPlayer from 'react-player'; + +:::warning Legacy Content +These videos are preserved for reference but may not reflect current implementation details. +See the [Platform Layer](/layers/platform) for up-to-date documentation. +::: + + +These videos cover the platform elements of the Cloud Posse Reference Architecture, +including software delivery, GitOps automation, container orchestration, and monitoring. + + + + + ## Software Delivery / Release Engineering + + Learn about Cloud Posse's approach to CI/CD and release engineering. This covers the + philosophy behind treating pipelines as software, using GitHub Actions for automation, + and implementing consistent deployment patterns across environments. + +
+ +
AI generated voice
+
+ + View Current Documentation +
+ + + ## GitOps with Terraform + + Understand how to implement GitOps for Terraform using GitHub Actions. This covers + plan/apply workflows, drift detection, and the integration with Atmos for managing + infrastructure deployments through pull requests. + +
+ +
AI generated voice
+
+ + View Current Documentation +
+ + + ## ECS Platform + + Learn about Amazon ECS (Elastic Container Service) as a container orchestration solution. + This covers the benefits of ECS over Kubernetes for simpler deployments, cluster configuration, + service deployment, and integration with the reference architecture. + +
+ +
AI generated voice
+
+ + View Current Documentation +
+ + + ## Monitoring and SRE + + Understand Cloud Posse's approach to monitoring and observability. This covers integrating + with Datadog, setting up monitors, implementing SLIs/SLOs, and building a monitoring-as-code + approach using Terraform. + +
+ +
AI generated voice
+
+ + View Current Documentation +
+
diff --git a/docs/resources/legacy/setup-videos/setup-videos.mdx b/docs/resources/legacy/setup-videos/setup-videos.mdx new file mode 100644 index 000000000..9f0d2fc8c --- /dev/null +++ b/docs/resources/legacy/setup-videos/setup-videos.mdx @@ -0,0 +1,21 @@ +--- +title: "Legacy Setup Videos" +sidebar_label: "Setup Videos" +description: "AI-generated overview videos for the Cloud Posse Reference Architecture" +--- +import Intro from '@site/src/components/Intro'; +import DocCardList from '@theme/DocCardList'; + +:::warning Legacy Content +These AI-generated overview videos were created during the initial documentation development. +They provide high-level overviews but may not reflect the latest implementation details. + +**For current documentation**, please refer to the main documentation sections for each layer. +::: + + +These videos provide AI-narrated overviews of the Cloud Posse Reference Architecture. +They are organized by the major setup phases: Foundation, Platform, and Development. + + + diff --git a/docs/slides/_getting-started.mdx b/docs/slides/_getting-started.mdx new file mode 100644 index 000000000..dcb911c76 --- /dev/null +++ b/docs/slides/_getting-started.mdx @@ -0,0 +1,627 @@ +{/* + Getting Started with the Reference Architecture - Slide Content + + This file contains the slide content for the "Getting Started" presentation. + It can be imported and embedded in other MDX files using: + + import GettingStartedSlides from '@site/docs/slides/_getting-started.mdx'; + +*/} + +import { + SlideDeck, + Slide, + SlideTitle, + SlideSubtitle, + SlideContent, + SlideList, + SlideCode, + SlideImage, + SlideSplit, + SlideNotes, +} from '@site/src/components/SlideDeck'; + + + +{/* Slide 1: Title */} + + Getting Started with the Reference Architecture + The "Big Picture" Overview + + Welcome to the introduction to the Cloud Posse toolchain. In this presentation, + we'll cover the big picture of the Cloud Posse reference architecture. + + + +{/* Slide 2: Goals */} + + Goals for Today + +
  • Training Handoff Layout and Schedule
  • +
  • Terminology
  • +
  • Conventions
  • +
  • Repository Layout
  • +
  • Toolbox
  • +
    + + At this point, you may be wondering, "when can I start to use my infrastructure?" + And "when will I learn how to develop Components?" Or "How can I get help?" + We'll answer all these questions and more in this handoff. + We are going to cover the basics of Cloud Posse's toolchain and the structure of these handoffs, + including the recommended listening order. + +
    + +{/* Slide 3: Handoff Schedule */} + + What to Expect: Training & Handoff Order + + +
  • Kick Off
  • +
  • Introduction to Toolset
  • +
  • Identity and Authentication
  • +
  • Component Development
  • +
  • Account Management
  • +
  • DNS & Network Architecture
  • +
  • ECS/EKS Clusters
  • +
    + +
  • Atmos GitOps Workflows
  • +
  • Monitoring
  • +
  • Alerting
  • +
  • Release Engineering
  • +
  • Final Call (Sign-off)
  • +
    +
    + This schedule is open for customization to best suit your needs + + This is the general idea of what to expect in handoffs. + Each of these topics starts with a brief presentation and should address the most common questions we receive from customers. + We recommend attending the weekly customer workshops for an open-ended discussion on any of these topics. + Of course, this schedule depends on the specifics of your engagement. + +
    + +{/* Slide 4: Terminology Section Title */} + + Terminology + + So let's begin by covering our terminology. + Cloud Posse has coined a few terms that we use across our architecture and with Atmos + to refer to a number of unique practices. + + + +{/* Slide 5: Stacks */} + + Stacks + + +
  • Written as YAML
  • +
  • Configures Terraform variables and Atmos settings
  • +
  • Defines Instances of Components
  • +
  • Found under stacks/
  • +
    + +{`components: + terraform: + cloudtrail: + settings: + github: + actions_enabled: false + vars: + enabled: true + name: cloudtrail + cloudtrail_bucket_environment_name: ue2 + cloudtrail_bucket_stage_name: audit + cloudwatch_logs_retention_in_days: 90 + is_organization_trail: true`} + +
    + + Stacks are a way to express the complete infrastructure needed for a deployment. + Think of a Stack like an architectural "Blueprint" composed of one or more Components + and defined using a standardized YAML configuration. + This abstraction layer helps orchestrate loosely coupled components, or Terraform root modules. + They enable scalable infrastructure-as-code configurations that can inherit and deep-merge + settings to minimize config duplication and manual effort. + Please note that Cloud Posse's definition of "stacks" is unique and differs from the + interpretations by HashiCorp, Spacelift, Terragrunt, and others. + +
    + +{/* Slide 6: Components */} + + Components + + +
  • Reusable building blocks of Infrastructure-as-Code
  • +
  • Terraform "root" modules
  • +
  • Details in a follow-up call
  • +
    + +{`components/terraform +. +├── account +├── account-map +├── account-settings +├── acm +├── argocd-repo +├── aurora-postgres + +───────────────────────────────────── + +components/terraform/datadog-monitor +├── README.md +├── component.yaml +├── context.tf +├── main.tf +├── outputs.tf +├── providers.tf +├── variables.tf +└── versions.tf`} + +
    + + A Terraform root module is a top-level module that specifies a state backend + and is typically the location where you would run Terraform commands. + Components are opinionated, self-contained, reusable blocks of Infrastructure-as-Code + designed to address specific problems or use cases. + These components are configured within one or more stacks. + We will thoroughly dive into Components with the Component Development handoff. + +
    + +{/* Slide 7: Modules */} + + Modules + + +
  • Any Terraform module that is called from another module
  • +
  • A child module does not have Terraform State
  • +
  • Called from Components
  • +
  • Cloud Posse open source modules live in our GitHub Org
  • +
    + +{`module "eks" { + source = "cloudposse/stack-config/yaml//modules/remote-state" + version = "1.3.1" + + component = var.eks_component_name + + context = module.this.context +}`} + +
    + + A module is a Terraform concept. Technically, they are called child modules, + but we'll frequently just refer to them as modules. + Modules are like packages that group multiple resources used together, + consisting of collections of Terraform files organized within a single source. + They can call other modules, and unlike terraform root modules or components, + they don't have state because they don't define provider configuration. + Cloud Posse maintains an open source collection of modules in our GitHub organization. + +
    + +{/* Slide 8: Conventions Section Title */} + + Conventions + + At Cloud Posse, we also have a few conventions that may be outside the industry norm. + When we refer to the following terms, this is what we mean. + + + +{/* Slide 9: Environment */} + + Environment + +
    + +
  • Represents the shorthand notation of the AWS Region where resources are deployed
  • +
  • Either short or fixed notation
  • +
  • us-east-1 in short is use1
  • +
  • us-east-1 in fixed is ue1
  • +
  • Fixed is always 3 letters
  • +
  • Short is 3-4 letters typically
  • +
    +
    + +{`# Short +region: us-east-1 +environment: use1 + +# Fixed +region: us-east-1 +environment: ue1`} + +
    + + The term "Environment" is frequently overloaded. At Cloud Posse, an Environment is the + location where resources are deployed. In most cases this is an AWS region. + Specifically, an environment uses the shorthand notation for a region. + For example, if the region is US east 1, then the environment is "USE1" or "UE1". + We use either a "short" or "fixed" notation, but our default recommendation is to use "short" notation. + Short is easier to understand and is therefore preferred. + +
    + +{/* Slide 10: Stage */} + + Stage + +
    + +
  • A classification for different life cycles in infrastructure management
  • +
  • In industry, usually referred to as environment
  • +
  • Examples: Dev, Staging, Prod, Auto
  • +
  • Grouped by tenant
  • +
    +
    + +{`core: # Tenant (OU) + artifacts # Stage + audit # Stage + auto # Stage + dns # Stage + identity # Stage + network # Stage + root # Stage + security # Stage +plat: # Tenant (OU) + dev # Stage + staging # Stage + prod # Stage + sandbox # Stage`} + +
    + + A stage at Cloud Posse is a classification for different life cycles in infrastructure management, + such as the typical Development, Production, Staging, or QA. + But also stages like "audit" or "security". + It is recommended to allocate at least one account per stage to maintain clear boundaries and isolation. + In our industry, the term "Environment" frequently describes what Cloud Posse refers to as "Stage." + Furthermore, we group these stages by "tenant". + +
    + +{/* Slide 11: Tenant */} + + Tenant + +
    + +
  • A tenant is a logical grouping of stages
  • +
  • Typically mirrors AWS Organizational Units (OUs)
  • +
  • We typically have 2: Core and Platform (plat)
  • +
  • Can have multiple Platform tenants with the same stages
  • +
    +
    + +{`core: # Tenant + artifacts # Stage + audit # Stage + auto # Stage + dns # Stage + identity # Stage + network # Stage + root # Stage + security # Stage +plat: # Tenant + dev # Stage + staging # Stage + prod # Stage + sandbox # Stage`} + +
    + + A tenant consists of a logical group of stages. By convention, a tenant is almost always 1 to 1 + with an AWS Organizational Unit, also referred to as an "OU". + With our reference architecture, we deploy 2 tenants: core and platform. + The core tenant includes all management accounts. These accounts are singletons and will never need to be duplicated. + The platform or "plat" tenant includes accounts related to your application platform, such as dev, staging, prod. + You may want to create multiple platform tenants, each with a new set of accounts if you want to isolate a new application platform. + +
    + +{/* Slide 12: Repository Layout Section Title */} + + Repository Layout + + Now let's go over the layout of your infrastructure repository itself. + + + +{/* Slide 13: Documentation */} + + Documentation + +
    + + https://docs.cloudposse.com/ + + +
  • Use the README to get started
  • +
  • ADRs are Architecture Design Records
  • +
  • Setup docs include how to get started with different layers of architecture
  • +
  • Component READMEs describe getting started with that individual component
  • +
    +
    + +{`infrastructure/ +├── Makefile +├── README.md +├── components/ +│ ├── docker/ +│ └── terraform/*/README.md +├── docs/ +│ ├── adr/ +│ └── setup/ +├── rootfs/ +└── stacks/`} + +
    + + We include documentation in a number of places. The latest documentation for everything we do + at Cloud Posse is at "docs.cloudposse.com". + The first place to find a high level overview is the README. + We include all Architecture Design Records or "ADRs" under "docs/adr". + We also include some setup documentation with specific values used in your organization. + Finally, we have the READMEs for every component. + +
    + +{/* Slide 14: Terraform Configuration */} + + Terraform Configuration + +
    + +
  • Terraform Configuration: components/terraform/ and stacks/
  • +
  • Components holds Terraform code
  • +
  • Stacks holds your Organization's configuration for your components
  • +
    +
    + +{`infrastructure/ +├── Makefile +├── README.md +├── components/ +│ ├── docker/ +│ └── terraform/ +├── docs/ +├── rootfs/ +└── stacks/`} + +
    + + Configuration for Terraform lives in two places. First, all Terraform code itself lives under + "components/terraform". + Each component in this directory is currently pulled from the upstream library of components + using vendoring, but you can create your own custom components as well. + The unique configuration for each component in your Organization lives under "stacks". + This approach ensures a clear separation of configuration from code, maximizing the reusability of components. + +
    + +{/* Slide 15: Stack Layout */} + + Stack Layout + + +
  • catalog defines baseline configurations for components
  • +
  • orgs defines all stacks to deploy
  • +
  • The orgs file structure mirrors your AWS account structure
  • +
  • Configurable via atmos.yaml
  • +
    + +{`infrastructure/stacks/ +├── catalog +├── orgs +│ └── acme +│ ├── _defaults.yaml +│ ├── core +│ └── plat +│ ├── _defaults.yaml +│ ├── dev +│ │ ├── _defaults.yaml +│ │ ├── global-region +│ │ │ ├── baseline.yaml +│ │ │ └── identity.yaml +│ │ └── us-east-2 +│ │ ├── baseline.yaml +│ │ ├── ecs.yaml +│ │ └── network.yaml +│ ├── prod +│ ├── sandbox +│ └── staging`} + +
    + + "Stacks" include the account architecture and the catalog of all components. + The Stack Catalog is used to define a component's default configuration for a specific organization. + Think of it as where to store the baseline configurations and establish best practices. + Then import a stack catalog into the stack manifest for a given account. + By importing the stack catalog into the stack file, you can deploy that component into the account + and add or override configuration that is unique to this account. + +
    + +{/* Slide 16: Toolset Section Title */} + + Toolset + + To get started developing, Cloud Posse has a few tools that we use across all engagements. + + + +{/* Slide 17: Atmos */} + + Atmos + +
  • https://atmos.tools/
  • +
  • Manage environments easily in Terraform
  • +
  • Simplify complex architectures with DRY configuration
  • +
  • Is and always will be FREE and Open Source
  • +
    + + We recommend getting started by reviewing the official documentation for Atmos at "atmos.tools". + However, we will cover Atmos development with the Component Development handoff. + +
    + +{/* Slide 18: Atmos Workflows */} + + Atmos Workflows + + +
  • Workflows are a way of combining multiple commands into one executable unit of work
  • +
  • Supports Atmos commands and shell scripting
  • +
  • Automates running sequences of commands
  • +
  • Can call other workflows
  • +
    + +{`atmos workflow deploy/eks -s acme-ue1-dev + +workflows: + deploy/eks: + description: | + Deploy EKS + steps: + - command: terraform deploy vpc + - command: terraform deploy eks + - command: terraform deploy \\ + eks/alb-controller`} + +
    + + Workflows combine multiple commands into a single executable unit of work, + which we rely on heavily when deploying the initial infrastructure. + They are excellent for documenting the steps or process to bring up some infrastructure. + However, once deployed, usage of workflows may be less common. + Workflows are entirely optional and have zero impact on your ability to deploy components. + +
    + +{/* Slide 19: Atmos Vendoring */} + + Atmos Vendoring + + +
  • Allows keeping Components up to date with Cloud Posse latest reference architecture
  • +
  • Vendored in with simple command to pull Terraform files
  • +
  • Included GitHub Actions can create PRs to keep components up-to-date
  • +
    + +{`atmos vendor pull -c foobar + +components/terraform/foobar +├── README.md +├── component.yaml +├── context.tf +├── main.tf +├── outputs.tf +├── providers.tf +├── remote-state.tf +├── variables.tf +└── versions.tf`} + +
    + + Vendoring is what we refer to as the process of pulling in some version of a given component + from our upstream library of components. + We reuse the same components across all engagements, thereby ensuring reusability. + These are free and open source and also shared by the community. + The "component.yaml" file defines which upstream component and version to pull. + Separately, we include a GitHub action to regularly open Pull Requests whenever there is a new version available. + +
    + +{/* Slide 20: Geodesic */} + + Geodesic + +
  • Universal DevOps toolbox
  • +
  • make all and make run
  • +
  • Docker Image with pre-installed toolset
  • +
  • Allows Customization
  • +
  • Simply an interactive shell
  • +
    + + + + + + + + + + + + + + + + + +
    HostContainer
    ~/ ($HOME dir)/localhost/
    $PROJECT_ROOT/rootfs//
    + + Geodesic is a universal toolbox distributed as a Docker image, and designed to solve the common + issue of "it works on my machine". When every developer uses Geodesic to plan and apply Terraform, + every developer has the same tools pre-installed. + Furthermore, Geodesic allows for customization. You can create unique aliases, commands, + and startup scripts either for your personal use or across your organization. + +
    + +{/* Slide 21: Running Geodesic */} + + Running Geodesic: make all + + Geodesic is simple to start and run. + + + You can build your Geodesic image locally now by cloning the infrastructure repository and running make all. + + + You can build your Geodesic image locally now by cloning the infrastructure repository + and running "make all". This will build and launch the container with all the tools you need. + + + +{/* Slide 22: References & Next Steps */} + + References & Next Steps + +
    + References + +
  • Atmos: https://atmos.tools/
  • +
  • Geodesic: github.com/cloudposse/geodesic
  • +
  • Cloud Posse Docs: docs.cloudposse.com
  • +
  • Support: /support/
  • +
    +
    +
    + Next Steps + +
  • Build Geodesic locally
  • +
  • Install Leapp
  • +
  • Read Identity and Authentication Fundamentals
  • +
    + Next Call + +
  • Identity and Authentication
  • +
    +
    +
    + + Please take the time to review our documentation at "atmos.tools" and at "docs.cloudposse.com". + You can now start getting hands on with the code. Try out "make all". Try out some Atmos commands. + Next time we'll cover "Identity and Authentication". + As always, please ask us questions in Slack or write them down for the next handoff or community workshop. + +
    + +
    diff --git a/package-lock.json b/package-lock.json index 2c9b4ccc0..8270f926b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "docusaurus-plugin-llms": "^0.2.2", "docusaurus-plugin-sass": "^0.2.5", "docusaurus-plugin-sentry": "^2.0.0", + "framer-motion": "^12.24.0", "html-loader": "^5.0.0", "iconify-icon": "^2.1.0", "js-cookie": "^3.0.5", @@ -56,6 +57,7 @@ "react-dom": "^18.3.1", "react-github-btn": "^1.4.0", "react-hubspot-form": "^1.3.7", + "react-icons": "^5.5.0", "react-image-gallery": "^1.3.0", "react-player": "^2.16.0", "react-social-media-embed": "^2.5.13", @@ -8696,6 +8698,32 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.24.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.24.0.tgz", + "integrity": "sha512-ggTMRkIDPc76lHmM+dRT1MmVfFV6t/y+jkWjWuzR7FG5xRvnAAl/5wFPjzSkLE8Nu5E5uIQRCNxmIXtWJVo6XQ==", + "dependencies": { + "motion-dom": "^12.24.0", + "motion-utils": "^12.23.28", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -13773,6 +13801,19 @@ "ufo": "^1.5.3" } }, + "node_modules/motion-dom": { + "version": "12.24.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.24.0.tgz", + "integrity": "sha512-RD2kZkFd/GH4fITI8IJvypGgn0vIu5vkrJaXIAkYqORGs5P0CKDHKNvswmoY1H+tbUAOPSh6VtUqoAmc/3Gvig==", + "dependencies": { + "motion-utils": "^12.23.28" + } + }, + "node_modules/motion-utils": { + "version": "12.23.28", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.28.tgz", + "integrity": "sha512-0W6cWd5Okoyf8jmessVK3spOmbyE0yTdNKujHctHH9XdAE4QDuZ1/LjSXC68rrhsJU+TkzXURC5OdSWh9ibOwQ==" + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -15886,6 +15927,14 @@ "stylis": "^3.5.0" } }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-image-gallery": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/react-image-gallery/-/react-image-gallery-1.3.0.tgz", diff --git a/package.json b/package.json index 0806e270c..c1b54fc0e 100644 --- a/package.json +++ b/package.json @@ -46,8 +46,10 @@ "clsx": "^2.1.1", "custom-loaders": "file:plugins/custom-loaders", "docusaurus-plugin-image-zoom": "^2.0.0", + "docusaurus-plugin-llms": "^0.2.2", "docusaurus-plugin-sass": "^0.2.5", "docusaurus-plugin-sentry": "^2.0.0", + "framer-motion": "^12.24.0", "html-loader": "^5.0.0", "iconify-icon": "^2.1.0", "js-cookie": "^3.0.5", @@ -62,6 +64,7 @@ "react-dom": "^18.3.1", "react-github-btn": "^1.4.0", "react-hubspot-form": "^1.3.7", + "react-icons": "^5.5.0", "react-image-gallery": "^1.3.0", "react-player": "^2.16.0", "react-social-media-embed": "^2.5.13", @@ -72,8 +75,7 @@ "sass": "^1.77.8", "styled-jsx": "^5.1.6", "unified": "^11.0.5", - "unist-util-visit": "^5.0.0", - "docusaurus-plugin-llms": "^0.2.2" + "unist-util-visit": "^5.0.0" }, "devDependencies": { "@docusaurus/module-type-aliases": "^3.4.0", diff --git a/src/components/SlideDeck/Slide.css b/src/components/SlideDeck/Slide.css new file mode 100644 index 000000000..bb760c1c7 --- /dev/null +++ b/src/components/SlideDeck/Slide.css @@ -0,0 +1,290 @@ +/* Base Slide */ +.slide { + width: 100% !important; + height: 100% !important; + min-height: 200px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + padding: 0.75rem 1.5rem !important; + box-sizing: border-box !important; + background: #ffffff !important; + color: #1c1e21 !important; + overflow: hidden !important; + visibility: visible !important; + opacity: 1 !important; +} + +/* Dark mode explicit styles */ +html[data-theme='dark'] .slide { + background: #1b1b1d !important; + color: #e3e3e3 !important; +} + +.slide__inner { + width: 100%; + max-width: 800px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + margin: 0 auto; +} + +/* Title Layout - Large centered title */ +.slide--title { + /* Subtle warm gradient for light mode */ + background: linear-gradient(135deg, #f8f5f2 0%, var(--ifm-background-color) 100%); +} + +html[data-theme='dark'] .slide--title { + background: linear-gradient(135deg, var(--ifm-color-primary-darkest) 0%, var(--ifm-background-color) 100%); +} + +.slide--title .slide__inner { + text-align: center; + align-items: center; +} + +/* Content Layout - Standard content */ +.slide--content .slide__inner { + text-align: left; + align-items: flex-start; +} + +/* Split Layout - Two columns */ +.slide--split { + padding: 0.75rem 1.5rem; +} + +.slide--split .slide__inner { + flex-direction: row; + gap: 1.5rem; + text-align: left; + align-items: flex-start; + max-width: 100%; +} + +.slide--split .slide__inner > *:first-child { + flex: 2; + min-width: 0; +} + +.slide--split .slide__inner > *:last-child { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + justify-content: center; +} + +/* Code Layout - Optimized for code display */ +.slide--code { + padding: 1.5rem 2rem; +} + +.slide--code .slide__inner { + max-width: 900px; + text-align: left; + align-items: flex-start; +} + +/* Quote Layout - Large centered quote */ +.slide--quote .slide__inner { + text-align: center; + align-items: center; + max-width: 700px; +} + +/* Fullscreen adjustments - slides inherit normal theme styling */ + +/* Desktop fullscreen - larger padding and allow overflow for more content */ +.slide-deck--fullscreen .slide { + padding: 2rem 4rem !important; + overflow-y: auto !important; +} + +.slide-deck--fullscreen .slide--split { + padding: 2rem 4rem !important; +} + +/* Desktop fullscreen - scale content to fill the larger slide area */ +.slide-deck--fullscreen .slide__inner { + max-width: 85%; +} + +.slide-deck--fullscreen .slide--split .slide__inner { + max-width: 90%; + gap: 3rem; + align-items: center; +} + +.slide-deck--fullscreen .slide--code .slide__inner { + max-width: 90%; +} + +/* Responsive */ +@media screen and (max-width: 996px) { + .slide { + padding: 1.5rem 2rem; + } + + .slide--split { + padding: 1.5rem 2rem; + } + + .slide--split .slide__inner { + flex-direction: column; + gap: 2rem; + } +} + +@media screen and (max-width: 768px) { + .slide { + padding: 1rem 1.5rem; + } + + .slide--code { + padding: 1rem; + } + + .slide__inner { + max-width: 100%; + } +} + +/* Small Mobile */ +@media screen and (max-width: 480px) { + .slide { + padding: 0.75rem 1rem; + } + + .slide--title { + padding: 0.75rem 1rem; + } + + .slide--code { + padding: 0.5rem; + } + + .slide--split { + padding: 0.75rem 1rem; + } +} + +/* Mobile fullscreen - scale content to fit viewport */ +@media screen and (max-width: 996px) and (max-height: 500px), + screen and (max-width: 768px) { + .slide-deck--fullscreen .slide { + padding: 0 1.5rem !important; + overflow-y: auto; + } + + .slide-deck--fullscreen .slide__inner { + max-width: 95vw; + width: 100%; + margin: auto; + padding: 0 1rem; + } + + /* Override content layout for mobile fullscreen */ + .slide-deck--fullscreen .slide--content .slide__inner { + text-align: left; + align-items: flex-start; + } + + /* Scale text for mobile fullscreen */ + .slide-deck--fullscreen .slide-title { + font-size: clamp(1.25rem, 6vw, 2rem); + margin-bottom: 0.75rem; + } + + .slide-deck--fullscreen .slide-subtitle { + font-size: clamp(1rem, 4vw, 1.5rem); + } + + .slide-deck--fullscreen .slide-content, + .slide-deck--fullscreen .slide-list { + font-size: clamp(0.9rem, 3.5vw, 1.1rem); + } + + .slide-deck--fullscreen .slide-list li { + margin-bottom: 0.4em; + } + + /* Scale images proportionally */ + .slide-deck--fullscreen .slide-image img { + max-height: 40vh; + max-width: 100%; + width: auto; + height: auto; + object-fit: contain; + } + + /* For split layouts on mobile, keep 2-column layout */ + .slide-deck--fullscreen .slide--split .slide__inner { + flex-direction: row; + gap: 1.5rem; + align-items: center; + } + + .slide-deck--fullscreen .slide--split .slide__inner > *:first-child { + flex: 2; + min-width: 0; + } + + .slide-deck--fullscreen .slide--split .slide__inner > *:last-child { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + justify-content: center; + } + + .slide-deck--fullscreen .slide--split .slide-image img, + .slide-deck--fullscreen .slide--split img { + max-height: 35vh; + max-width: 100%; + width: auto; + height: auto; + object-fit: contain; + } + + /* Split layout text sizes */ + .slide-deck--fullscreen .slide--split .slide-title { + font-size: clamp(1rem, 5vw, 1.75rem); + margin-bottom: 0.5rem; + } + + .slide-deck--fullscreen .slide--split .slide-list { + font-size: clamp(0.75rem, 3vw, 1rem); + } + + .slide-deck--fullscreen .slide--split .slide-list li { + margin-bottom: 0.3em; + } +} + +/* Mobile Portrait Fullscreen - adjust layout for narrow tall viewports */ +@media screen and (max-width: 768px) and (orientation: portrait) { + .slide-deck--fullscreen .slide { + padding: 2rem 1.5rem 5rem !important; + /* Center content vertically */ + align-items: center; + justify-content: center; + } + + .slide-deck--fullscreen .slide__inner { + max-width: 100%; + width: 100%; + margin: 0 auto; + padding: 0; + } + + /* Content slides keep left text alignment */ + .slide-deck--fullscreen .slide--content .slide__inner { + text-align: left; + align-items: flex-start; + } +} diff --git a/src/components/SlideDeck/Slide.tsx b/src/components/SlideDeck/Slide.tsx new file mode 100644 index 000000000..a4edafd39 --- /dev/null +++ b/src/components/SlideDeck/Slide.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import type { SlideProps } from './types'; +import './Slide.css'; + +export function Slide({ + children, + layout = 'content', + background, + className = '', +}: SlideProps) { + const style = background ? { background } : undefined; + + return ( +
    +
    + {children} +
    +
    + ); +} + +export default Slide; diff --git a/src/components/SlideDeck/SlideCode.tsx b/src/components/SlideDeck/SlideCode.tsx new file mode 100644 index 000000000..a8ff9ce4c --- /dev/null +++ b/src/components/SlideDeck/SlideCode.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import CodeBlock from '@theme/CodeBlock'; +import type { SlideCodeProps } from './types'; + +export function SlideCode({ + children, + language = 'yaml', + showLineNumbers = false, + className = '', +}: SlideCodeProps) { + return ( +
    + + {children} + +
    + ); +} + +export default SlideCode; diff --git a/src/components/SlideDeck/SlideContent.css b/src/components/SlideDeck/SlideContent.css new file mode 100644 index 000000000..5262dfc1b --- /dev/null +++ b/src/components/SlideDeck/SlideContent.css @@ -0,0 +1,317 @@ +/* SlideTitle - compact for embedded mode */ +.slide-title { + font-size: 1.25rem !important; + font-weight: 700 !important; + line-height: 1.2 !important; + margin: 0 0 0.5rem 0 !important; + color: var(--ifm-heading-color, #1c1e21) !important; + display: block !important; + visibility: visible !important; + opacity: 1 !important; +} + +html[data-theme='dark'] .slide-title { + color: var(--ifm-heading-color, #e3e3e3) !important; +} + +.slide--title .slide-title { + font-size: 1.75rem; + margin-bottom: 0.75rem; +} + +/* SlideSubtitle */ +.slide-subtitle { + font-size: 0.9rem; + font-weight: 500; + line-height: 1.3; + margin: 0 0 0.5rem 0; + color: var(--ifm-color-emphasis-700); +} + +html[data-theme='dark'] .slide-subtitle { + color: var(--ifm-color-emphasis-500); +} + +.slide--title .slide-subtitle { + font-size: 1rem; + color: var(--ifm-color-emphasis-600); +} + +/* SlideContent */ +.slide-content { + font-size: 0.8rem; + line-height: 1.4; + color: var(--ifm-font-color-base); +} + +.slide-content p { + margin: 0 0 0.5rem 0; +} + +.slide-content p:last-child { + margin-bottom: 0; +} + +/* SlideList */ +.slide-list { + font-size: 0.8rem; + line-height: 1.4; + margin: 0.25rem 0; + padding-left: 1.25rem; + text-align: left; +} + +.slide-list li { + margin-bottom: 0.25rem; +} + +.slide-list li:last-child { + margin-bottom: 0; +} + +.slide-list--ordered { + list-style-type: decimal; +} + +/* SlideCode */ +.slide-code { + width: 100%; + font-size: 0.65rem; + margin: 0.25rem 0; +} + +.slide-code pre { + margin: 0; + border-radius: 4px; + padding: 0.5rem !important; +} + +/* SlideImage */ +.slide-image { + display: flex; + align-items: center; + justify-content: center; + margin: 0.25rem 0; +} + +.slide-image img { + border-radius: 4px; + max-height: 150px; + object-fit: contain; +} + +/* SlideSplit */ +.slide-split { + display: flex; + gap: 1rem; + width: 100%; + align-items: flex-start; +} + +.slide-split > * { + flex: 1; + min-width: 0; +} + +.slide-split--1-2 > *:first-child { + flex: 1; +} + +.slide-split--1-2 > *:last-child { + flex: 2; +} + +.slide-split--2-1 > *:first-child { + flex: 2; +} + +.slide-split--2-1 > *:last-child { + flex: 1; +} + +/* Desktop fullscreen - scale text to fill larger slide area */ +.slide-deck--fullscreen .slide-title { + font-size: clamp(2.5rem, 4vw, 4rem); + margin: 0 0 1rem 0 !important; +} + +.slide-deck--fullscreen .slide--title .slide-title { + font-size: clamp(3.5rem, 5vw, 5.5rem); + margin-bottom: 1.5rem; +} + +.slide-deck--fullscreen .slide-subtitle { + font-size: clamp(1.5rem, 2.5vw, 2.5rem); + margin: 0 0 1rem 0; +} + +.slide-deck--fullscreen .slide--title .slide-subtitle { + font-size: clamp(1.75rem, 3vw, 3rem); +} + +.slide-deck--fullscreen .slide-content { + font-size: clamp(1.25rem, 2vw, 2rem); +} + +.slide-deck--fullscreen .slide-content p { + margin: 0 0 1rem 0; +} + +.slide-deck--fullscreen .slide-list { + font-size: clamp(1.25rem, 2vw, 2rem); + margin: 1rem 0; +} + +.slide-deck--fullscreen .slide-list li { + margin-bottom: 0.75rem; +} + +.slide-deck--fullscreen .slide-code { + font-size: clamp(0.9rem, 1.2vw, 1.3rem); + margin: 1rem 0; +} + +.slide-deck--fullscreen .slide-code pre { + padding: 1rem !important; +} + +.slide-deck--fullscreen .slide-split { + gap: 2rem; + align-items: center; +} + +.slide-deck--fullscreen .slide-image img { + max-height: none; +} + +/* Responsive */ +@media screen and (max-width: 996px) { + .slide-title { + font-size: 2rem; + } + + .slide--title .slide-title { + font-size: 2.5rem; + } + + .slide-subtitle { + font-size: 1.25rem; + } + + .slide-content, + .slide-list { + font-size: 1.1rem; + } + + .slide-split { + flex-direction: column; + } +} + +@media screen and (max-width: 768px) { + .slide-title { + font-size: 1.5rem; + } + + .slide--title .slide-title { + font-size: 2rem; + } + + .slide-subtitle { + font-size: 1.1rem; + } + + .slide-content, + .slide-list { + font-size: 1rem; + } + + .slide-code { + font-size: 0.8rem; + } +} + +/* Small Mobile */ +@media screen and (max-width: 480px) { + .slide-title { + font-size: 1.25rem; + margin-bottom: 0.5rem; + } + + .slide--title .slide-title { + font-size: 1.5rem; + } + + .slide-subtitle { + font-size: 1rem; + margin-bottom: 0.5rem; + } + + .slide-content, + .slide-list { + font-size: 0.9rem; + line-height: 1.5; + } + + .slide-list { + padding-left: 1rem; + } + + .slide-list li { + margin-bottom: 0.5rem; + } + + .slide-code { + font-size: 0.7rem; + } + + .slide-image img { + border-radius: 4px; + } +} + +/* Mobile Portrait Fullscreen - use smaller font sizes for narrow viewports */ +@media screen and (max-width: 768px) and (orientation: portrait) { + .slide-deck--fullscreen .slide-title { + font-size: clamp(1.25rem, 5vw, 1.75rem); + margin-bottom: 0.5rem; + } + + .slide-deck--fullscreen .slide--title .slide-title { + font-size: clamp(1.5rem, 6vw, 2.25rem); + } + + .slide-deck--fullscreen .slide-subtitle { + font-size: clamp(1rem, 4vw, 1.25rem); + margin-bottom: 0.5rem; + } + + .slide-deck--fullscreen .slide--title .slide-subtitle { + font-size: clamp(1rem, 4.5vw, 1.5rem); + } + + .slide-deck--fullscreen .slide-content, + .slide-deck--fullscreen .slide-list { + font-size: clamp(0.875rem, 3.5vw, 1.1rem); + line-height: 1.5; + } + + .slide-deck--fullscreen .slide-list li { + margin-bottom: 0.5rem; + } + + .slide-deck--fullscreen .slide-code { + font-size: clamp(0.7rem, 2.5vw, 0.9rem); + } + + /* Split layouts in portrait - stack vertically */ + .slide-deck--fullscreen .slide--split .slide__inner { + flex-direction: column; + gap: 1rem; + } + + .slide-deck--fullscreen .slide--split .slide-image img, + .slide-deck--fullscreen .slide--split img { + max-height: 30vh; + } +} diff --git a/src/components/SlideDeck/SlideContent.tsx b/src/components/SlideDeck/SlideContent.tsx new file mode 100644 index 000000000..42f42f3d1 --- /dev/null +++ b/src/components/SlideDeck/SlideContent.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import type { SlideContentProps } from './types'; + +export function SlideContent({ children, className = '' }: SlideContentProps) { + return ( +
    + {children} +
    + ); +} + +export default SlideContent; diff --git a/src/components/SlideDeck/SlideDeck.css b/src/components/SlideDeck/SlideDeck.css new file mode 100644 index 000000000..e49fa82d3 --- /dev/null +++ b/src/components/SlideDeck/SlideDeck.css @@ -0,0 +1,557 @@ +/* SlideDeck Container */ +.slide-deck { + position: relative; + width: 100%; + max-width: 100%; + margin: 0; + background: var(--ifm-background-color, #ffffff); + border-radius: 8px; + overflow: hidden; + /* Use 16:9 aspect ratio */ + aspect-ratio: 16 / 9; + max-height: calc(100vh - 250px); + display: flex; + flex-direction: column; + border: 1px solid var(--ifm-color-emphasis-200); +} + +/* Dark mode explicit styles */ +html[data-theme='dark'] .slide-deck { + background: var(--ifm-background-color, #1b1b1d); + border-color: var(--ifm-color-emphasis-300); +} + +/* Page mode styles - slides embedded in page content */ + +/* Fullscreen Mode - z-index must be higher than Docusaurus navbar (var(--ifm-z-index-fixed) = 100) */ +.slide-deck--fullscreen { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + max-width: none; + margin: 0; + border-radius: 0; + border: none; + z-index: 99999; + display: flex; + flex-direction: column; + background: var(--ifm-background-color); +} + +.slide-deck--fullscreen .slide-deck__main { + flex: 1; + min-height: 0; + overflow: hidden; +} + +.slide-deck--fullscreen .slide-deck__container { + padding-top: 0; + height: 100%; + background: var(--ifm-background-color); + display: flex; + align-items: center; + justify-content: center; +} + +.slide-deck--fullscreen .slide-deck__slide-wrapper { + position: relative; + top: auto; + left: auto; + right: auto; + bottom: auto; + /* Size based on viewport height to fill screen while maintaining 16:9 */ + height: calc(100vh - 60px); + width: calc((100vh - 60px) * 16 / 9); + max-width: calc(100vw - 80px); + max-height: calc((100vw - 80px) * 9 / 16); + aspect-ratio: 16 / 9; +} + +/* Left area - drawer trigger zone */ +.slide-deck__left-area { + display: flex; + align-items: stretch; + flex-shrink: 0; + cursor: pointer; + min-width: 40px; +} + +/* Controls Hidden State */ +.slide-deck--controls-hidden .slide-deck__left-area { + opacity: 0; + transform: translateX(-100%); + pointer-events: none; + transition: opacity 0.3s ease, transform 0.3s ease; +} + +.slide-deck__left-area { + transition: opacity 0.3s ease, transform 0.3s ease; +} + +.slide-deck--controls-hidden .slide-deck__side-nav--next { + opacity: 0; + transform: translateX(100%); + pointer-events: none; +} + +.slide-deck--controls-hidden .slide-deck__toolbar { + opacity: 0; + transform: translateY(100%); + pointer-events: none; +} + +.slide-deck--controls-hidden .slide-deck__progress-bar { + opacity: 0; +} + +/* Main layout with side navigation */ +.slide-deck__main { + display: flex; + align-items: stretch; + position: relative; + flex: 1; + min-height: 400px; +} + +/* Side navigation buttons */ +.slide-deck__side-nav { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + flex-shrink: 0; + border: none; + background: transparent; + color: var(--ifm-color-emphasis-500); + font-size: 1.5rem; + cursor: pointer; + transition: all 0.2s ease, opacity 0.3s ease, transform 0.3s ease; + opacity: 1; + /* Ensure nav buttons are above notes panel backdrop (z-index: 100) and panel (z-index: 101) */ + z-index: 102; + position: relative; +} + +.slide-deck__side-nav:hover:not(:disabled) { + color: var(--ifm-color-primary); + background: var(--ifm-color-emphasis-100); +} + +.slide-deck__side-nav:disabled { + color: var(--ifm-color-emphasis-300); + cursor: not-allowed; +} + +html[data-theme='dark'] .slide-deck__side-nav:disabled { + color: var(--ifm-color-emphasis-600); +} + +/* Slide Container */ +.slide-deck__container { + position: relative; + flex: 1; + min-width: 0; + min-height: 400px; + background: var(--ifm-background-color, #ffffff); + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} + +html[data-theme='dark'] .slide-deck__container { + background: var(--ifm-background-color, #1b1b1d); +} + +.slide-deck__slide-wrapper { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +/* Ensure the slide inside the wrapper fills it */ +.slide-deck__slide-wrapper > .slide { + width: 100%; + height: 100%; +} + +/* Bottom Toolbar - minimal */ +.slide-deck__toolbar { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + padding: 0.5rem 1rem; + background: #ffffff; + background: var(--ifm-background-color, #ffffff); + border-top: 1px solid rgba(0, 0, 0, 0.06); + transition: opacity 0.3s ease, transform 0.3s ease; +} + +html[data-theme='dark'] .slide-deck__toolbar { + background: #1b1b1d; + background: var(--ifm-background-color, #1b1b1d); + border-top-color: rgba(255, 255, 255, 0.06); +} + +.slide-deck__tool-button { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--ifm-color-emphasis-500); + font-size: 1.125rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.slide-deck__tool-button:hover:not(:disabled) { + color: var(--ifm-color-primary); + background: var(--ifm-color-emphasis-100); +} + +html[data-theme='dark'] .slide-deck__tool-button:hover:not(:disabled) { + background: var(--ifm-color-emphasis-200); +} + +/* Active state for toggle buttons */ +.slide-deck__tool-button--active { + color: var(--ifm-color-primary); + background: var(--ifm-color-primary-lightest); +} + +html[data-theme='dark'] .slide-deck__tool-button--active { + background: rgba(30, 91, 184, 0.2); +} + +.slide-deck__tool-button:disabled { + opacity: 0.5; + cursor: wait; +} + +/* Spinning animation for loading states */ +.slide-deck__spin { + animation: slide-deck-spin 1s linear infinite; +} + +@keyframes slide-deck-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.slide-deck__progress { + font-size: 0.8125rem; + color: var(--ifm-color-emphasis-500); + font-variant-numeric: tabular-nums; + font-weight: 500; +} + +/* Progress Bar */ +.slide-deck__progress-bar { + height: 2px; + background: var(--ifm-color-emphasis-200); + transition: opacity 0.3s ease; +} + +/* Extend progress bar to full width in page mode */ +.markdown .slide-deck__progress-bar, +.theme-doc-markdown .slide-deck__progress-bar { + margin-left: -2rem; + margin-right: -2rem; +} + +html[data-theme='dark'] .slide-deck__progress-bar { + background: var(--ifm-color-emphasis-700); +} + +.slide-deck__progress-bar-fill { + height: 100%; + background: var(--ifm-color-primary); + transition: width 0.3s ease; +} + +/* Notes Shrink Mode - Resize slide container to make room for notes panel */ +.slide-deck--notes-shrink.slide-deck--notes-right .slide-deck__main { + padding-right: 320px; + transition: padding-right 0.3s ease; +} + +.slide-deck--notes-shrink.slide-deck--notes-bottom { + padding-bottom: 25vh; + transition: padding-bottom 0.3s ease; +} + +/* Fullscreen shrink mode adjustments */ +.slide-deck--fullscreen.slide-deck--notes-shrink.slide-deck--notes-right .slide-deck__slide-wrapper { + max-width: calc((100vh - 60px) * 16 / 9 - 320px); +} + +.slide-deck--fullscreen.slide-deck--notes-shrink.slide-deck--notes-bottom .slide-deck__slide-wrapper { + height: calc(100vh - 60px - 25vh); + max-height: calc((100vw - 80px) * 9 / 16 - 25vh); +} + +/* Responsive - Tablet */ +@media screen and (max-width: 996px) { + .markdown .slide-deck, + .theme-doc-markdown .slide-deck { + padding-left: 1rem; + padding-right: 1rem; + height: calc(100vh - 100px); + max-height: calc(100vh - 100px); + } + + .markdown .slide-deck .slide-deck__container, + .theme-doc-markdown .slide-deck .slide-deck__container { + padding-left: 1rem; + padding-right: 1rem; + } + + .markdown .slide-deck .slide-deck__slide-wrapper, + .theme-doc-markdown .slide-deck .slide-deck__slide-wrapper { + max-width: calc((100vh - 140px) * 16 / 9); + } +} + +/* Responsive - Mobile (increased to 996px to catch large phones in landscape) */ +@media screen and (max-width: 996px) and (max-height: 500px), + screen and (max-width: 768px) { + .markdown .slide-deck, + .theme-doc-markdown .slide-deck { + padding-left: 0; + padding-right: 0; + height: auto; + max-height: none; + min-height: 300px; + } + + .markdown .slide-deck .slide-deck__main, + .theme-doc-markdown .slide-deck .slide-deck__main { + flex: none; + } + + .markdown .slide-deck .slide-deck__container, + .theme-doc-markdown .slide-deck .slide-deck__container { + padding-left: 0; + padding-right: 0; + height: auto; + padding-top: 0; + } + + .markdown .slide-deck .slide-deck__slide-wrapper, + .theme-doc-markdown .slide-deck .slide-deck__slide-wrapper { + width: 100%; + max-width: 100%; + } + + .slide-deck__slide-wrapper { + left: 0; + right: 0; + } + + .slide-deck__side-nav { + width: 28px; + font-size: 1.125rem; + } + + .slide-deck__toolbar { + padding: 0.375rem 0.5rem; + gap: 0.5rem; + } + + .slide-deck__tool-button { + width: 40px; + height: 40px; + font-size: 1rem; + } + + .slide-deck__progress { + font-size: 0.7rem; + } + + /* Mobile fullscreen - fill entire viewport and center content */ + .slide-deck--fullscreen { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100vw; + height: 100vh; + z-index: 99999; + background: var(--ifm-background-color); + } + + /* Collapse the left area container but keep children visible (nav buttons are fixed positioned) */ + .slide-deck--fullscreen .slide-deck__left-area { + width: 0; + min-width: 0; + overflow: visible; + } + + .slide-deck--fullscreen .slide-deck__main { + flex: 1; + display: flex; + align-items: stretch; + min-height: 0; + } + + .slide-deck--fullscreen .slide-deck__container { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + padding: 0 !important; + } + + .slide-deck--fullscreen .slide-deck__slide-wrapper { + /* Fill entire viewport on mobile - no 16:9 constraint */ + position: relative !important; + top: auto !important; + left: auto !important; + right: auto !important; + bottom: auto !important; + width: 100%; + height: 100%; + max-width: 100%; + max-height: 100%; + aspect-ratio: auto; + } + + .slide-deck--fullscreen .slide-deck__slide-wrapper .slide { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + padding: 0 2rem 4rem; + overflow-y: auto; + /* Flexbox with min-height allows centering when short, scrolling when tall */ + display: flex; + flex-direction: column; + } + + .slide-deck--fullscreen .slide-deck__slide-wrapper .slide .slide__inner { + margin: auto; + flex-shrink: 0; + } + + /* Ensure controls are visible on mobile */ + .slide-deck--fullscreen .slide-deck__toolbar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: var(--ifm-background-color); + border-top: 1px solid var(--ifm-color-emphasis-200); + z-index: 10; + } + + .slide-deck--fullscreen .slide-deck__side-nav { + position: fixed; + top: 50%; + transform: translateY(-50%); + z-index: 10; + background: rgba(255, 255, 255, 0.9); + border-radius: 50%; + width: 36px; + height: 36px; + } + + html[data-theme='dark'] .slide-deck--fullscreen .slide-deck__side-nav { + background: rgba(0, 0, 0, 0.7); + } + + .slide-deck--fullscreen .slide-deck__side-nav--prev { + left: 0.5rem; + } + + .slide-deck--fullscreen .slide-deck__side-nav--next { + right: 0.5rem; + } +} + +/* Responsive - Small Mobile */ +@media screen and (max-width: 480px) { + .slide-deck__side-nav { + width: 24px; + font-size: 1rem; + } + + .slide-deck__left-area { + min-width: 24px; + } + + .slide-deck__toolbar { + padding: 0.25rem 0.5rem; + gap: 0.25rem; + } + + .slide-deck__tool-button { + width: 36px; + height: 36px; + font-size: 0.9rem; + } + + .slide-deck__progress { + font-size: 0.65rem; + } +} + +/* Mobile Portrait - specific handling for tall narrow viewports */ +@media screen and (max-width: 768px) and (orientation: portrait) { + .slide-deck--fullscreen { + /* Account for mobile browser UI (URL bar, etc.) using dvh */ + height: 100dvh; + padding-top: env(safe-area-inset-top); + padding-bottom: env(safe-area-inset-bottom); + } + + .slide-deck--fullscreen .slide-deck__main { + /* Leave room for toolbar at bottom */ + height: calc(100dvh - 50px - env(safe-area-inset-top) - env(safe-area-inset-bottom)); + } + + .slide-deck--fullscreen .slide-deck__slide-wrapper .slide { + /* Add top padding to account for any browser UI overlap */ + padding-top: 1rem; + padding-bottom: 5rem; + } + + .slide-deck--fullscreen .slide-deck__toolbar { + padding-bottom: calc(0.5rem + env(safe-area-inset-bottom)); + } +} + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + .slide-deck__side-nav, + .slide-deck__tool-button, + .slide-deck__toolbar, + .slide-deck__progress-bar { + transition: none; + } + + .slide-deck__progress-bar-fill { + transition: none; + } + + /* Show controls always when reduced motion is preferred */ + .slide-deck--controls-hidden .slide-deck__left-area, + .slide-deck--controls-hidden .slide-deck__side-nav, + .slide-deck--controls-hidden .slide-deck__toolbar, + .slide-deck--controls-hidden .slide-deck__progress-bar { + opacity: 1; + transform: none; + pointer-events: auto; + } +} diff --git a/src/components/SlideDeck/SlideDeck.tsx b/src/components/SlideDeck/SlideDeck.tsx new file mode 100644 index 000000000..ba8073305 --- /dev/null +++ b/src/components/SlideDeck/SlideDeck.tsx @@ -0,0 +1,504 @@ +import React, { useEffect, useCallback, useState, useRef, Children, isValidElement, ReactElement } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + RiArrowLeftSLine, + RiArrowRightSLine, + RiFullscreenLine, + RiFullscreenExitLine, + RiMenuLine, + RiSpeakLine, + RiArrowGoBackLine, + RiPlayLine, + RiPauseLine, + RiLoader4Line, +} from 'react-icons/ri'; +import { SlideDeckProvider, useSlideDeck } from './SlideDeckContext'; +import { SlideDrawer } from './SlideDrawer'; +import { SlideNotesPanel } from './SlideNotesPanel'; +import { SlideNotesPopout } from './SlideNotesPopout'; +import { TTSPlayer } from './TTSPlayer'; +import { useTTS } from './useTTS'; +import { Tooltip } from './Tooltip'; +import type { SlideDeckProps } from './types'; +import './SlideDeck.css'; + +type SlideDeckInnerProps = Omit; + +function SlideDeckInner({ + children, + title, + showProgress = true, + showNavigation = true, + showFullscreen = true, + showDrawer = true, + className = '', +}: SlideDeckInnerProps) { + const { + currentSlide, + totalSlides, + nextSlide, + prevSlide, + isFullscreen, + toggleFullscreen, + showNotes, + toggleNotes, + notesPreferences, + setNotesPopout, + currentNotes, + } = useSlideDeck(); + + const { position: notesPosition, displayMode: notesDisplayMode, isPopout: notesPopout } = notesPreferences; + + // Extract deck name from URL for TTS. + const deckName = typeof window !== 'undefined' + ? window.location.pathname.split('/slides/').pop()?.split('/')[0] || 'unknown' + : 'unknown'; + + // Track auto-play mode - stays true across slide transitions until user stops. + // Use both ref (for callbacks) and state (for UI updates). + const autoPlayRef = useRef(false); + const [isAutoPlaying, setIsAutoPlaying] = useState(false); + const autoAdvanceTimerRef = useRef | null>(null); + + // Configurable delays during auto-play (in milliseconds). + const AUTO_ADVANCE_DELAY = 1000; // Delay before advancing to next slide. + const AUTO_PLAY_DELAY = 1000; // Delay before starting audio on new slide. + + // TTS hook for audio playback. + const tts = useTTS({ + deckName, + onEnded: () => { + // Auto-advance to next slide if not on last slide. + if (currentSlide < totalSlides) { + // Keep autoPlayRef true - we want to continue playing. + // Add delay after audio ends before advancing to next slide. + autoAdvanceTimerRef.current = setTimeout(() => { + nextSlide(); + }, AUTO_ADVANCE_DELAY); + } else { + // Reached last slide - disable auto-play. + autoPlayRef.current = false; + setIsAutoPlaying(false); + } + }, + }); + + // Enable auto-play when user starts playing. + // Also prefetch the next slide's audio in the background. + useEffect(() => { + if (tts.isPlaying) { + autoPlayRef.current = true; + setIsAutoPlaying(true); + + // Prefetch next slide in background while current plays. + if (currentSlide < totalSlides) { + tts.prefetchInBackground(currentSlide + 1); + } + } + }, [tts.isPlaying, currentSlide, totalSlides, tts]); + + // Disable auto-play when user explicitly stops. + const handleStop = useCallback(() => { + autoPlayRef.current = false; + setIsAutoPlaying(false); + // Clear any pending auto-advance timer. + if (autoAdvanceTimerRef.current) { + clearTimeout(autoAdvanceTimerRef.current); + autoAdvanceTimerRef.current = null; + } + tts.stop(); + }, [tts]); + + // Auto-play notes when slide changes if in auto-play mode. + // Start prefetching audio immediately while delay runs in parallel. + useEffect(() => { + if (autoPlayRef.current && currentNotes) { + let cancelled = false; + + // Start prefetch and delay in parallel. + const prefetchPromise = tts.prefetch(currentSlide); + const delayPromise = new Promise(resolve => + autoAdvanceTimerRef.current = setTimeout(resolve, AUTO_PLAY_DELAY) + ); + + // Wait for both, then play. + Promise.all([prefetchPromise, delayPromise]).then(([playPrefetched]) => { + if (!cancelled && autoPlayRef.current) { + playPrefetched(); + } + }); + + return () => { + cancelled = true; + }; + } + }, [currentSlide]); // eslint-disable-line react-hooks/exhaustive-deps + + // Cleanup auto-advance timer on unmount. + useEffect(() => { + return () => { + if (autoAdvanceTimerRef.current) { + clearTimeout(autoAdvanceTimerRef.current); + } + }; + }, []); + + // Handle TTS play/pause toggle. + const handleTTSPlayPause = useCallback(() => { + if (tts.isPlaying) { + autoPlayRef.current = false; // Disable auto-play on pause. + setIsAutoPlaying(false); + // Clear any pending auto-advance timer. + if (autoAdvanceTimerRef.current) { + clearTimeout(autoAdvanceTimerRef.current); + autoAdvanceTimerRef.current = null; + } + tts.pause(); + } else if (tts.isPaused) { + autoPlayRef.current = true; // Re-enable auto-play on resume. + setIsAutoPlaying(true); + tts.resume(); + } else if (currentNotes) { + tts.play(currentSlide); + } + }, [tts, currentNotes, currentSlide]); + + // Toggle popout mode (bring notes back from popout). + const toggleNotesPopout = useCallback(() => { + setNotesPopout(!notesPopout); + }, [notesPopout, setNotesPopout]); + + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [isHovering, setIsHovering] = useState(false); + const [showControls, setShowControls] = useState(true); + const hideTimeoutRef = useRef | null>(null); + const drawerHoverTimeoutRef = useRef | null>(null); + + const openDrawer = useCallback(() => setIsDrawerOpen(true), []); + const closeDrawer = useCallback(() => setIsDrawerOpen(false), []); + + // Show controls and reset hide timer. + const showControlsTemporarily = useCallback(() => { + setShowControls(true); + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + } + // Hide after 2 seconds of inactivity if not hovering. + hideTimeoutRef.current = setTimeout(() => { + if (!isHovering) { + setShowControls(false); + } + }, 2000); + }, [isHovering]); + + // Handle mouse enter/leave. + const handleMouseEnter = useCallback(() => { + setIsHovering(true); + setShowControls(true); + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + } + }, []); + + const handleMouseLeave = useCallback(() => { + setIsHovering(false); + // Hide controls after a short delay when mouse leaves. + hideTimeoutRef.current = setTimeout(() => { + setShowControls(false); + }, 500); + }, []); + + // Handle left edge hover for drawer. + const handleLeftEdgeEnter = useCallback(() => { + if (drawerHoverTimeoutRef.current) { + clearTimeout(drawerHoverTimeoutRef.current); + } + drawerHoverTimeoutRef.current = setTimeout(() => { + setIsDrawerOpen(true); + }, 200); + }, []); + + const handleLeftEdgeLeave = useCallback(() => { + if (drawerHoverTimeoutRef.current) { + clearTimeout(drawerHoverTimeoutRef.current); + } + }, []); + + // Keyboard navigation. + const handleKeyDown = useCallback((e: KeyboardEvent) => { + // Don't intercept keys when user is typing in form elements. + const target = e.target as HTMLElement; + const isEditable = + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable; + if (isEditable) return; + + // Show controls on any key press. + showControlsTemporarily(); + + // Close drawer or notes panel on Escape. + if (e.key === 'Escape') { + if (isDrawerOpen) { + e.preventDefault(); + closeDrawer(); + return; + } + if (showNotes) { + e.preventDefault(); + toggleNotes(); + return; + } + if (isFullscreen) { + e.preventDefault(); + toggleFullscreen(); + return; + } + } + + // Only handle arrow keys for slide navigation in fullscreen mode. + // In non-fullscreen mode, let arrow keys perform default page navigation. + if (isFullscreen && (e.key === 'ArrowRight' || e.key === ' ')) { + e.preventDefault(); + e.stopPropagation(); + nextSlide(); + return; + } else if (isFullscreen && e.key === 'ArrowLeft') { + e.preventDefault(); + e.stopPropagation(); + prevSlide(); + return; + } else if (e.key === 'f' || e.key === 'F') { + e.preventDefault(); + toggleFullscreen(); + } else if (e.key === 'g' || e.key === 'G') { + e.preventDefault(); + setIsDrawerOpen(prev => !prev); + } else if (e.key === 'n' || e.key === 'N') { + e.preventDefault(); + toggleNotes(); + } else if (e.key === 'p' || e.key === 'P') { + e.preventDefault(); + handleTTSPlayPause(); + } else if (e.key === 'm' || e.key === 'M') { + e.preventDefault(); + tts.toggleMute(); + } + }, [nextSlide, prevSlide, isFullscreen, toggleFullscreen, isDrawerOpen, closeDrawer, showControlsTemporarily, showNotes, toggleNotes, handleTTSPlayPause, tts]); + + useEffect(() => { + // Use capture phase to intercept keyboard events before other handlers. + window.addEventListener('keydown', handleKeyDown, true); + return () => window.removeEventListener('keydown', handleKeyDown, true); + }, [handleKeyDown]); + + // Cleanup timeouts on unmount. + useEffect(() => { + return () => { + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + } + if (drawerHoverTimeoutRef.current) { + clearTimeout(drawerHoverTimeoutRef.current); + } + }; + }, []); + + // Convert children to array and get current slide. + const slides = Children.toArray(children).filter(isValidElement) as ReactElement[]; + const currentSlideElement = slides[currentSlide - 1]; + + const controlsVisible = showControls || isDrawerOpen || showNotes; + + // Build class names for notes position and display mode. + const notesClasses = showNotes + ? `slide-deck--notes-${notesPosition} slide-deck--notes-${notesDisplayMode}` + : ''; + + return ( +
    + {/* Slide container with side navigation */} +
    + {/* Left navigation area - triggers drawer on hover */} +
    + {showNavigation && ( + + + + )} +
    + + {/* Slide content area */} +
    + + + {currentSlideElement} + + +
    + + {/* Right navigation button */} + {showNavigation && ( + + + + )} +
    + + {/* Bottom toolbar - minimal */} +
    + {showDrawer && ( + + + + )} + + + + + + {/* TTS Play/Pause button - always show, use isAutoPlaying for state during transitions */} + + + + + {showProgress && ( +
    + {currentSlide} / {totalSlides} +
    + )} + + {showFullscreen && ( + + + + )} +
    + + {/* TTS Player bar - shows when playing, paused, or in auto-play mode (between slides) */} + {(tts.isPlaying || tts.isPaused || isAutoPlaying) && ( + { + autoPlayRef.current = false; + setIsAutoPlaying(false); + // Clear any pending auto-advance timer. + if (autoAdvanceTimerRef.current) { + clearTimeout(autoAdvanceTimerRef.current); + autoAdvanceTimerRef.current = null; + } + tts.pause(); + }} + onResume={() => { + autoPlayRef.current = true; + setIsAutoPlaying(true); + tts.resume(); + }} + /> + )} + + {/* Progress bar */} +
    +
    +
    + + {/* Slide drawer */} + {showDrawer && ( + + {children} + + )} + + {/* Speaker notes panel - hide when popped out */} + + + {/* Speaker notes popout window manager */} + +
    + ); +} + +export function SlideDeck({ + children, + startSlide = 1, + ...props +}: SlideDeckProps) { + const slides = Children.toArray(children).filter(isValidElement); + const totalSlides = slides.length; + + return ( + + {children} + + ); +} + +export default SlideDeck; diff --git a/src/components/SlideDeck/SlideDeckContext.tsx b/src/components/SlideDeck/SlideDeckContext.tsx new file mode 100644 index 000000000..cfe715318 --- /dev/null +++ b/src/components/SlideDeck/SlideDeckContext.tsx @@ -0,0 +1,242 @@ +import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode, useMemo, useRef } from 'react'; +import type { SlideDeckContextValue, NotesPreferences, NotesPosition, NotesDisplayMode } from './types'; + +const SlideDeckContext = createContext(null); + +// localStorage key for notes preferences. +const NOTES_PREFS_KEY = 'slide-deck-notes-preferences'; + +// Default notes preferences. +const defaultNotesPreferences: NotesPreferences = { + position: 'right', + displayMode: 'overlay', + isPopout: false, +}; + +// Load preferences from localStorage. +const loadNotesPreferences = (): NotesPreferences => { + if (typeof window === 'undefined') return defaultNotesPreferences; + try { + const stored = localStorage.getItem(NOTES_PREFS_KEY); + if (stored) { + return { ...defaultNotesPreferences, ...JSON.parse(stored) }; + } + } catch (e) { + console.error('Failed to load notes preferences:', e); + } + return defaultNotesPreferences; +}; + +// Save preferences to localStorage. +const saveNotesPreferences = (prefs: NotesPreferences) => { + if (typeof window === 'undefined') return; + try { + localStorage.setItem(NOTES_PREFS_KEY, JSON.stringify(prefs)); + } catch (e) { + console.error('Failed to save notes preferences:', e); + } +}; + +interface SlideDeckProviderProps { + children: ReactNode; + totalSlides: number; + startSlide?: number; +} + +// Check if device is mobile/tablet (touch device or small screen). +const isMobileDevice = () => { + if (typeof window === 'undefined') return false; + // Check for touch capability or small screen. + const hasTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0; + const isSmallScreen = window.innerWidth <= 1024; + return hasTouch && isSmallScreen; +}; + +export function SlideDeckProvider({ + children, + totalSlides, + startSlide = 1 +}: SlideDeckProviderProps) { + const [currentSlide, setCurrentSlide] = useState(startSlide); + // Initialize fullscreen to false to avoid hydration mismatch (server always renders false). + const [isFullscreen, setIsFullscreen] = useState(false); + const [showNotes, setShowNotes] = useState(false); + const [currentNotes, setCurrentNotes] = useState(null); + const [isMobile, setIsMobile] = useState(false); + const [notesPreferences, setNotesPreferences] = useState(defaultNotesPreferences); + + // Ref to track current fullscreen state for resize handler (avoids stale closure). + const isFullscreenRef = useRef(isFullscreen); + isFullscreenRef.current = isFullscreen; + + // Load notes preferences and set mobile/fullscreen state after mount (client-side only). + useEffect(() => { + setNotesPreferences(loadNotesPreferences()); + // Auto-enter fullscreen on mobile after hydration. + if (isMobileDevice()) { + setIsMobile(true); + setIsFullscreen(true); + } + }, []); + + // Sync with URL hash on mount. + useEffect(() => { + const hash = window.location.hash; + const match = hash.match(/^#slide-(\d+)$/); + if (match) { + const slideNum = parseInt(match[1], 10); + if (slideNum >= 1 && slideNum <= totalSlides) { + setCurrentSlide(slideNum); + } + } + }, [totalSlides]); + + // Update URL hash when slide changes. + useEffect(() => { + const newHash = `#slide-${currentSlide}`; + if (window.location.hash !== newHash) { + window.history.replaceState(null, '', newHash); + } + }, [currentSlide]); + + // Handle browser back/forward. + useEffect(() => { + const handleHashChange = () => { + const hash = window.location.hash; + const match = hash.match(/^#slide-(\d+)$/); + if (match) { + const slideNum = parseInt(match[1], 10); + if (slideNum >= 1 && slideNum <= totalSlides) { + setCurrentSlide(slideNum); + } + } + }; + + window.addEventListener('hashchange', handleHashChange); + return () => window.removeEventListener('hashchange', handleHashChange); + }, [totalSlides]); + + // Handle fullscreen change events and mobile detection. + useEffect(() => { + const handleFullscreenChange = () => { + // If native fullscreen changed, sync state. + // But keep fullscreen on if we're on mobile. + if (document.fullscreenElement) { + setIsFullscreen(true); + } else if (!isMobileDevice()) { + setIsFullscreen(false); + } + }; + + const handleResize = () => { + // Auto-enter fullscreen mode on mobile, exit on desktop (unless native fullscreen). + // Use ref to get current fullscreen state (avoids stale closure). + const mobile = isMobileDevice(); + setIsMobile(mobile); + if (mobile && !isFullscreenRef.current) { + setIsFullscreen(true); + } else if (!mobile && !document.fullscreenElement && isFullscreenRef.current) { + setIsFullscreen(false); + } + }; + + document.addEventListener('fullscreenchange', handleFullscreenChange); + window.addEventListener('resize', handleResize); + return () => { + document.removeEventListener('fullscreenchange', handleFullscreenChange); + window.removeEventListener('resize', handleResize); + }; + }, []); + + const goToSlide = useCallback((index: number) => { + if (index >= 1 && index <= totalSlides) { + setCurrentSlide(index); + } + }, [totalSlides]); + + const nextSlide = useCallback(() => { + setCurrentSlide(prev => Math.min(prev + 1, totalSlides)); + }, [totalSlides]); + + const prevSlide = useCallback(() => { + setCurrentSlide(prev => Math.max(prev - 1, 1)); + }, []); + + const toggleFullscreen = useCallback(async () => { + try { + if (!document.fullscreenElement) { + const deckElement = document.querySelector('[data-slide-deck]'); + if (deckElement) { + await deckElement.requestFullscreen(); + } + } else { + await document.exitFullscreen(); + } + } catch (err) { + console.error('Fullscreen error:', err); + } + }, []); + + const toggleNotes = useCallback(() => { + setShowNotes(prev => !prev); + }, []); + + const setNotesPosition = useCallback((position: NotesPosition) => { + setNotesPreferences(prev => { + const updated = { ...prev, position }; + saveNotesPreferences(updated); + return updated; + }); + }, []); + + const setNotesDisplayMode = useCallback((displayMode: NotesDisplayMode) => { + setNotesPreferences(prev => { + const updated = { ...prev, displayMode }; + saveNotesPreferences(updated); + return updated; + }); + }, []); + + const setNotesPopout = useCallback((isPopout: boolean) => { + setNotesPreferences(prev => { + const updated = { ...prev, isPopout }; + saveNotesPreferences(updated); + return updated; + }); + }, []); + + const value: SlideDeckContextValue = useMemo(() => ({ + currentSlide, + totalSlides, + goToSlide, + nextSlide, + prevSlide, + isFullscreen, + toggleFullscreen, + showNotes, + toggleNotes, + currentNotes, + setCurrentNotes, + notesPreferences, + setNotesPosition, + setNotesDisplayMode, + setNotesPopout, + isMobile, + }), [currentSlide, totalSlides, goToSlide, nextSlide, prevSlide, isFullscreen, toggleFullscreen, showNotes, toggleNotes, currentNotes, notesPreferences, setNotesPosition, setNotesDisplayMode, setNotesPopout, isMobile]); + + return ( + + {children} + + ); +} + +export function useSlideDeck(): SlideDeckContextValue { + const context = useContext(SlideDeckContext); + if (!context) { + throw new Error('useSlideDeck must be used within a SlideDeckProvider'); + } + return context; +} + +export { SlideDeckContext }; diff --git a/src/components/SlideDeck/SlideDrawer.css b/src/components/SlideDeck/SlideDrawer.css new file mode 100644 index 000000000..d7352ccb0 --- /dev/null +++ b/src/components/SlideDeck/SlideDrawer.css @@ -0,0 +1,244 @@ +/* Slide Drawer - Slideout panel with thumbnail navigation */ + +/* Backdrop overlay */ +.slide-drawer__backdrop { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 100; +} + +/* Drawer panel */ +.slide-drawer { + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: 220px; + background: #ffffff; + background: var(--ifm-background-color, #ffffff); + border-right: 1px solid rgba(0, 0, 0, 0.1); + z-index: 101; + display: flex; + flex-direction: column; + box-shadow: 4px 0 16px rgba(0, 0, 0, 0.15); +} + +html[data-theme='dark'] .slide-drawer { + background: #1b1b1d; + background: var(--ifm-background-color, #1b1b1d); + border-right-color: rgba(255, 255, 255, 0.1); + box-shadow: 4px 0 16px rgba(0, 0, 0, 0.4); +} + +/* Fullscreen mode adjustments */ +.slide-deck--fullscreen .slide-drawer__backdrop { + position: fixed; +} + +.slide-deck--fullscreen .slide-drawer { + position: fixed; + background: var(--slide-drawer-fullscreen-bg, #1a1a2e); + border-right-color: rgba(255, 255, 255, 0.1); +} + +/* Header */ +.slide-drawer__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem 0.75rem 1.5rem; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + flex-shrink: 0; +} + +html[data-theme='dark'] .slide-drawer__header, +.slide-deck--fullscreen .slide-drawer__header { + border-bottom-color: rgba(255, 255, 255, 0.1); +} + +.slide-drawer__title { + font-size: 0.875rem; + font-weight: 600; + margin: 0; + color: var(--ifm-font-color-base); +} + +.slide-deck--fullscreen .slide-drawer__title { + color: #fff; +} + +.slide-drawer__close { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--ifm-color-emphasis-600); + font-size: 1.25rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.slide-drawer__close:hover { + background: var(--ifm-color-emphasis-200); + color: var(--ifm-color-emphasis-800); +} + +.slide-deck--fullscreen .slide-drawer__close { + color: rgba(255, 255, 255, 0.7); +} + +.slide-deck--fullscreen .slide-drawer__close:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; +} + +/* Scrollable content */ +.slide-drawer__content { + flex: 1; + overflow-y: auto; + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* Thumbnail button */ +.slide-drawer__thumbnail { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.5rem; + border: 2px solid transparent; + border-radius: 6px; + background: transparent; + cursor: pointer; + text-align: left; + transition: all 0.15s ease; + width: 100%; +} + +.slide-drawer__thumbnail:hover { + background: var(--ifm-color-emphasis-100); +} + +.slide-drawer__thumbnail--active { + border-color: var(--ifm-color-primary); + background: var(--ifm-color-primary-lightest); +} + +html[data-theme='dark'] .slide-drawer__thumbnail:hover { + background: var(--ifm-color-emphasis-200); +} + +html[data-theme='dark'] .slide-drawer__thumbnail--active { + background: rgba(30, 91, 184, 0.2); +} + +.slide-deck--fullscreen .slide-drawer__thumbnail:hover { + background: rgba(255, 255, 255, 0.05); +} + +.slide-deck--fullscreen .slide-drawer__thumbnail--active { + border-color: var(--ifm-color-primary); + background: rgba(30, 91, 184, 0.3); +} + +/* Slide number */ +.slide-drawer__thumbnail-number { + flex-shrink: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 600; + color: var(--ifm-color-emphasis-600); + background: var(--ifm-color-emphasis-200); + border-radius: 4px; +} + +.slide-drawer__thumbnail--active .slide-drawer__thumbnail-number { + background: var(--ifm-color-primary); + color: #fff; +} + +.slide-deck--fullscreen .slide-drawer__thumbnail-number { + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.7); +} + +.slide-deck--fullscreen .slide-drawer__thumbnail--active .slide-drawer__thumbnail-number { + background: var(--ifm-color-primary); + color: #fff; +} + +/* Thumbnail preview container */ +.slide-drawer__thumbnail-preview { + flex: 1; + min-width: 0; + aspect-ratio: 16 / 9; + background: var(--ifm-background-surface-color); + border: 1px solid var(--ifm-color-emphasis-200); + border-radius: 4px; + overflow: hidden; + position: relative; +} + +html[data-theme='dark'] .slide-drawer__thumbnail-preview { + background: var(--ifm-color-emphasis-100); + border-color: var(--ifm-color-emphasis-300); +} + +.slide-deck--fullscreen .slide-drawer__thumbnail-preview { + background: #1a1a2e; + border-color: rgba(255, 255, 255, 0.15); +} + +/* Thumbnail content - scaled down slide */ +.slide-drawer__thumbnail-content { + position: absolute; + top: 0; + left: 0; + width: 800%; + height: 800%; + transform: scale(0.125); + transform-origin: top left; + pointer-events: none; + overflow: hidden; +} + +/* Reduce code block size in thumbnails for better visibility and performance */ +.slide-drawer__thumbnail-content .prism-code, +.slide-drawer__thumbnail-content pre { + font-size: 0.5rem !important; +} + +/* Responsive adjustments */ +@media screen and (max-width: 768px) { + .slide-drawer { + width: 200px; + } + + .slide-drawer__thumbnail-content { + width: 700%; + height: 700%; + transform: scale(0.143); + } +} + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + .slide-drawer__thumbnail, + .slide-drawer__close { + transition: none; + } +} diff --git a/src/components/SlideDeck/SlideDrawer.tsx b/src/components/SlideDeck/SlideDrawer.tsx new file mode 100644 index 000000000..88541a95f --- /dev/null +++ b/src/components/SlideDeck/SlideDrawer.tsx @@ -0,0 +1,89 @@ +import React, { Children, isValidElement, ReactNode } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { RiCloseLine } from 'react-icons/ri'; +import { useSlideDeck } from './SlideDeckContext'; +import './SlideDrawer.css'; + +export interface SlideDrawerProps { + children: ReactNode; + isOpen: boolean; + onClose: () => void; +} + +export function SlideDrawer({ children, isOpen, onClose }: SlideDrawerProps) { + const { currentSlide, goToSlide } = useSlideDeck(); + + // Convert children to array of slides. + const slides = Children.toArray(children).filter(isValidElement); + + const handleSlideClick = (index: number) => { + goToSlide(index); + onClose(); + }; + + return ( + + {isOpen && ( + <> + {/* Backdrop */} + + + {/* Drawer panel */} + +
    +

    Slides

    + +
    + +
    + {slides.map((slide, index) => { + const slideNumber = index + 1; + const isActive = slideNumber === currentSlide; + + return ( + + ); + })} +
    +
    + + )} +
    + ); +} + +export default SlideDrawer; diff --git a/src/components/SlideDeck/SlideImage.css b/src/components/SlideDeck/SlideImage.css new file mode 100644 index 000000000..ca6891fc4 --- /dev/null +++ b/src/components/SlideDeck/SlideImage.css @@ -0,0 +1,52 @@ +/* SlideImage styles */ +.slide-image { + display: flex; + align-items: center; + justify-content: center; +} + +/* Metallic gradient effect for logos */ +.slide-image--metallic { + position: relative; +} + +.slide-image--metallic img { + /* Apply metallic gradient directly to the image */ + filter: drop-shadow(0 0 20px rgba(192, 192, 192, 0.3)); +} + +.slide-image--metallic::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 135deg, + rgba(180, 180, 220, 0.4) 0%, + rgba(255, 255, 255, 0.6) 20%, + rgba(160, 160, 200, 0.4) 40%, + rgba(255, 255, 255, 0.7) 60%, + rgba(180, 180, 220, 0.4) 80%, + rgba(255, 255, 255, 0.5) 100% + ); + background-size: 300% 300%; + mix-blend-mode: soft-light; + pointer-events: none; + animation: metallic-shimmer 6s ease-in-out infinite; + border-radius: 8px; + z-index: 1; +} + +@keyframes metallic-shimmer { + 0% { + background-position: 0% 0%; + } + 50% { + background-position: 100% 100%; + } + 100% { + background-position: 0% 0%; + } +} diff --git a/src/components/SlideDeck/SlideImage.tsx b/src/components/SlideDeck/SlideImage.tsx new file mode 100644 index 000000000..fe2a47ebc --- /dev/null +++ b/src/components/SlideDeck/SlideImage.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import type { SlideImageProps } from './types'; +import './SlideImage.css'; + +export function SlideImage({ + src, + alt, + className = '', + width, + height, + metallic = false, +}: SlideImageProps) { + const classes = ['slide-image', metallic && 'slide-image--metallic', className] + .filter(Boolean) + .join(' '); + + return ( +
    + {alt} +
    + ); +} + +export default SlideImage; diff --git a/src/components/SlideDeck/SlideIndex.css b/src/components/SlideDeck/SlideIndex.css new file mode 100644 index 000000000..1ccfd6a73 --- /dev/null +++ b/src/components/SlideDeck/SlideIndex.css @@ -0,0 +1,148 @@ +/* SlideIndex Container */ +.slide-index { + width: 100%; +} + +.slide-index--empty { + text-align: center; + padding: 3rem; + color: var(--ifm-color-emphasis-600); +} + +/* Grid Layout */ +.slide-index__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 1.5rem; +} + +/* Card */ +.slide-index__card { + display: flex; + flex-direction: column; + background: var(--ifm-card-background-color); + border: 1px solid var(--ifm-color-emphasis-200); + border-radius: 12px; + overflow: hidden; + text-decoration: none; + color: inherit; + transition: all 0.2s ease; +} + +.slide-index__card:hover { + border-color: var(--ifm-color-primary); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + transform: translateY(-2px); + text-decoration: none; +} + +html[data-theme='dark'] .slide-index__card { + border-color: var(--ifm-color-emphasis-300); +} + +html[data-theme='dark'] .slide-index__card:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +/* Thumbnail */ +.slide-index__thumbnail { + width: 100%; + aspect-ratio: 16 / 9; + background: var(--ifm-color-emphasis-100); + overflow: hidden; +} + +html[data-theme='dark'] .slide-index__thumbnail { + background: var(--ifm-color-emphasis-200); +} + +.slide-index__thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* Card Content */ +.slide-index__card-content { + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.slide-index__card-title { + font-size: 1.125rem; + font-weight: 600; + margin: 0; + color: var(--ifm-heading-color); +} + +.slide-index__card-description { + font-size: 0.875rem; + color: var(--ifm-color-emphasis-700); + margin: 0; + line-height: 1.5; +} + +html[data-theme='dark'] .slide-index__card-description { + color: var(--ifm-color-emphasis-500); +} + +/* Meta */ +.slide-index__card-meta { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 0.5rem; + flex-wrap: wrap; + gap: 0.5rem; +} + +.slide-index__slide-count { + font-size: 0.75rem; + color: var(--ifm-color-emphasis-600); + font-weight: 500; +} + +/* Tags */ +.slide-index__tags { + display: flex; + gap: 0.375rem; + flex-wrap: wrap; +} + +.slide-index__tag { + font-size: 0.6875rem; + padding: 0.125rem 0.5rem; + background: var(--ifm-color-primary-lightest); + color: var(--ifm-color-primary-darkest); + border-radius: 9999px; + font-weight: 500; +} + +html[data-theme='dark'] .slide-index__tag { + background: var(--ifm-color-primary-darkest); + color: var(--ifm-color-primary-lightest); +} + +/* Responsive */ +@media screen and (max-width: 768px) { + .slide-index__grid { + grid-template-columns: 1fr; + } + + .slide-index__card-content { + padding: 1rem; + } +} + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + .slide-index__card { + transition: none; + } + + .slide-index__card:hover { + transform: none; + } +} diff --git a/src/components/SlideDeck/SlideIndex.tsx b/src/components/SlideDeck/SlideIndex.tsx new file mode 100644 index 000000000..3879a333b --- /dev/null +++ b/src/components/SlideDeck/SlideIndex.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import Link from '@docusaurus/Link'; +import type { SlideIndexProps, SlideDeckMeta } from './types'; +import './SlideIndex.css'; + +function SlideCard({ deck }: { deck: SlideDeckMeta }) { + return ( + + {deck.thumbnail && ( +
    + {`${deck.title} +
    + )} +
    +

    {deck.title}

    +

    {deck.description}

    +
    + {deck.slideCount} slides + {deck.tags && deck.tags.length > 0 && ( +
    + {deck.tags.map((tag, index) => ( + {tag} + ))} +
    + )} +
    +
    + + ); +} + +export function SlideIndex({ decks, className = '' }: SlideIndexProps) { + if (decks.length === 0) { + return ( +
    +

    No slide decks available.

    +
    + ); + } + + return ( +
    +
    + {decks.map((deck) => ( + + ))} +
    +
    + ); +} + +export default SlideIndex; diff --git a/src/components/SlideDeck/SlideList.tsx b/src/components/SlideDeck/SlideList.tsx new file mode 100644 index 000000000..80975d398 --- /dev/null +++ b/src/components/SlideDeck/SlideList.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import type { SlideListProps } from './types'; + +export function SlideList({ children, ordered = false, className = '' }: SlideListProps) { + const Tag = ordered ? 'ol' : 'ul'; + return ( + + {children} + + ); +} + +export default SlideList; diff --git a/src/components/SlideDeck/SlideNotes.css b/src/components/SlideDeck/SlideNotes.css new file mode 100644 index 000000000..90c12a34e --- /dev/null +++ b/src/components/SlideDeck/SlideNotes.css @@ -0,0 +1,279 @@ +/* Slide Notes Panel - Slideout panel for speaker notes */ + +/* Backdrop overlay */ +.slide-notes__backdrop { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.3); + z-index: 100; +} + +/* Notes panel - base styles */ +.slide-notes { + position: absolute; + background: #ffffff; + background: var(--ifm-background-color, #ffffff); + z-index: 101; + display: flex; + flex-direction: column; +} + +html[data-theme='dark'] .slide-notes { + background: #1b1b1d; + background: var(--ifm-background-color, #1b1b1d); +} + +/* Right position (default) - slides in from right */ +.slide-notes--right { + top: 0; + right: 0; + bottom: 0; + width: 320px; + max-width: 90vw; + border-left: 1px solid rgba(0, 0, 0, 0.1); + box-shadow: -4px 0 16px rgba(0, 0, 0, 0.15); +} + +html[data-theme='dark'] .slide-notes--right { + background: #1a1a2e; + border-left-color: rgba(255, 255, 255, 0.08); + box-shadow: -4px 0 24px rgba(0, 0, 0, 0.5); +} + +/* Bottom position - slides up from bottom (Google Slides style) */ +.slide-notes--bottom { + left: 0; + right: 0; + bottom: 0; + height: 25vh; + min-height: 150px; + max-height: 50vh; + border-top: 1px solid rgba(0, 0, 0, 0.1); + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.15); +} + +html[data-theme='dark'] .slide-notes--bottom { + background: #1a1a2e; + border-top-color: rgba(255, 255, 255, 0.08); + box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.5); +} + +.slide-notes--bottom .slide-notes__header { + padding-left: 2rem; + padding-right: 1rem; +} + +.slide-notes--bottom .slide-notes__content { + min-height: 0; + padding-left: 2rem; + padding-right: 2rem; +} + +/* Fullscreen mode adjustments */ +.slide-deck--fullscreen .slide-notes__backdrop { + position: fixed; +} + +.slide-deck--fullscreen .slide-notes { + position: fixed; +} + +.slide-deck--fullscreen .slide-notes--right { + background: #1a1a2e; + border-left-color: rgba(255, 255, 255, 0.1); +} + +.slide-deck--fullscreen .slide-notes--bottom { + background: #1a1a2e; + border-top-color: rgba(255, 255, 255, 0.1); +} + +/* Header */ +.slide-notes__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + flex-shrink: 0; +} + +html[data-theme='dark'] .slide-notes__header, +.slide-deck--fullscreen .slide-notes__header { + border-bottom-color: rgba(255, 255, 255, 0.1); +} + +.slide-notes__header-left { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.slide-notes__header-controls { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.slide-notes__control-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--ifm-color-emphasis-600); + font-size: 1rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.slide-notes__control-btn:hover { + background: var(--ifm-color-emphasis-200); + color: var(--ifm-color-emphasis-800); +} + +html[data-theme='dark'] .slide-notes__control-btn, +.slide-deck--fullscreen .slide-notes__control-btn { + color: rgba(255, 255, 255, 0.7); +} + +html[data-theme='dark'] .slide-notes__control-btn:hover, +.slide-deck--fullscreen .slide-notes__control-btn:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; +} + +.slide-notes__icon { + font-size: 1.125rem; + color: var(--ifm-color-primary); +} + +.slide-deck--fullscreen .slide-notes__icon { + color: var(--ifm-color-primary-light); +} + +.slide-notes__title { + font-size: 0.875rem; + font-weight: 600; + margin: 0; + color: var(--ifm-font-color-base); +} + +html[data-theme='dark'] .slide-notes__title, +.slide-deck--fullscreen .slide-notes__title { + color: #fff; +} + +.slide-notes__close { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--ifm-color-emphasis-600); + font-size: 1.25rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.slide-notes__close:hover { + background: var(--ifm-color-emphasis-200); + color: var(--ifm-color-emphasis-800); +} + +html[data-theme='dark'] .slide-notes__close, +.slide-deck--fullscreen .slide-notes__close { + color: rgba(255, 255, 255, 0.7); +} + +html[data-theme='dark'] .slide-notes__close:hover, +.slide-deck--fullscreen .slide-notes__close:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; +} + +/* Scrollable content area */ +.slide-notes__content { + flex: 1; + min-height: 0; /* Required for flex child to scroll */ + overflow-y: auto; + padding: 1rem; +} + +/* Notes text */ +.slide-notes__text { + font-size: 0.9375rem; + line-height: 1.7; + color: var(--ifm-font-color-base); +} + +.slide-notes__text p { + margin: 0 0 1em 0; +} + +.slide-notes__text p:last-child { + margin-bottom: 0; +} + +html[data-theme='dark'] .slide-notes__text, +.slide-deck--fullscreen .slide-notes__text { + color: rgba(255, 255, 255, 0.9); +} + +/* Empty state */ +.slide-notes__empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + text-align: center; +} + +.slide-notes__empty p { + color: var(--ifm-color-emphasis-600); + font-style: italic; + margin: 0; +} + +html[data-theme='dark'] .slide-notes__empty p, +.slide-deck--fullscreen .slide-notes__empty p { + color: rgba(255, 255, 255, 0.5); +} + +/* Responsive adjustments */ +@media screen and (max-width: 768px) { + .slide-notes--right { + width: 280px; + } + + .slide-notes--bottom { + height: 30vh; + } +} + +@media screen and (max-width: 480px) { + .slide-notes--right { + width: 100%; + max-width: 100%; + } + + .slide-notes--bottom { + height: 35vh; + } +} + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + .slide-notes__close { + transition: none; + } +} diff --git a/src/components/SlideDeck/SlideNotes.tsx b/src/components/SlideDeck/SlideNotes.tsx new file mode 100644 index 000000000..ad52e3b19 --- /dev/null +++ b/src/components/SlideDeck/SlideNotes.tsx @@ -0,0 +1,41 @@ +import { useEffect } from 'react'; +import { useSlideDeck } from './SlideDeckContext'; +import type { SlideNotesProps } from './types'; + +/** + * SlideNotes - A component for adding speaker notes to slides. + * + * This component does not render any visible content. Instead, it registers + * its children (the notes content) with the SlideDeck context so that the + * notes can be displayed in the SlideNotesPanel when the user presses 'N'. + * + * Usage in MDX: + * ```mdx + * + * My Slide + * ... + * + * Speaker notes go here. Can be multiple paragraphs. + * The presenter sees these when pressing 'N'. + * + * + * ``` + */ +export function SlideNotes({ children }: SlideNotesProps) { + const { setCurrentNotes } = useSlideDeck(); + + useEffect(() => { + // Register notes with the context when this component mounts. + setCurrentNotes(children); + + // Clear notes when unmounting (slide changes). + return () => { + setCurrentNotes(null); + }; + }, [children, setCurrentNotes]); + + // This component renders nothing - notes are displayed in SlideNotesPanel. + return null; +} + +export default SlideNotes; diff --git a/src/components/SlideDeck/SlideNotesPanel.tsx b/src/components/SlideDeck/SlideNotesPanel.tsx new file mode 100644 index 000000000..4081e3149 --- /dev/null +++ b/src/components/SlideDeck/SlideNotesPanel.tsx @@ -0,0 +1,167 @@ +import React, { useCallback } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + RiCloseLine, + RiSpeakLine, + RiLayoutRightLine, + RiLayoutBottomLine, + RiStackLine, + RiSplitCellsHorizontal, + RiExternalLinkLine, +} from 'react-icons/ri'; +import { useSlideDeck } from './SlideDeckContext'; +import { Tooltip } from './Tooltip'; +import type { SlideNotesPanelProps } from './types'; +import './SlideNotes.css'; + +/** + * SlideNotesPanel - A slide-out panel displaying speaker notes. + * + * Supports two positions: + * - 'right': slides in from the right (default) + * - 'bottom': slides up from the bottom (Google Slides style) + * + * Supports two display modes: + * - 'overlay': floats on top of slides with backdrop + * - 'shrink': shrinks the slide area (no backdrop) + */ +export function SlideNotesPanel({ isOpen, onClose }: SlideNotesPanelProps) { + const { + currentNotes, + currentSlide, + notesPreferences, + setNotesPosition, + setNotesDisplayMode, + setNotesPopout, + isMobile, + } = useSlideDeck(); + const { position, displayMode } = notesPreferences; + + // Toggle notes position between right and bottom. + const toggleNotesPosition = useCallback(() => { + setNotesPosition(position === 'right' ? 'bottom' : 'right'); + }, [position, setNotesPosition]); + + // Toggle notes display mode between overlay and shrink. + const toggleNotesDisplayMode = useCallback(() => { + setNotesDisplayMode(displayMode === 'overlay' ? 'shrink' : 'overlay'); + }, [displayMode, setNotesDisplayMode]); + + // Toggle popout mode. + const toggleNotesPopout = useCallback(() => { + setNotesPopout(true); + }, [setNotesPopout]); + + // Animation variants based on position. + const panelVariants = { + right: { + initial: { x: '100%' }, + animate: { x: 0 }, + exit: { x: '100%' }, + }, + bottom: { + initial: { y: '100%' }, + animate: { y: 0 }, + exit: { y: '100%' }, + }, + }; + + const variant = panelVariants[position]; + const showBackdrop = displayMode === 'overlay'; + const panelClassName = `slide-notes slide-notes--${position}`; + + return ( + + {isOpen && ( + <> + {/* Backdrop - only shown in overlay mode */} + {showBackdrop && ( + + )} + + {/* Notes panel */} + +
    +
    + +

    Speaker Notes

    +
    +
    + {/* Position toggle */} + + + + + {/* Display mode toggle */} + + + + + {/* Popout button - desktop only */} + {!isMobile && ( + + + + )} + + {/* Close button */} + +
    +
    + +
    + {currentNotes ? ( +
    + {currentNotes} +
    + ) : ( +
    +

    No speaker notes for slide {currentSlide}.

    +
    + )} +
    +
    + + )} +
    + ); +} + +export default SlideNotesPanel; diff --git a/src/components/SlideDeck/SlideNotesPopout.tsx b/src/components/SlideDeck/SlideNotesPopout.tsx new file mode 100644 index 000000000..d1c7326f8 --- /dev/null +++ b/src/components/SlideDeck/SlideNotesPopout.tsx @@ -0,0 +1,345 @@ +import { useEffect, useRef } from 'react'; +import { useSlideDeck } from './SlideDeckContext'; + +// Channel name for cross-window communication. +const CHANNEL_NAME = 'slide-deck-notes-sync'; + +// Message types for BroadcastChannel. +interface SyncMessage { + type: 'slide-change' | 'notes-update' | 'close-popout' | 'navigate'; + slide?: number; + notes?: string; + direction?: 'next' | 'prev'; +} + +/** + * SlideNotesPopout - Manages a separate browser window for speaker notes. + * + * Uses BroadcastChannel API for cross-window communication. + * Shows current slide notes with navigation controls. + */ +export function SlideNotesPopout() { + const { + currentSlide, + totalSlides, + currentNotes, + nextSlide, + prevSlide, + notesPreferences, + setNotesPopout, + } = useSlideDeck(); + + const popoutWindowRef = useRef(null); + const channelRef = useRef(null); + + // Refs to track current values for use in popout initialization. + const currentSlideRef = useRef(currentSlide); + const totalSlidesRef = useRef(totalSlides); + const currentNotesRef = useRef(currentNotes); + + // Keep refs in sync with state. + currentSlideRef.current = currentSlide; + totalSlidesRef.current = totalSlides; + currentNotesRef.current = currentNotes; + + // Helper function to update popout content (used by both init and update effects). + const updatePopoutContent = (popout: Window) => { + const slideNumEl = popout.document.getElementById('slide-num'); + const notesContentEl = popout.document.getElementById('notes-content'); + const prevBtn = popout.document.getElementById('prev-btn') as HTMLButtonElement; + const nextBtn = popout.document.getElementById('next-btn') as HTMLButtonElement; + + const slide = currentSlideRef.current; + const total = totalSlidesRef.current; + const notes = currentNotesRef.current; + + if (slideNumEl) { + slideNumEl.textContent = `Slide ${slide} / ${total}`; + } + + if (prevBtn) { + prevBtn.disabled = slide === 1; + } + + if (nextBtn) { + nextBtn.disabled = slide === total; + } + + if (notesContentEl) { + if (notes) { + // Convert React node to string if possible. + // Use textContent for safety to prevent XSS. + const notesText = typeof notes === 'string' + ? notes + : (notes as React.ReactElement)?.props?.children || 'Notes available'; + // Clear existing content and create new div safely. + notesContentEl.textContent = ''; + const div = popout.document.createElement('div'); + div.textContent = typeof notesText === 'string' ? notesText : String(notesText); + notesContentEl.appendChild(div); + } else { + // Clear and create empty state safely. + notesContentEl.textContent = ''; + const empty = popout.document.createElement('div'); + empty.className = 'empty'; + empty.textContent = 'No notes for this slide.'; + notesContentEl.appendChild(empty); + } + } + }; + + // Initialize BroadcastChannel for cross-window sync. + useEffect(() => { + if (typeof BroadcastChannel !== 'undefined') { + channelRef.current = new BroadcastChannel(CHANNEL_NAME); + + // Listen for messages from the popout window. + channelRef.current.onmessage = (event: MessageEvent) => { + const { type, direction } = event.data; + if (type === 'navigate') { + if (direction === 'next') { + nextSlide(); + } else if (direction === 'prev') { + prevSlide(); + } + } else if (type === 'close-popout') { + setNotesPopout(false); + } + }; + } + + return () => { + channelRef.current?.close(); + }; + }, [nextSlide, prevSlide, setNotesPopout]); + + // Send slide updates to popout window. + useEffect(() => { + if (channelRef.current && notesPreferences.isPopout) { + const message: SyncMessage = { + type: 'slide-change', + slide: currentSlide, + }; + channelRef.current.postMessage(message); + } + }, [currentSlide, notesPreferences.isPopout]); + + // Open popout window when isPopout becomes true. + // Only depends on isPopout - content updates happen in a separate effect. + useEffect(() => { + if (!notesPreferences.isPopout) { + // Close the popout window if it exists. + if (popoutWindowRef.current && !popoutWindowRef.current.closed) { + popoutWindowRef.current.close(); + } + popoutWindowRef.current = null; + return; + } + + // Open the popout window. + // Note: Some browsers (like Arc) may open this as a tab instead of a popup. + // Adding popup=yes helps signal intent but behavior varies by browser. + const width = 400; + const height = 500; + const left = window.screenX + window.outerWidth - width - 50; + const top = window.screenY + 50; + + const popout = window.open( + '', + 'SlideNotesPopout', + `popup=yes,width=${width},height=${height},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no` + ); + + if (!popout) { + console.error('Failed to open popout window - popup may be blocked'); + setNotesPopout(false); + return; + } + + popoutWindowRef.current = popout; + + // Set up the popout window content. + popout.document.title = 'Speaker Notes'; + + // Write initial HTML structure (content will be updated by separate effect). + popout.document.write(` + + + + Speaker Notes + + + +
    +
    + 📝 Speaker Notes + Loading... +
    + +
    + +
    +
    Loading notes...
    +
    + + + + `); + popout.document.close(); + + // Immediately update with current slide state (avoids "Loading..." flash). + // Use setTimeout to ensure DOM is ready after document.close(). + setTimeout(() => { + if (popout && !popout.closed) { + updatePopoutContent(popout); + } + }, 0); + + // Handle popout window being closed by user. + const checkClosed = setInterval(() => { + if (popout.closed) { + clearInterval(checkClosed); + setNotesPopout(false); + } + }, 500); + + return () => { + clearInterval(checkClosed); + }; + }, [notesPreferences.isPopout, setNotesPopout]); + + // Update the popout window content when notes change. + useEffect(() => { + if (!notesPreferences.isPopout || !popoutWindowRef.current || popoutWindowRef.current.closed) { + return; + } + + updatePopoutContent(popoutWindowRef.current); + }, [currentSlide, totalSlides, currentNotes, notesPreferences.isPopout]); + + // This component doesn't render anything in the main window. + return null; +} + +export default SlideNotesPopout; diff --git a/src/components/SlideDeck/SlideSplit.tsx b/src/components/SlideDeck/SlideSplit.tsx new file mode 100644 index 000000000..1c2202d50 --- /dev/null +++ b/src/components/SlideDeck/SlideSplit.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import type { SlideSplitProps } from './types'; + +export function SlideSplit({ children, ratio = '1:1', className = '' }: SlideSplitProps) { + return ( +
    + {children} +
    + ); +} + +export default SlideSplit; diff --git a/src/components/SlideDeck/SlideSubtitle.tsx b/src/components/SlideDeck/SlideSubtitle.tsx new file mode 100644 index 000000000..be5dff8fc --- /dev/null +++ b/src/components/SlideDeck/SlideSubtitle.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import type { SlideSubtitleProps } from './types'; + +export function SlideSubtitle({ children, className = '' }: SlideSubtitleProps) { + return ( +

    + {children} +

    + ); +} + +export default SlideSubtitle; diff --git a/src/components/SlideDeck/SlideTitle.tsx b/src/components/SlideDeck/SlideTitle.tsx new file mode 100644 index 000000000..540cf6210 --- /dev/null +++ b/src/components/SlideDeck/SlideTitle.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import type { SlideTitleProps } from './types'; + +export function SlideTitle({ children, className = '' }: SlideTitleProps) { + return ( +

    + {children} +

    + ); +} + +export default SlideTitle; diff --git a/src/components/SlideDeck/TTSPlayer.css b/src/components/SlideDeck/TTSPlayer.css new file mode 100644 index 000000000..061e9d1f6 --- /dev/null +++ b/src/components/SlideDeck/TTSPlayer.css @@ -0,0 +1,201 @@ +.tts-player { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: #f5f6f7; + background: var(--ifm-background-surface-color, #f5f6f7); + border-top: 1px solid var(--ifm-color-emphasis-200); +} + +html[data-theme='dark'] .tts-player, +.slide-deck--fullscreen .tts-player { + background: rgba(26, 26, 46, 0.95); + border-top-color: rgba(255, 255, 255, 0.1); +} + +.tts-player__btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + border-radius: 50%; + background: var(--ifm-color-emphasis-200); + color: var(--ifm-font-color-base); + cursor: pointer; + transition: all 0.15s ease; + flex-shrink: 0; +} + +.tts-player__btn:hover:not(:disabled) { + background: var(--ifm-color-primary); + color: white; +} + +.tts-player__btn--play { + width: 40px; + height: 40px; + background: var(--ifm-color-primary); + color: white; +} + +.tts-player__btn--play:hover:not(:disabled) { + background: var(--ifm-color-primary-dark); +} + +.tts-player__btn:disabled { + opacity: 0.5; + cursor: wait; +} + +.tts-player__btn--muted { + color: var(--ifm-color-danger); +} + +.tts-player__btn--muted:hover:not(:disabled) { + background: var(--ifm-color-danger); + color: white; +} + +html[data-theme='dark'] .tts-player__btn, +.slide-deck--fullscreen .tts-player__btn { + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.9); +} + +html[data-theme='dark'] .tts-player__btn:hover:not(:disabled), +.slide-deck--fullscreen .tts-player__btn:hover:not(:disabled) { + background: var(--ifm-color-primary); + color: white; +} + +html[data-theme='dark'] .tts-player__btn--play, +.slide-deck--fullscreen .tts-player__btn--play { + background: var(--ifm-color-primary); + color: white; +} + +.tts-player__spin { + animation: tts-spin 1s linear infinite; +} + +@keyframes tts-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.tts-player__progress { + flex: 1; + height: 6px; + min-width: 80px; + background: var(--ifm-color-emphasis-200); + border-radius: 3px; + cursor: pointer; + overflow: hidden; + position: relative; +} + +html[data-theme='dark'] .tts-player__progress, +.slide-deck--fullscreen .tts-player__progress { + background: rgba(255, 255, 255, 0.15); +} + +.tts-player__progress:hover { + height: 8px; +} + +.tts-player__progress-fill { + height: 100%; + background: var(--ifm-color-primary); + transition: width 0.1s linear; + border-radius: 3px; +} + +.tts-player__time { + font-size: 0.75rem; + font-variant-numeric: tabular-nums; + color: var(--ifm-color-emphasis-600); + min-width: 70px; + text-align: center; + flex-shrink: 0; +} + +html[data-theme='dark'] .tts-player__time, +.slide-deck--fullscreen .tts-player__time { + color: rgba(255, 255, 255, 0.6); +} + +.tts-player__select { + padding: 0.25rem 0.5rem; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 4px; + background: var(--ifm-background-color); + color: var(--ifm-font-color-base); + font-size: 0.75rem; + cursor: pointer; + flex-shrink: 0; +} + +.tts-player__select:focus { + outline: 2px solid var(--ifm-color-primary); + outline-offset: 1px; +} + +html[data-theme='dark'] .tts-player__select, +.slide-deck--fullscreen .tts-player__select { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.2); + color: white; +} + +.tts-player__error { + color: var(--ifm-color-danger); + font-size: 0.75rem; + flex-shrink: 0; +} + +/* Mobile adjustments */ +@media (max-width: 768px) { + .tts-player { + flex-wrap: wrap; + gap: 0.375rem; + padding: 0.375rem 0.5rem; + } + + .tts-player__btn--play { + width: 36px; + height: 36px; + } + + .tts-player__btn { + width: 28px; + height: 28px; + } + + .tts-player__progress { + order: 5; + flex-basis: 100%; + margin: 0.25rem 0; + } + + .tts-player__time { + order: 6; + flex-basis: 100%; + text-align: center; + min-width: auto; + } + + .tts-player__select { + font-size: 0.7rem; + padding: 0.2rem 0.4rem; + } +} + +/* Very small screens */ +@media (max-width: 480px) { + .tts-player__select { + max-width: 60px; + } +} diff --git a/src/components/SlideDeck/TTSPlayer.tsx b/src/components/SlideDeck/TTSPlayer.tsx new file mode 100644 index 000000000..d4858b4b9 --- /dev/null +++ b/src/components/SlideDeck/TTSPlayer.tsx @@ -0,0 +1,221 @@ +import React from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + RiPlayLine, + RiPauseLine, + RiStopLine, + RiLoader4Line, + RiVolumeMuteLine, + RiVolumeUpLine, +} from 'react-icons/ri'; +import type { TTSVoice, UseTTSReturn } from './useTTS'; +import { Tooltip } from './Tooltip'; +import './TTSPlayer.css'; + +interface TTSPlayerProps { + tts: UseTTSReturn; + currentSlide: number; + onStop?: () => void; + onPause?: () => void; + onResume?: () => void; +} + +const VOICES: { value: TTSVoice; label: string }[] = [ + { value: 'alloy', label: 'Alloy' }, + { value: 'echo', label: 'Echo' }, + { value: 'fable', label: 'Fable' }, + { value: 'nova', label: 'Nova' }, + { value: 'onyx', label: 'Onyx' }, + { value: 'shimmer', label: 'Shimmer' }, +]; + +const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2]; + +/** + * TTSPlayer - A full-featured audio player bar for TTS playback. + * + * Features: + * - Play/Pause/Stop controls + * - Mute toggle + * - Progress bar with seek + * - Speed selector + * - Voice selector + */ +export function TTSPlayer({ tts, currentSlide, onStop, onPause, onResume }: TTSPlayerProps) { + const { + isPlaying, + isLoading, + isPaused, + isMuted, + error, + progress, + duration, + currentTime, + voice, + playbackRate, + play, + pause, + resume, + stop, + seek, + toggleMute, + setVoice, + setPlaybackRate, + } = tts; + + const handlePlayPause = () => { + if (isPlaying) { + onPause ? onPause() : pause(); + } else if (isPaused) { + onResume ? onResume() : resume(); + } else { + play(currentSlide); + } + }; + + const handleStop = () => { + onStop ? onStop() : stop(); + }; + + const handleProgressClick = (e: React.MouseEvent) => { + if (duration <= 0) return; + const rect = e.currentTarget.getBoundingClientRect(); + const pct = (e.clientX - rect.left) / rect.width; + seek(pct * duration); + }; + + const handleProgressKeyDown = (e: React.KeyboardEvent) => { + if (duration <= 0) return; + const step = 5; // Seek 5 seconds per key press. + if (e.key === 'ArrowRight') { + e.preventDefault(); + seek(Math.min(currentTime + step, duration)); + } else if (e.key === 'ArrowLeft') { + e.preventDefault(); + seek(Math.max(currentTime - step, 0)); + } else if (e.key === 'Home') { + e.preventDefault(); + seek(0); + } else if (e.key === 'End') { + e.preventDefault(); + seek(duration); + } + }; + + const formatTime = (s: number) => { + if (!isFinite(s) || s < 0) return '0:00'; + const mins = Math.floor(s / 60); + const secs = Math.floor(s % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + return ( + + + {/* Play/Pause Button */} + + + + + {/* Stop Button */} + {(isPlaying || isPaused) && ( + + + + )} + + {/* Mute Button */} + + + + + {/* Progress Bar */} +
    +
    +
    + + {/* Time Display */} + + {formatTime(currentTime)} / {formatTime(duration)} + + + {/* Speed Selector */} + + + + + {/* Voice Selector */} + + + + + {/* Error Display */} + {error && {error}} + + + ); +} + +export default TTSPlayer; diff --git a/src/components/SlideDeck/Tooltip.css b/src/components/SlideDeck/Tooltip.css new file mode 100644 index 000000000..214d82953 --- /dev/null +++ b/src/components/SlideDeck/Tooltip.css @@ -0,0 +1,125 @@ +/* Tooltip Styles */ +.tooltip-wrapper { + position: relative; + display: inline-flex; + align-self: center; +} + +.tooltip { + position: absolute; + z-index: 1000; + padding: 0.375rem 0.625rem; + font-size: 0.75rem; + font-weight: 500; + color: #fff; + background: rgba(0, 0, 0, 0.85); + border-radius: 4px; + white-space: nowrap; + pointer-events: none; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +html[data-theme='dark'] .tooltip { + background: rgba(255, 255, 255, 0.9); + color: #1a1a2e; +} + +/* Fullscreen mode tooltip */ +.slide-deck--fullscreen .tooltip { + background: rgba(255, 255, 255, 0.95); + color: #1a1a2e; +} + +/* Position variants */ +.tooltip--top { + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); +} + +.tooltip--bottom { + top: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); +} + +.tooltip--left { + right: calc(100% + 8px); + top: 50%; + transform: translateY(-50%); +} + +.tooltip--right { + left: calc(100% + 8px); + top: 50%; + transform: translateY(-50%); +} + +/* Arrow indicator */ +.tooltip::after { + content: ''; + position: absolute; + border: 5px solid transparent; +} + +.tooltip--top::after { + top: 100%; + left: 50%; + transform: translateX(-50%); + border-top-color: rgba(0, 0, 0, 0.85); +} + +.tooltip--bottom::after { + bottom: 100%; + left: 50%; + transform: translateX(-50%); + border-bottom-color: rgba(0, 0, 0, 0.85); +} + +.tooltip--left::after { + left: 100%; + top: 50%; + transform: translateY(-50%); + border-left-color: rgba(0, 0, 0, 0.85); +} + +.tooltip--right::after { + right: 100%; + top: 50%; + transform: translateY(-50%); + border-right-color: rgba(0, 0, 0, 0.85); +} + +/* Dark mode arrows */ +html[data-theme='dark'] .tooltip--top::after { + border-top-color: rgba(255, 255, 255, 0.9); +} + +html[data-theme='dark'] .tooltip--bottom::after { + border-bottom-color: rgba(255, 255, 255, 0.9); +} + +html[data-theme='dark'] .tooltip--left::after { + border-left-color: rgba(255, 255, 255, 0.9); +} + +html[data-theme='dark'] .tooltip--right::after { + border-right-color: rgba(255, 255, 255, 0.9); +} + +/* Fullscreen mode arrows */ +.slide-deck--fullscreen .tooltip--top::after { + border-top-color: rgba(255, 255, 255, 0.95); +} + +.slide-deck--fullscreen .tooltip--bottom::after { + border-bottom-color: rgba(255, 255, 255, 0.95); +} + +.slide-deck--fullscreen .tooltip--left::after { + border-left-color: rgba(255, 255, 255, 0.95); +} + +.slide-deck--fullscreen .tooltip--right::after { + border-right-color: rgba(255, 255, 255, 0.95); +} diff --git a/src/components/SlideDeck/Tooltip.tsx b/src/components/SlideDeck/Tooltip.tsx new file mode 100644 index 000000000..4db1d0ad3 --- /dev/null +++ b/src/components/SlideDeck/Tooltip.tsx @@ -0,0 +1,72 @@ +import React, { useState, useRef, useEffect, ReactNode } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import './Tooltip.css'; + +interface TooltipProps { + children: ReactNode; + content: string; + position?: 'top' | 'bottom' | 'left' | 'right'; + delay?: number; +} + +export function Tooltip({ + children, + content, + position = 'top', + delay = 0, +}: TooltipProps) { + const [isVisible, setIsVisible] = useState(false); + const timeoutRef = useRef(null); + + const showTooltip = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + setIsVisible(true); + }, delay); + }; + + const hideTooltip = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + setIsVisible(false); + }; + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + return ( +
    + {children} + + {isVisible && ( + + {content} + + )} + +
    + ); +} + +export default Tooltip; diff --git a/src/components/SlideDeck/index.tsx b/src/components/SlideDeck/index.tsx new file mode 100644 index 000000000..5436cbdba --- /dev/null +++ b/src/components/SlideDeck/index.tsx @@ -0,0 +1,57 @@ +// Main exports for SlideDeck component library. +import './SlideDeck.css'; +import './Slide.css'; +import './SlideIndex.css'; +import './SlideContent.css'; +import './SlideDrawer.css'; +import './SlideImage.css'; +import './SlideNotes.css'; +import './Tooltip.css'; +import './TTSPlayer.css'; + +export { SlideDeck } from './SlideDeck'; +export { Slide } from './Slide'; +export { SlideTitle } from './SlideTitle'; +export { SlideSubtitle } from './SlideSubtitle'; +export { SlideContent } from './SlideContent'; +export { SlideList } from './SlideList'; +export { SlideCode } from './SlideCode'; +export { SlideImage } from './SlideImage'; +export { SlideSplit } from './SlideSplit'; +export { SlideIndex } from './SlideIndex'; +export { SlideDrawer } from './SlideDrawer'; +export { SlideNotes } from './SlideNotes'; +export { SlideNotesPanel } from './SlideNotesPanel'; +export { SlideNotesPopout } from './SlideNotesPopout'; +export { TTSPlayer } from './TTSPlayer'; +export { Tooltip } from './Tooltip'; + +// Context exports for advanced usage. +export { SlideDeckProvider, useSlideDeck } from './SlideDeckContext'; + +// Hook exports. +export { useTTS } from './useTTS'; + +// Type exports. +export type { + SlideDeckProps, + SlideProps, + SlideTitleProps, + SlideSubtitleProps, + SlideContentProps, + SlideListProps, + SlideCodeProps, + SlideImageProps, + SlideSplitProps, + SlideLayout, + SlideDeckContextValue, + SlideDeckMeta, + SlideIndexProps, + SlideNotesProps, + SlideNotesPanelProps, + NotesPosition, + NotesDisplayMode, + NotesPreferences, +} from './types'; + +export type { TTSVoice, UseTTSReturn } from './useTTS'; diff --git a/src/components/SlideDeck/types.ts b/src/components/SlideDeck/types.ts new file mode 100644 index 000000000..76d100c55 --- /dev/null +++ b/src/components/SlideDeck/types.ts @@ -0,0 +1,135 @@ +import React, { ReactNode } from 'react'; + +// Slide layout variants. +export type SlideLayout = 'title' | 'content' | 'split' | 'code' | 'quote'; + +// Notes panel position. +export type NotesPosition = 'right' | 'bottom'; + +// Notes display mode. +export type NotesDisplayMode = 'overlay' | 'shrink'; + +// Notes preferences stored in localStorage. +export interface NotesPreferences { + position: NotesPosition; + displayMode: NotesDisplayMode; + isPopout: boolean; +} + +// Props for the SlideDeck container component. +export interface SlideDeckProps { + children: ReactNode; + title?: string; + showProgress?: boolean; + showNavigation?: boolean; + showFullscreen?: boolean; + showDrawer?: boolean; + startSlide?: number; + className?: string; +} + +// Props for individual Slide components. +export interface SlideProps { + children: ReactNode; + layout?: SlideLayout; + background?: string; + className?: string; +} + +// Props for SlideTitle component. +export interface SlideTitleProps { + children: ReactNode; + className?: string; +} + +// Props for SlideSubtitle component. +export interface SlideSubtitleProps { + children: ReactNode; + className?: string; +} + +// Props for SlideContent component. +export interface SlideContentProps { + children: ReactNode; + className?: string; +} + +// Props for SlideList component. +export interface SlideListProps { + children: ReactNode; + ordered?: boolean; + className?: string; +} + +// Props for SlideCode component. +export interface SlideCodeProps { + children: string; + language?: string; + showLineNumbers?: boolean; + className?: string; +} + +// Props for SlideImage component. +export interface SlideImageProps { + src: string; + alt: string; + className?: string; + width?: number | string; + height?: number | string; + metallic?: boolean; +} + +// Props for SlideSplit component. +export interface SlideSplitProps { + children: ReactNode; + ratio?: '1:1' | '1:2' | '2:1'; + className?: string; +} + +// Context for slide navigation state. +export interface SlideDeckContextValue { + currentSlide: number; + totalSlides: number; + goToSlide: (index: number) => void; + nextSlide: () => void; + prevSlide: () => void; + isFullscreen: boolean; + toggleFullscreen: () => void; + showNotes: boolean; + toggleNotes: () => void; + currentNotes: React.ReactNode | null; + setCurrentNotes: (notes: React.ReactNode | null) => void; + // Notes preferences. + notesPreferences: NotesPreferences; + setNotesPosition: (position: NotesPosition) => void; + setNotesDisplayMode: (mode: NotesDisplayMode) => void; + setNotesPopout: (isPopout: boolean) => void; + isMobile: boolean; +} + +// Metadata for slide deck index page. +export interface SlideDeckMeta { + title: string; + description: string; + thumbnail?: string; + slideCount: number; + tags?: string[]; + slug: string; +} + +// Props for SlideIndex component. +export interface SlideIndexProps { + decks: SlideDeckMeta[]; + className?: string; +} + +// Props for SlideNotes component. +export interface SlideNotesProps { + children: ReactNode; +} + +// Props for SlideNotesPanel component. +export interface SlideNotesPanelProps { + isOpen: boolean; + onClose: () => void; +} diff --git a/src/components/SlideDeck/useTTS.ts b/src/components/SlideDeck/useTTS.ts new file mode 100644 index 000000000..49680d88e --- /dev/null +++ b/src/components/SlideDeck/useTTS.ts @@ -0,0 +1,408 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; + +export type TTSVoice = 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer'; + +interface UseTTSOptions { + deckName: string; + onEnded?: () => void; // Callback when audio finishes. +} + +export interface UseTTSReturn { + // State. + isPlaying: boolean; + isLoading: boolean; + isPaused: boolean; + isMuted: boolean; + error: string | null; + progress: number; // 0-100. + duration: number; // seconds. + currentTime: number; // seconds. + voice: TTSVoice; + playbackRate: number; + + // Actions. + play: (slideNumber: number) => Promise; + prefetch: (slideNumber: number) => Promise<() => Promise>; // Returns playPrefetched function. + prefetchInBackground: (slideNumber: number) => void; // Prefetch next slide while current plays. + pause: () => void; + resume: () => void; + stop: () => void; + seek: (time: number) => void; + toggleMute: () => void; + setVoice: (voice: TTSVoice) => void; + setPlaybackRate: (rate: number) => void; +} + +const TTS_PREFS_KEY = 'slide-deck-tts-preferences'; + +interface TTSPrefs { + voice: TTSVoice; + rate: number; + muted: boolean; +} + +const defaultPrefs: TTSPrefs = { voice: 'nova', rate: 1, muted: false }; + +/** + * Custom hook for Text-to-Speech playback of slide notes. + * + * Uses the Cloud Posse TTS API to convert slide notes to speech. + * Supports voice selection, speed control, muting, and progress tracking. + * + * IMPORTANT: This hook reuses a single Audio element to maintain user-activation + * state on iOS. Creating new Audio elements breaks autoplay on mobile Safari. + */ +export function useTTS({ deckName, onEnded }: UseTTSOptions): UseTTSReturn { + // Load saved preferences. + const loadPrefs = (): TTSPrefs => { + if (typeof window === 'undefined') return defaultPrefs; + try { + const stored = localStorage.getItem(TTS_PREFS_KEY); + return stored ? { ...defaultPrefs, ...JSON.parse(stored) } : defaultPrefs; + } catch { + return defaultPrefs; + } + }; + + const [voice, setVoiceState] = useState(defaultPrefs.voice); + const [playbackRate, setPlaybackRateState] = useState(defaultPrefs.rate); + const [isMuted, setIsMuted] = useState(defaultPrefs.muted); + const [isPlaying, setIsPlaying] = useState(false); + const [isPaused, setIsPaused] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [progress, setProgress] = useState(0); + const [duration, setDuration] = useState(0); + const [currentTime, setCurrentTime] = useState(0); + + // Persistent audio element - reused across plays to maintain iOS user-activation. + const audioRef = useRef(null); + const onEndedRef = useRef(onEnded); + onEndedRef.current = onEnded; + + // Cache for prefetched audio data URLs, keyed by slide number and voice. + const prefetchCacheRef = useRef>(new Map()); + + // Get or create the persistent audio element. + const getAudioElement = useCallback(() => { + if (!audioRef.current && typeof window !== 'undefined') { + const audio = new Audio(); + audio.onloadedmetadata = () => setDuration(audio.duration); + audio.ontimeupdate = () => { + setCurrentTime(audio.currentTime); + if (audio.duration > 0) { + setProgress((audio.currentTime / audio.duration) * 100); + } + }; + audio.onended = () => { + setIsPlaying(false); + setIsPaused(false); + setProgress(100); + onEndedRef.current?.(); + }; + audio.onerror = () => { + setError('Playback failed'); + setIsPlaying(false); + setIsLoading(false); + }; + audioRef.current = audio; + } + return audioRef.current; + }, []); + + // Load prefs on mount. + useEffect(() => { + const prefs = loadPrefs(); + setVoiceState(prefs.voice); + setPlaybackRateState(prefs.rate); + setIsMuted(prefs.muted); + }, []); + + // Save preferences. + const savePrefs = useCallback((v: TTSVoice, r: number, m: boolean) => { + if (typeof window === 'undefined') return; + localStorage.setItem(TTS_PREFS_KEY, JSON.stringify({ voice: v, rate: r, muted: m })); + }, []); + + const setVoice = useCallback((v: TTSVoice) => { + setVoiceState(v); + savePrefs(v, playbackRate, isMuted); + }, [playbackRate, isMuted, savePrefs]); + + const setPlaybackRate = useCallback((r: number) => { + setPlaybackRateState(r); + const audio = audioRef.current; + if (audio) audio.playbackRate = r; + savePrefs(voice, r, isMuted); + }, [voice, isMuted, savePrefs]); + + const toggleMute = useCallback(() => { + const newMuted = !isMuted; + setIsMuted(newMuted); + const audio = audioRef.current; + if (audio) audio.muted = newMuted; + savePrefs(voice, playbackRate, newMuted); + }, [isMuted, voice, playbackRate, savePrefs]); + + const getTextUrl = useCallback((slideNumber: number) => { + const origin = typeof window !== 'undefined' ? window.location.origin : ''; + return `${origin}/slides/${deckName}/slide${slideNumber}.txt`; + }, [deckName]); + + // Generate cache key for a slide/voice combination. + const getCacheKey = useCallback((slideNumber: number, v: TTSVoice) => { + return `${slideNumber}-${v}`; + }, []); + + // Fetch audio data from API (internal helper). + const fetchAudioData = useCallback(async (slideNumber: number): Promise => { + const textUrl = getTextUrl(slideNumber); + const apiUrl = `https://cloudposse.com/api/tts?url=${encodeURIComponent(textUrl)}&voice=${voice}`; + + const response = await fetch(apiUrl); + if (!response.ok) { + let errorMsg = 'TTS failed'; + try { + const err = await response.json(); + errorMsg = err.error || errorMsg; + } catch { + // Ignore JSON parse errors. + } + throw new Error(errorMsg); + } + + const data = await response.json(); + return `data:${data.mimeType};base64,${data.audio}`; + }, [getTextUrl, voice]); + + const play = useCallback(async (slideNumber: number) => { + const audio = getAudioElement(); + if (!audio) return; + + // Stop current playback. + audio.pause(); + audio.currentTime = 0; + + setIsLoading(true); + setError(null); + setProgress(0); + setCurrentTime(0); + setDuration(0); + + try { + // Check cache first. + const cacheKey = getCacheKey(slideNumber, voice); + let audioDataUrl = prefetchCacheRef.current.get(cacheKey); + + if (!audioDataUrl) { + // Not cached, fetch from API. + audioDataUrl = await fetchAudioData(slideNumber); + } else { + // Remove from cache after use. + prefetchCacheRef.current.delete(cacheKey); + } + + // Update the existing audio element's source instead of creating a new one. + // This preserves the user-activation state on iOS. + audio.src = audioDataUrl; + audio.playbackRate = playbackRate; + audio.muted = isMuted; + + // Wait for audio to be ready. + await new Promise((resolve, reject) => { + const onCanPlay = () => { + audio.removeEventListener('canplaythrough', onCanPlay); + audio.removeEventListener('error', onError); + resolve(); + }; + const onError = () => { + audio.removeEventListener('canplaythrough', onCanPlay); + audio.removeEventListener('error', onError); + reject(new Error('Failed to load audio')); + }; + audio.addEventListener('canplaythrough', onCanPlay); + audio.addEventListener('error', onError); + audio.load(); + }); + + setIsLoading(false); + setIsPlaying(true); + setIsPaused(false); + await audio.play(); + } catch (err) { + setIsLoading(false); + setIsPlaying(false); + setError(err instanceof Error ? err.message : 'Unknown error'); + } + }, [getAudioElement, getCacheKey, fetchAudioData, voice, playbackRate, isMuted]); + + // Prefetch audio for a slide without playing it. + // Returns a function that plays the prefetched audio. + // This allows starting the API call in parallel with a delay. + const prefetch = useCallback(async (slideNumber: number): Promise<() => Promise> => { + setIsLoading(true); + setError(null); + + try { + // Check cache first. + const cacheKey = getCacheKey(slideNumber, voice); + let audioDataUrl = prefetchCacheRef.current.get(cacheKey); + + if (!audioDataUrl) { + // Not cached, fetch from API. + audioDataUrl = await fetchAudioData(slideNumber); + } else { + // Remove from cache after use. + prefetchCacheRef.current.delete(cacheKey); + } + + // Return a function that plays the prefetched audio. + return async () => { + const audio = getAudioElement(); + if (!audio) return; + + // Stop current playback. + audio.pause(); + audio.currentTime = 0; + + setProgress(0); + setCurrentTime(0); + setDuration(0); + + // Set the prefetched audio source. + audio.src = audioDataUrl; + audio.playbackRate = playbackRate; + audio.muted = isMuted; + + // Wait for audio to be ready. + await new Promise((resolve, reject) => { + const onCanPlay = () => { + audio.removeEventListener('canplaythrough', onCanPlay); + audio.removeEventListener('error', onError); + resolve(); + }; + const onError = () => { + audio.removeEventListener('canplaythrough', onCanPlay); + audio.removeEventListener('error', onError); + reject(new Error('Failed to load audio')); + }; + audio.addEventListener('canplaythrough', onCanPlay); + audio.addEventListener('error', onError); + audio.load(); + }); + + setIsLoading(false); + setIsPlaying(true); + setIsPaused(false); + await audio.play(); + }; + } catch (err) { + setIsLoading(false); + setError(err instanceof Error ? err.message : 'Unknown error'); + // Return a no-op function on error. + return async () => {}; + } + }, [getAudioElement, getCacheKey, fetchAudioData, voice, playbackRate, isMuted]); + + // Prefetch audio in the background (doesn't affect loading state). + // Use this to prefetch the next slide while current slide plays. + const prefetchInBackground = useCallback((slideNumber: number): void => { + const cacheKey = getCacheKey(slideNumber, voice); + + // Skip if already cached. + if (prefetchCacheRef.current.has(cacheKey)) { + return; + } + + // Fetch in background, don't await. + fetchAudioData(slideNumber) + .then(audioDataUrl => { + prefetchCacheRef.current.set(cacheKey, audioDataUrl); + }) + .catch(() => { + // Silently ignore background prefetch errors. + }); + }, [getCacheKey, fetchAudioData, voice]); + + const pause = useCallback(() => { + const audio = audioRef.current; + if (audio) { + audio.pause(); + setIsPaused(true); + setIsPlaying(false); + } + }, []); + + const resume = useCallback(() => { + const audio = audioRef.current; + if (audio && isPaused) { + audio.play() + .then(() => { + setIsPaused(false); + setIsPlaying(true); + }) + .catch((err) => { + // Handle autoplay blocked or other playback errors. + setError(err instanceof Error ? err.message : 'Resume failed'); + setIsPaused(true); + setIsPlaying(false); + }); + } + }, [isPaused]); + + const stop = useCallback(() => { + const audio = audioRef.current; + if (audio) { + audio.pause(); + audio.currentTime = 0; + audio.src = ''; // Clear the source. + } + setIsPlaying(false); + setIsPaused(false); + setProgress(0); + setCurrentTime(0); + setDuration(0); + }, []); + + const seek = useCallback((time: number) => { + const audio = audioRef.current; + if (audio) { + audio.currentTime = time; + } + }, []); + + // Cleanup on unmount. + useEffect(() => { + return () => { + const audio = audioRef.current; + if (audio) { + audio.pause(); + audio.src = ''; + audioRef.current = null; + } + }; + }, []); + + return { + isPlaying, + isLoading, + isPaused, + isMuted, + error, + progress, + duration, + currentTime, + voice, + playbackRate, + play, + prefetch, + prefetchInBackground, + pause, + resume, + stop, + seek, + toggleMute, + setVoice, + setPlaybackRate, + }; +} diff --git a/src/components/slides/slides.js b/src/components/slides/slides.js deleted file mode 100644 index 6312cc9da..000000000 --- a/src/components/slides/slides.js +++ /dev/null @@ -1,39 +0,0 @@ -import React from "react"; -import styles from "@site/src/components/slides/slides.module.css"; -import ImageGallery from "react-image-gallery"; -import "react-image-gallery/styles/css/image-gallery.css" - -// https://github.com/xiaolin/react-image-gallery -// https://stackoverflow.com/questions/3746725/how-to-create-an-array-containing-1-n -const images = Array.from({length: 22}, (_, i) => { - let ix = i; // starting at 0 intentionally - return { - original: "/img/slides/introduction-to-toolset-" + ix + ".svg", - thumbnail: "/img/slides/introduction-to-toolset-" + ix + ".svg", - originalAlt: "Introduction to Toolset slide " + ix, - originalTitle: "Introduction to Toolset slide " + ix, - thumbnailAlt: "Introduction to Toolset slide " + ix, - thumbnailTitle: "Introduction to Toolset slide " + ix, - loading: "lazy" - } - } -); - -export default function Slides() { - return ( -
    -
    -
    - -
    -
    -
    - ); -} diff --git a/src/components/slides/slides.module.css b/src/components/slides/slides.module.css deleted file mode 100644 index df51370be..000000000 --- a/src/components/slides/slides.module.css +++ /dev/null @@ -1,11 +0,0 @@ -/** - * CSS files with the .module.css suffix will be treated as CSS modules - * and scoped locally. - */ - -.slidesContainer { - display: flex; - align-items: center; - padding: 0; - width: 100%; -}