mirror of
https://github.com/remvze/moodist.git
synced 2025-12-19 09:54:17 +00:00
Merge a9c38cf529 into 6ac65c1948
This commit is contained in:
commit
d58aa998a2
147 changed files with 12569 additions and 596 deletions
77
.dockerignore
Normal file
77
.dockerignore
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.github
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
README.md
|
||||||
|
CHANGELOG.md
|
||||||
|
LICENSE
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# Build outputs (exclude .build-cache for multi-stage builds)
|
||||||
|
# Note: dist/ is included for simple build approach
|
||||||
|
.build-cache
|
||||||
|
|
||||||
|
# Docker files
|
||||||
|
Dockerfile*
|
||||||
|
docker-compose*.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
.tmp
|
||||||
|
.temp
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Storybook build
|
||||||
|
storybook-static
|
||||||
|
|
||||||
|
# Spec workflow files
|
||||||
|
.spec-workflow
|
||||||
|
.serenatoken
|
||||||
|
|
||||||
|
# Astro cache
|
||||||
|
.astro
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -20,4 +20,5 @@ pnpm-debug.log*
|
||||||
# macOS-specific files
|
# macOS-specific files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
*storybook.log
|
*storybook.log
|
||||||
|
data
|
||||||
|
|
|
||||||
1
.serena/.gitignore
vendored
Normal file
1
.serena/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/cache
|
||||||
84
.serena/project.yml
Normal file
84
.serena/project.yml
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
# list of languages for which language servers are started; choose from:
|
||||||
|
# al bash clojure cpp csharp csharp_omnisharp
|
||||||
|
# dart elixir elm erlang fortran go
|
||||||
|
# haskell java julia kotlin lua markdown
|
||||||
|
# nix perl php python python_jedi r
|
||||||
|
# rego ruby ruby_solargraph rust scala swift
|
||||||
|
# terraform typescript typescript_vts zig
|
||||||
|
# Note:
|
||||||
|
# - For C, use cpp
|
||||||
|
# - For JavaScript, use typescript
|
||||||
|
# Special requirements:
|
||||||
|
# - csharp: Requires the presence of a .sln file in the project folder.
|
||||||
|
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||||
|
# The first language is the default language and the respective language server will be used as a fallback.
|
||||||
|
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||||
|
languages:
|
||||||
|
- typescript
|
||||||
|
|
||||||
|
# the encoding used by text files in the project
|
||||||
|
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||||
|
encoding: "utf-8"
|
||||||
|
|
||||||
|
# whether to use the project's gitignore file to ignore files
|
||||||
|
# Added on 2025-04-07
|
||||||
|
ignore_all_files_in_gitignore: true
|
||||||
|
|
||||||
|
# list of additional paths to ignore
|
||||||
|
# same syntax as gitignore, so you can use * and **
|
||||||
|
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
||||||
|
# Added (renamed) on 2025-04-07
|
||||||
|
ignored_paths: []
|
||||||
|
|
||||||
|
# whether the project is in read-only mode
|
||||||
|
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||||
|
# Added on 2025-04-18
|
||||||
|
read_only: false
|
||||||
|
|
||||||
|
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||||
|
# Below is the complete list of tools for convenience.
|
||||||
|
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||||
|
# execute `uv run scripts/print_tool_overview.py`.
|
||||||
|
#
|
||||||
|
# * `activate_project`: Activates a project by name.
|
||||||
|
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||||
|
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||||
|
# * `delete_lines`: Deletes a range of lines within a file.
|
||||||
|
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||||
|
# * `execute_shell_command`: Executes a shell command.
|
||||||
|
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||||
|
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||||
|
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||||
|
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||||
|
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||||
|
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||||
|
# Should only be used in settings where the system prompt cannot be set,
|
||||||
|
# e.g. in clients you have no control over, like Claude Desktop.
|
||||||
|
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||||
|
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||||
|
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||||
|
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||||
|
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||||
|
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||||
|
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||||
|
# * `read_file`: Reads a file within the project directory.
|
||||||
|
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||||
|
# * `remove_project`: Removes a project from the Serena configuration.
|
||||||
|
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||||
|
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||||
|
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||||
|
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||||
|
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||||
|
# * `switch_modes`: Activates modes by providing a list of their names
|
||||||
|
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||||
|
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||||
|
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||||
|
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||||
|
excluded_tools: []
|
||||||
|
|
||||||
|
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||||
|
# (contrary to the memories, which are loaded on demand).
|
||||||
|
initial_prompt: ""
|
||||||
|
|
||||||
|
project_name: "moodist"
|
||||||
|
included_optional_tools: []
|
||||||
96
.spec-workflow/templates/design-template.md
Normal file
96
.spec-workflow/templates/design-template.md
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
# Design Document
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
[High-level description of the feature and its place in the overall system]
|
||||||
|
|
||||||
|
## Steering Document Alignment
|
||||||
|
|
||||||
|
### Technical Standards (tech.md)
|
||||||
|
[How the design follows documented technical patterns and standards]
|
||||||
|
|
||||||
|
### Project Structure (structure.md)
|
||||||
|
[How the implementation will follow project organization conventions]
|
||||||
|
|
||||||
|
## Code Reuse Analysis
|
||||||
|
[What existing code will be leveraged, extended, or integrated with this feature]
|
||||||
|
|
||||||
|
### Existing Components to Leverage
|
||||||
|
- **[Component/Utility Name]**: [How it will be used]
|
||||||
|
- **[Service/Helper Name]**: [How it will be extended]
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
- **[Existing System/API]**: [How the new feature will integrate]
|
||||||
|
- **[Database/Storage]**: [How data will connect to existing schemas]
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
[Describe the overall architecture and design patterns used]
|
||||||
|
|
||||||
|
### Modular Design Principles
|
||||||
|
- **Single File Responsibility**: Each file should handle one specific concern or domain
|
||||||
|
- **Component Isolation**: Create small, focused components rather than large monolithic files
|
||||||
|
- **Service Layer Separation**: Separate data access, business logic, and presentation layers
|
||||||
|
- **Utility Modularity**: Break utilities into focused, single-purpose modules
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[Component A] --> B[Component B]
|
||||||
|
B --> C[Component C]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components and Interfaces
|
||||||
|
|
||||||
|
### Component 1
|
||||||
|
- **Purpose:** [What this component does]
|
||||||
|
- **Interfaces:** [Public methods/APIs]
|
||||||
|
- **Dependencies:** [What it depends on]
|
||||||
|
- **Reuses:** [Existing components/utilities it builds upon]
|
||||||
|
|
||||||
|
### Component 2
|
||||||
|
- **Purpose:** [What this component does]
|
||||||
|
- **Interfaces:** [Public methods/APIs]
|
||||||
|
- **Dependencies:** [What it depends on]
|
||||||
|
- **Reuses:** [Existing components/utilities it builds upon]
|
||||||
|
|
||||||
|
## Data Models
|
||||||
|
|
||||||
|
### Model 1
|
||||||
|
```
|
||||||
|
[Define the structure of Model1 in your language]
|
||||||
|
- id: [unique identifier type]
|
||||||
|
- name: [string/text type]
|
||||||
|
- [Additional properties as needed]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Model 2
|
||||||
|
```
|
||||||
|
[Define the structure of Model2 in your language]
|
||||||
|
- id: [unique identifier type]
|
||||||
|
- [Additional properties as needed]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Error Scenarios
|
||||||
|
1. **Scenario 1:** [Description]
|
||||||
|
- **Handling:** [How to handle]
|
||||||
|
- **User Impact:** [What user sees]
|
||||||
|
|
||||||
|
2. **Scenario 2:** [Description]
|
||||||
|
- **Handling:** [How to handle]
|
||||||
|
- **User Impact:** [What user sees]
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Testing
|
||||||
|
- [Unit testing approach]
|
||||||
|
- [Key components to test]
|
||||||
|
|
||||||
|
### Integration Testing
|
||||||
|
- [Integration testing approach]
|
||||||
|
- [Key flows to test]
|
||||||
|
|
||||||
|
### End-to-End Testing
|
||||||
|
- [E2E testing approach]
|
||||||
|
- [User scenarios to test]
|
||||||
51
.spec-workflow/templates/product-template.md
Normal file
51
.spec-workflow/templates/product-template.md
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
# Product Overview
|
||||||
|
|
||||||
|
## Product Purpose
|
||||||
|
[Describe the core purpose of this product/project. What problem does it solve?]
|
||||||
|
|
||||||
|
## Target Users
|
||||||
|
[Who are the primary users of this product? What are their needs and pain points?]
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
[List the main features that deliver value to users]
|
||||||
|
|
||||||
|
1. **Feature 1**: [Description]
|
||||||
|
2. **Feature 2**: [Description]
|
||||||
|
3. **Feature 3**: [Description]
|
||||||
|
|
||||||
|
## Business Objectives
|
||||||
|
[What are the business goals this product aims to achieve?]
|
||||||
|
|
||||||
|
- [Objective 1]
|
||||||
|
- [Objective 2]
|
||||||
|
- [Objective 3]
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
[How will we measure the success of this product?]
|
||||||
|
|
||||||
|
- [Metric 1]: [Target]
|
||||||
|
- [Metric 2]: [Target]
|
||||||
|
- [Metric 3]: [Target]
|
||||||
|
|
||||||
|
## Product Principles
|
||||||
|
[Core principles that guide product decisions]
|
||||||
|
|
||||||
|
1. **[Principle 1]**: [Explanation]
|
||||||
|
2. **[Principle 2]**: [Explanation]
|
||||||
|
3. **[Principle 3]**: [Explanation]
|
||||||
|
|
||||||
|
## Monitoring & Visibility (if applicable)
|
||||||
|
[How do users track progress and monitor the system?]
|
||||||
|
|
||||||
|
- **Dashboard Type**: [e.g., Web-based, CLI, Desktop app]
|
||||||
|
- **Real-time Updates**: [e.g., WebSocket, polling, push notifications]
|
||||||
|
- **Key Metrics Displayed**: [What information is most important to surface]
|
||||||
|
- **Sharing Capabilities**: [e.g., read-only links, exports, reports]
|
||||||
|
|
||||||
|
## Future Vision
|
||||||
|
[Where do we see this product evolving in the future?]
|
||||||
|
|
||||||
|
### Potential Enhancements
|
||||||
|
- **Remote Access**: [e.g., Tunnel features for sharing dashboards with stakeholders]
|
||||||
|
- **Analytics**: [e.g., Historical trends, performance metrics]
|
||||||
|
- **Collaboration**: [e.g., Multi-user support, commenting]
|
||||||
50
.spec-workflow/templates/requirements-template.md
Normal file
50
.spec-workflow/templates/requirements-template.md
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# Requirements Document
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
[Provide a brief overview of the feature, its purpose, and its value to users]
|
||||||
|
|
||||||
|
## Alignment with Product Vision
|
||||||
|
|
||||||
|
[Explain how this feature supports the goals outlined in product.md]
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement 1
|
||||||
|
|
||||||
|
**User Story:** As a [role], I want [feature], so that [benefit]
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN [event] THEN [system] SHALL [response]
|
||||||
|
2. IF [precondition] THEN [system] SHALL [response]
|
||||||
|
3. WHEN [event] AND [condition] THEN [system] SHALL [response]
|
||||||
|
|
||||||
|
### Requirement 2
|
||||||
|
|
||||||
|
**User Story:** As a [role], I want [feature], so that [benefit]
|
||||||
|
|
||||||
|
#### Acceptance Criteria
|
||||||
|
|
||||||
|
1. WHEN [event] THEN [system] SHALL [response]
|
||||||
|
2. IF [precondition] THEN [system] SHALL [response]
|
||||||
|
|
||||||
|
## Non-Functional Requirements
|
||||||
|
|
||||||
|
### Code Architecture and Modularity
|
||||||
|
- **Single Responsibility Principle**: Each file should have a single, well-defined purpose
|
||||||
|
- **Modular Design**: Components, utilities, and services should be isolated and reusable
|
||||||
|
- **Dependency Management**: Minimize interdependencies between modules
|
||||||
|
- **Clear Interfaces**: Define clean contracts between components and layers
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- [Performance requirements]
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- [Security requirements]
|
||||||
|
|
||||||
|
### Reliability
|
||||||
|
- [Reliability requirements]
|
||||||
|
|
||||||
|
### Usability
|
||||||
|
- [Usability requirements]
|
||||||
145
.spec-workflow/templates/structure-template.md
Normal file
145
.spec-workflow/templates/structure-template.md
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
# Project Structure
|
||||||
|
|
||||||
|
## Directory Organization
|
||||||
|
|
||||||
|
```
|
||||||
|
[Define your project's directory structure. Examples below - adapt to your project type]
|
||||||
|
|
||||||
|
Example for a library/package:
|
||||||
|
project-root/
|
||||||
|
├── src/ # Source code
|
||||||
|
├── tests/ # Test files
|
||||||
|
├── docs/ # Documentation
|
||||||
|
├── examples/ # Usage examples
|
||||||
|
└── [build/dist/out] # Build output
|
||||||
|
|
||||||
|
Example for an application:
|
||||||
|
project-root/
|
||||||
|
├── [src/app/lib] # Main source code
|
||||||
|
├── [assets/resources] # Static resources
|
||||||
|
├── [config/settings] # Configuration
|
||||||
|
├── [scripts/tools] # Build/utility scripts
|
||||||
|
└── [tests/spec] # Test files
|
||||||
|
|
||||||
|
Common patterns:
|
||||||
|
- Group by feature/module
|
||||||
|
- Group by layer (UI, business logic, data)
|
||||||
|
- Group by type (models, controllers, views)
|
||||||
|
- Flat structure for simple projects
|
||||||
|
```
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
### Files
|
||||||
|
- **Components/Modules**: [e.g., `PascalCase`, `snake_case`, `kebab-case`]
|
||||||
|
- **Services/Handlers**: [e.g., `UserService`, `user_service`, `user-service`]
|
||||||
|
- **Utilities/Helpers**: [e.g., `dateUtils`, `date_utils`, `date-utils`]
|
||||||
|
- **Tests**: [e.g., `[filename]_test`, `[filename].test`, `[filename]Test`]
|
||||||
|
|
||||||
|
### Code
|
||||||
|
- **Classes/Types**: [e.g., `PascalCase`, `CamelCase`, `snake_case`]
|
||||||
|
- **Functions/Methods**: [e.g., `camelCase`, `snake_case`, `PascalCase`]
|
||||||
|
- **Constants**: [e.g., `UPPER_SNAKE_CASE`, `SCREAMING_CASE`, `PascalCase`]
|
||||||
|
- **Variables**: [e.g., `camelCase`, `snake_case`, `lowercase`]
|
||||||
|
|
||||||
|
## Import Patterns
|
||||||
|
|
||||||
|
### Import Order
|
||||||
|
1. External dependencies
|
||||||
|
2. Internal modules
|
||||||
|
3. Relative imports
|
||||||
|
4. Style imports
|
||||||
|
|
||||||
|
### Module/Package Organization
|
||||||
|
```
|
||||||
|
[Describe your project's import/include patterns]
|
||||||
|
Examples:
|
||||||
|
- Absolute imports from project root
|
||||||
|
- Relative imports within modules
|
||||||
|
- Package/namespace organization
|
||||||
|
- Dependency management approach
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Structure Patterns
|
||||||
|
|
||||||
|
[Define common patterns for organizing code within files. Below are examples - choose what applies to your project]
|
||||||
|
|
||||||
|
### Module/Class Organization
|
||||||
|
```
|
||||||
|
Example patterns:
|
||||||
|
1. Imports/includes/dependencies
|
||||||
|
2. Constants and configuration
|
||||||
|
3. Type/interface definitions
|
||||||
|
4. Main implementation
|
||||||
|
5. Helper/utility functions
|
||||||
|
6. Exports/public API
|
||||||
|
```
|
||||||
|
|
||||||
|
### Function/Method Organization
|
||||||
|
```
|
||||||
|
Example patterns:
|
||||||
|
- Input validation first
|
||||||
|
- Core logic in the middle
|
||||||
|
- Error handling throughout
|
||||||
|
- Clear return points
|
||||||
|
```
|
||||||
|
|
||||||
|
### File Organization Principles
|
||||||
|
```
|
||||||
|
Choose what works for your project:
|
||||||
|
- One class/module per file
|
||||||
|
- Related functionality grouped together
|
||||||
|
- Public API at the top/bottom
|
||||||
|
- Implementation details hidden
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Organization Principles
|
||||||
|
|
||||||
|
1. **Single Responsibility**: Each file should have one clear purpose
|
||||||
|
2. **Modularity**: Code should be organized into reusable modules
|
||||||
|
3. **Testability**: Structure code to be easily testable
|
||||||
|
4. **Consistency**: Follow patterns established in the codebase
|
||||||
|
|
||||||
|
## Module Boundaries
|
||||||
|
[Define how different parts of your project interact and maintain separation of concerns]
|
||||||
|
|
||||||
|
Examples of boundary patterns:
|
||||||
|
- **Core vs Plugins**: Core functionality vs extensible plugins
|
||||||
|
- **Public API vs Internal**: What's exposed vs implementation details
|
||||||
|
- **Platform-specific vs Cross-platform**: OS-specific code isolation
|
||||||
|
- **Stable vs Experimental**: Production code vs experimental features
|
||||||
|
- **Dependencies direction**: Which modules can depend on which
|
||||||
|
|
||||||
|
## Code Size Guidelines
|
||||||
|
[Define your project's guidelines for file and function sizes]
|
||||||
|
|
||||||
|
Suggested guidelines:
|
||||||
|
- **File size**: [Define maximum lines per file]
|
||||||
|
- **Function/Method size**: [Define maximum lines per function]
|
||||||
|
- **Class/Module complexity**: [Define complexity limits]
|
||||||
|
- **Nesting depth**: [Maximum nesting levels]
|
||||||
|
|
||||||
|
## Dashboard/Monitoring Structure (if applicable)
|
||||||
|
[How dashboard or monitoring components are organized]
|
||||||
|
|
||||||
|
### Example Structure:
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
└── dashboard/ # Self-contained dashboard subsystem
|
||||||
|
├── server/ # Backend server components
|
||||||
|
├── client/ # Frontend assets
|
||||||
|
├── shared/ # Shared types/utilities
|
||||||
|
└── public/ # Static assets
|
||||||
|
```
|
||||||
|
|
||||||
|
### Separation of Concerns
|
||||||
|
- Dashboard isolated from core business logic
|
||||||
|
- Own CLI entry point for independent operation
|
||||||
|
- Minimal dependencies on main application
|
||||||
|
- Can be disabled without affecting core functionality
|
||||||
|
|
||||||
|
## Documentation Standards
|
||||||
|
- All public APIs must have documentation
|
||||||
|
- Complex logic should include inline comments
|
||||||
|
- README files for major modules
|
||||||
|
- Follow language-specific documentation conventions
|
||||||
139
.spec-workflow/templates/tasks-template.md
Normal file
139
.spec-workflow/templates/tasks-template.md
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
# Tasks Document
|
||||||
|
|
||||||
|
- [ ] 1. Create core interfaces in src/types/feature.ts
|
||||||
|
- File: src/types/feature.ts
|
||||||
|
- Define TypeScript interfaces for feature data structures
|
||||||
|
- Extend existing base interfaces from base.ts
|
||||||
|
- Purpose: Establish type safety for feature implementation
|
||||||
|
- _Leverage: src/types/base.ts_
|
||||||
|
- _Requirements: 1.1_
|
||||||
|
- _Prompt: Role: TypeScript Developer specializing in type systems and interfaces | Task: Create comprehensive TypeScript interfaces for the feature data structures following requirements 1.1, extending existing base interfaces from src/types/base.ts | Restrictions: Do not modify existing base interfaces, maintain backward compatibility, follow project naming conventions | Success: All interfaces compile without errors, proper inheritance from base types, full type coverage for feature requirements_
|
||||||
|
|
||||||
|
- [ ] 2. Create base model class in src/models/FeatureModel.ts
|
||||||
|
- File: src/models/FeatureModel.ts
|
||||||
|
- Implement base model extending BaseModel class
|
||||||
|
- Add validation methods using existing validation utilities
|
||||||
|
- Purpose: Provide data layer foundation for feature
|
||||||
|
- _Leverage: src/models/BaseModel.ts, src/utils/validation.ts_
|
||||||
|
- _Requirements: 2.1_
|
||||||
|
- _Prompt: Role: Backend Developer with expertise in Node.js and data modeling | Task: Create a base model class extending BaseModel and implementing validation following requirement 2.1, leveraging existing patterns from src/models/BaseModel.ts and src/utils/validation.ts | Restrictions: Must follow existing model patterns, do not bypass validation utilities, maintain consistent error handling | Success: Model extends BaseModel correctly, validation methods implemented and tested, follows project architecture patterns_
|
||||||
|
|
||||||
|
- [ ] 3. Add specific model methods to FeatureModel.ts
|
||||||
|
- File: src/models/FeatureModel.ts (continue from task 2)
|
||||||
|
- Implement create, update, delete methods
|
||||||
|
- Add relationship handling for foreign keys
|
||||||
|
- Purpose: Complete model functionality for CRUD operations
|
||||||
|
- _Leverage: src/models/BaseModel.ts_
|
||||||
|
- _Requirements: 2.2, 2.3_
|
||||||
|
- _Prompt: Role: Backend Developer with expertise in ORM and database operations | Task: Implement CRUD methods and relationship handling in FeatureModel.ts following requirements 2.2 and 2.3, extending patterns from src/models/BaseModel.ts | Restrictions: Must maintain transaction integrity, follow existing relationship patterns, do not duplicate base model functionality | Success: All CRUD operations work correctly, relationships are properly handled, database operations are atomic and efficient_
|
||||||
|
|
||||||
|
- [ ] 4. Create model unit tests in tests/models/FeatureModel.test.ts
|
||||||
|
- File: tests/models/FeatureModel.test.ts
|
||||||
|
- Write tests for model validation and CRUD methods
|
||||||
|
- Use existing test utilities and fixtures
|
||||||
|
- Purpose: Ensure model reliability and catch regressions
|
||||||
|
- _Leverage: tests/helpers/testUtils.ts, tests/fixtures/data.ts_
|
||||||
|
- _Requirements: 2.1, 2.2_
|
||||||
|
- _Prompt: Role: QA Engineer with expertise in unit testing and Jest/Mocha frameworks | Task: Create comprehensive unit tests for FeatureModel validation and CRUD methods covering requirements 2.1 and 2.2, using existing test utilities from tests/helpers/testUtils.ts and fixtures from tests/fixtures/data.ts | Restrictions: Must test both success and failure scenarios, do not test external dependencies directly, maintain test isolation | Success: All model methods are tested with good coverage, edge cases covered, tests run independently and consistently_
|
||||||
|
|
||||||
|
- [ ] 5. Create service interface in src/services/IFeatureService.ts
|
||||||
|
- File: src/services/IFeatureService.ts
|
||||||
|
- Define service contract with method signatures
|
||||||
|
- Extend base service interface patterns
|
||||||
|
- Purpose: Establish service layer contract for dependency injection
|
||||||
|
- _Leverage: src/services/IBaseService.ts_
|
||||||
|
- _Requirements: 3.1_
|
||||||
|
- _Prompt: Role: Software Architect specializing in service-oriented architecture and TypeScript interfaces | Task: Design service interface contract following requirement 3.1, extending base service patterns from src/services/IBaseService.ts for dependency injection | Restrictions: Must maintain interface segregation principle, do not expose internal implementation details, ensure contract compatibility with DI container | Success: Interface is well-defined with clear method signatures, extends base service appropriately, supports all required service operations_
|
||||||
|
|
||||||
|
- [ ] 6. Implement feature service in src/services/FeatureService.ts
|
||||||
|
- File: src/services/FeatureService.ts
|
||||||
|
- Create concrete service implementation using FeatureModel
|
||||||
|
- Add error handling with existing error utilities
|
||||||
|
- Purpose: Provide business logic layer for feature operations
|
||||||
|
- _Leverage: src/services/BaseService.ts, src/utils/errorHandler.ts, src/models/FeatureModel.ts_
|
||||||
|
- _Requirements: 3.2_
|
||||||
|
- _Prompt: Role: Backend Developer with expertise in service layer architecture and business logic | Task: Implement concrete FeatureService following requirement 3.2, using FeatureModel and extending BaseService patterns with proper error handling from src/utils/errorHandler.ts | Restrictions: Must implement interface contract exactly, do not bypass model validation, maintain separation of concerns from data layer | Success: Service implements all interface methods correctly, robust error handling implemented, business logic is well-encapsulated and testable_
|
||||||
|
|
||||||
|
- [ ] 7. Add service dependency injection in src/utils/di.ts
|
||||||
|
- File: src/utils/di.ts (modify existing)
|
||||||
|
- Register FeatureService in dependency injection container
|
||||||
|
- Configure service lifetime and dependencies
|
||||||
|
- Purpose: Enable service injection throughout application
|
||||||
|
- _Leverage: existing DI configuration in src/utils/di.ts_
|
||||||
|
- _Requirements: 3.1_
|
||||||
|
- _Prompt: Role: DevOps Engineer with expertise in dependency injection and IoC containers | Task: Register FeatureService in DI container following requirement 3.1, configuring appropriate lifetime and dependencies using existing patterns from src/utils/di.ts | Restrictions: Must follow existing DI container patterns, do not create circular dependencies, maintain service resolution efficiency | Success: FeatureService is properly registered and resolvable, dependencies are correctly configured, service lifetime is appropriate for use case_
|
||||||
|
|
||||||
|
- [ ] 8. Create service unit tests in tests/services/FeatureService.test.ts
|
||||||
|
- File: tests/services/FeatureService.test.ts
|
||||||
|
- Write tests for service methods with mocked dependencies
|
||||||
|
- Test error handling scenarios
|
||||||
|
- Purpose: Ensure service reliability and proper error handling
|
||||||
|
- _Leverage: tests/helpers/testUtils.ts, tests/mocks/modelMocks.ts_
|
||||||
|
- _Requirements: 3.2, 3.3_
|
||||||
|
- _Prompt: Role: QA Engineer with expertise in service testing and mocking frameworks | Task: Create comprehensive unit tests for FeatureService methods covering requirements 3.2 and 3.3, using mocked dependencies from tests/mocks/modelMocks.ts and test utilities | Restrictions: Must mock all external dependencies, test business logic in isolation, do not test framework code | Success: All service methods tested with proper mocking, error scenarios covered, tests verify business logic correctness and error handling_
|
||||||
|
|
||||||
|
- [ ] 4. Create API endpoints
|
||||||
|
- Design API structure
|
||||||
|
- _Leverage: src/api/baseApi.ts, src/utils/apiUtils.ts_
|
||||||
|
- _Requirements: 4.0_
|
||||||
|
- _Prompt: Role: API Architect specializing in RESTful design and Express.js | Task: Design comprehensive API structure following requirement 4.0, leveraging existing patterns from src/api/baseApi.ts and utilities from src/utils/apiUtils.ts | Restrictions: Must follow REST conventions, maintain API versioning compatibility, do not expose internal data structures directly | Success: API structure is well-designed and documented, follows existing patterns, supports all required operations with proper HTTP methods and status codes_
|
||||||
|
|
||||||
|
- [ ] 4.1 Set up routing and middleware
|
||||||
|
- Configure application routes
|
||||||
|
- Add authentication middleware
|
||||||
|
- Set up error handling middleware
|
||||||
|
- _Leverage: src/middleware/auth.ts, src/middleware/errorHandler.ts_
|
||||||
|
- _Requirements: 4.1_
|
||||||
|
- _Prompt: Role: Backend Developer with expertise in Express.js middleware and routing | Task: Configure application routes and middleware following requirement 4.1, integrating authentication from src/middleware/auth.ts and error handling from src/middleware/errorHandler.ts | Restrictions: Must maintain middleware order, do not bypass security middleware, ensure proper error propagation | Success: Routes are properly configured with correct middleware chain, authentication works correctly, errors are handled gracefully throughout the request lifecycle_
|
||||||
|
|
||||||
|
- [ ] 4.2 Implement CRUD endpoints
|
||||||
|
- Create API endpoints
|
||||||
|
- Add request validation
|
||||||
|
- Write API integration tests
|
||||||
|
- _Leverage: src/controllers/BaseController.ts, src/utils/validation.ts_
|
||||||
|
- _Requirements: 4.2, 4.3_
|
||||||
|
- _Prompt: Role: Full-stack Developer with expertise in API development and validation | Task: Implement CRUD endpoints following requirements 4.2 and 4.3, extending BaseController patterns and using validation utilities from src/utils/validation.ts | Restrictions: Must validate all inputs, follow existing controller patterns, ensure proper HTTP status codes and responses | Success: All CRUD operations work correctly, request validation prevents invalid data, integration tests pass and cover all endpoints_
|
||||||
|
|
||||||
|
- [ ] 5. Add frontend components
|
||||||
|
- Plan component architecture
|
||||||
|
- _Leverage: src/components/BaseComponent.tsx, src/styles/theme.ts_
|
||||||
|
- _Requirements: 5.0_
|
||||||
|
- _Prompt: Role: Frontend Architect with expertise in React component design and architecture | Task: Plan comprehensive component architecture following requirement 5.0, leveraging base patterns from src/components/BaseComponent.tsx and theme system from src/styles/theme.ts | Restrictions: Must follow existing component patterns, maintain design system consistency, ensure component reusability | Success: Architecture is well-planned and documented, components are properly organized, follows existing patterns and theme system_
|
||||||
|
|
||||||
|
- [ ] 5.1 Create base UI components
|
||||||
|
- Set up component structure
|
||||||
|
- Implement reusable components
|
||||||
|
- Add styling and theming
|
||||||
|
- _Leverage: src/components/BaseComponent.tsx, src/styles/theme.ts_
|
||||||
|
- _Requirements: 5.1_
|
||||||
|
- _Prompt: Role: Frontend Developer specializing in React and component architecture | Task: Create reusable UI components following requirement 5.1, extending BaseComponent patterns and using existing theme system from src/styles/theme.ts | Restrictions: Must use existing theme variables, follow component composition patterns, ensure accessibility compliance | Success: Components are reusable and properly themed, follow existing architecture, accessible and responsive_
|
||||||
|
|
||||||
|
- [ ] 5.2 Implement feature-specific components
|
||||||
|
- Create feature components
|
||||||
|
- Add state management
|
||||||
|
- Connect to API endpoints
|
||||||
|
- _Leverage: src/hooks/useApi.ts, src/components/BaseComponent.tsx_
|
||||||
|
- _Requirements: 5.2, 5.3_
|
||||||
|
- _Prompt: Role: React Developer with expertise in state management and API integration | Task: Implement feature-specific components following requirements 5.2 and 5.3, using API hooks from src/hooks/useApi.ts and extending BaseComponent patterns | Restrictions: Must use existing state management patterns, handle loading and error states properly, maintain component performance | Success: Components are fully functional with proper state management, API integration works smoothly, user experience is responsive and intuitive_
|
||||||
|
|
||||||
|
- [ ] 6. Integration and testing
|
||||||
|
- Plan integration approach
|
||||||
|
- _Leverage: src/utils/integrationUtils.ts, tests/helpers/testUtils.ts_
|
||||||
|
- _Requirements: 6.0_
|
||||||
|
- _Prompt: Role: Integration Engineer with expertise in system integration and testing strategies | Task: Plan comprehensive integration approach following requirement 6.0, leveraging integration utilities from src/utils/integrationUtils.ts and test helpers | Restrictions: Must consider all system components, ensure proper test coverage, maintain integration test reliability | Success: Integration plan is comprehensive and feasible, all system components work together correctly, integration points are well-tested_
|
||||||
|
|
||||||
|
- [ ] 6.1 Write end-to-end tests
|
||||||
|
- Set up E2E testing framework
|
||||||
|
- Write user journey tests
|
||||||
|
- Add test automation
|
||||||
|
- _Leverage: tests/helpers/testUtils.ts, tests/fixtures/data.ts_
|
||||||
|
- _Requirements: All_
|
||||||
|
- _Prompt: Role: QA Automation Engineer with expertise in E2E testing and test frameworks like Cypress or Playwright | Task: Implement comprehensive end-to-end tests covering all requirements, setting up testing framework and user journey tests using test utilities and fixtures | Restrictions: Must test real user workflows, ensure tests are maintainable and reliable, do not test implementation details | Success: E2E tests cover all critical user journeys, tests run reliably in CI/CD pipeline, user experience is validated from end-to-end_
|
||||||
|
|
||||||
|
- [ ] 6.2 Final integration and cleanup
|
||||||
|
- Integrate all components
|
||||||
|
- Fix any integration issues
|
||||||
|
- Clean up code and documentation
|
||||||
|
- _Leverage: src/utils/cleanup.ts, docs/templates/_
|
||||||
|
- _Requirements: All_
|
||||||
|
- _Prompt: Role: Senior Developer with expertise in code quality and system integration | Task: Complete final integration of all components and perform comprehensive cleanup covering all requirements, using cleanup utilities and documentation templates | Restrictions: Must not break existing functionality, ensure code quality standards are met, maintain documentation consistency | Success: All components are fully integrated and working together, code is clean and well-documented, system meets all requirements and quality standards_
|
||||||
99
.spec-workflow/templates/tech-template.md
Normal file
99
.spec-workflow/templates/tech-template.md
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
# Technology Stack
|
||||||
|
|
||||||
|
## Project Type
|
||||||
|
[Describe what kind of project this is: web application, CLI tool, desktop application, mobile app, library, API service, embedded system, game, etc.]
|
||||||
|
|
||||||
|
## Core Technologies
|
||||||
|
|
||||||
|
### Primary Language(s)
|
||||||
|
- **Language**: [e.g., Python 3.11, Go 1.21, TypeScript, Rust, C++]
|
||||||
|
- **Runtime/Compiler**: [if applicable]
|
||||||
|
- **Language-specific tools**: [package managers, build tools, etc.]
|
||||||
|
|
||||||
|
### Key Dependencies/Libraries
|
||||||
|
[List the main libraries and frameworks your project depends on]
|
||||||
|
- **[Library/Framework name]**: [Purpose and version]
|
||||||
|
- **[Library/Framework name]**: [Purpose and version]
|
||||||
|
|
||||||
|
### Application Architecture
|
||||||
|
[Describe how your application is structured - this could be MVC, event-driven, plugin-based, client-server, standalone, microservices, monolithic, etc.]
|
||||||
|
|
||||||
|
### Data Storage (if applicable)
|
||||||
|
- **Primary storage**: [e.g., PostgreSQL, files, in-memory, cloud storage]
|
||||||
|
- **Caching**: [e.g., Redis, in-memory, disk cache]
|
||||||
|
- **Data formats**: [e.g., JSON, Protocol Buffers, XML, binary]
|
||||||
|
|
||||||
|
### External Integrations (if applicable)
|
||||||
|
- **APIs**: [External services you integrate with]
|
||||||
|
- **Protocols**: [e.g., HTTP/REST, gRPC, WebSocket, TCP/IP]
|
||||||
|
- **Authentication**: [e.g., OAuth, API keys, certificates]
|
||||||
|
|
||||||
|
### Monitoring & Dashboard Technologies (if applicable)
|
||||||
|
- **Dashboard Framework**: [e.g., React, Vue, vanilla JS, terminal UI]
|
||||||
|
- **Real-time Communication**: [e.g., WebSocket, Server-Sent Events, polling]
|
||||||
|
- **Visualization Libraries**: [e.g., Chart.js, D3, terminal graphs]
|
||||||
|
- **State Management**: [e.g., Redux, Vuex, file system as source of truth]
|
||||||
|
|
||||||
|
## Development Environment
|
||||||
|
|
||||||
|
### Build & Development Tools
|
||||||
|
- **Build System**: [e.g., Make, CMake, Gradle, npm scripts, cargo]
|
||||||
|
- **Package Management**: [e.g., pip, npm, cargo, go mod, apt, brew]
|
||||||
|
- **Development workflow**: [e.g., hot reload, watch mode, REPL]
|
||||||
|
|
||||||
|
### Code Quality Tools
|
||||||
|
- **Static Analysis**: [Tools for code quality and correctness]
|
||||||
|
- **Formatting**: [Code style enforcement tools]
|
||||||
|
- **Testing Framework**: [Unit, integration, and/or end-to-end testing tools]
|
||||||
|
- **Documentation**: [Documentation generation tools]
|
||||||
|
|
||||||
|
### Version Control & Collaboration
|
||||||
|
- **VCS**: [e.g., Git, Mercurial, SVN]
|
||||||
|
- **Branching Strategy**: [e.g., Git Flow, GitHub Flow, trunk-based]
|
||||||
|
- **Code Review Process**: [How code reviews are conducted]
|
||||||
|
|
||||||
|
### Dashboard Development (if applicable)
|
||||||
|
- **Live Reload**: [e.g., Hot module replacement, file watchers]
|
||||||
|
- **Port Management**: [e.g., Dynamic allocation, configurable ports]
|
||||||
|
- **Multi-Instance Support**: [e.g., Running multiple dashboards simultaneously]
|
||||||
|
|
||||||
|
## Deployment & Distribution (if applicable)
|
||||||
|
- **Target Platform(s)**: [Where/how the project runs: cloud, on-premise, desktop, mobile, embedded]
|
||||||
|
- **Distribution Method**: [How users get your software: download, package manager, app store, SaaS]
|
||||||
|
- **Installation Requirements**: [Prerequisites, system requirements]
|
||||||
|
- **Update Mechanism**: [How updates are delivered]
|
||||||
|
|
||||||
|
## Technical Requirements & Constraints
|
||||||
|
|
||||||
|
### Performance Requirements
|
||||||
|
- [e.g., response time, throughput, memory usage, startup time]
|
||||||
|
- [Specific benchmarks or targets]
|
||||||
|
|
||||||
|
### Compatibility Requirements
|
||||||
|
- **Platform Support**: [Operating systems, architectures, versions]
|
||||||
|
- **Dependency Versions**: [Minimum/maximum versions of dependencies]
|
||||||
|
- **Standards Compliance**: [Industry standards, protocols, specifications]
|
||||||
|
|
||||||
|
### Security & Compliance
|
||||||
|
- **Security Requirements**: [Authentication, encryption, data protection]
|
||||||
|
- **Compliance Standards**: [GDPR, HIPAA, SOC2, etc. if applicable]
|
||||||
|
- **Threat Model**: [Key security considerations]
|
||||||
|
|
||||||
|
### Scalability & Reliability
|
||||||
|
- **Expected Load**: [Users, requests, data volume]
|
||||||
|
- **Availability Requirements**: [Uptime targets, disaster recovery]
|
||||||
|
- **Growth Projections**: [How the system needs to scale]
|
||||||
|
|
||||||
|
## Technical Decisions & Rationale
|
||||||
|
[Document key architectural and technology choices]
|
||||||
|
|
||||||
|
### Decision Log
|
||||||
|
1. **[Technology/Pattern Choice]**: [Why this was chosen, alternatives considered]
|
||||||
|
2. **[Architecture Decision]**: [Rationale, trade-offs accepted]
|
||||||
|
3. **[Tool/Library Selection]**: [Reasoning, evaluation criteria]
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
[Document any technical debt, limitations, or areas for improvement]
|
||||||
|
|
||||||
|
- [Limitation 1]: [Impact and potential future solutions]
|
||||||
|
- [Limitation 2]: [Why it exists and when it might be addressed]
|
||||||
64
.spec-workflow/user-templates/README.md
Normal file
64
.spec-workflow/user-templates/README.md
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
# User Templates
|
||||||
|
|
||||||
|
This directory allows you to create custom templates that override the default Spec Workflow templates.
|
||||||
|
|
||||||
|
## How to Use Custom Templates
|
||||||
|
|
||||||
|
1. **Create your custom template file** in this directory with the exact same name as the default template you want to override:
|
||||||
|
- `requirements-template.md` - Override requirements document template
|
||||||
|
- `design-template.md` - Override design document template
|
||||||
|
- `tasks-template.md` - Override tasks document template
|
||||||
|
- `product-template.md` - Override product steering template
|
||||||
|
- `tech-template.md` - Override tech steering template
|
||||||
|
- `structure-template.md` - Override structure steering template
|
||||||
|
|
||||||
|
2. **Template Loading Priority**:
|
||||||
|
- The system first checks this `user-templates/` directory
|
||||||
|
- If a matching template is found here, it will be used
|
||||||
|
- Otherwise, the default template from `templates/` will be used
|
||||||
|
|
||||||
|
## Example Custom Template
|
||||||
|
|
||||||
|
To create a custom requirements template:
|
||||||
|
|
||||||
|
1. Create a file named `requirements-template.md` in this directory
|
||||||
|
2. Add your custom structure, for example:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Requirements Document
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
[Your custom section]
|
||||||
|
|
||||||
|
## Business Requirements
|
||||||
|
[Your custom structure]
|
||||||
|
|
||||||
|
## Technical Requirements
|
||||||
|
[Your custom fields]
|
||||||
|
|
||||||
|
## Custom Sections
|
||||||
|
[Add any sections specific to your workflow]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Template Variables
|
||||||
|
|
||||||
|
Templates can include placeholders that will be replaced when documents are created:
|
||||||
|
- `{{projectName}}` - The name of your project
|
||||||
|
- `{{featureName}}` - The name of the feature being specified
|
||||||
|
- `{{date}}` - The current date
|
||||||
|
- `{{author}}` - The document author
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Start from defaults**: Copy a default template from `../templates/` as a starting point
|
||||||
|
2. **Keep structure consistent**: Maintain similar section headers for tool compatibility
|
||||||
|
3. **Document changes**: Add comments explaining why sections were added/modified
|
||||||
|
4. **Version control**: Track your custom templates in version control
|
||||||
|
5. **Test thoroughly**: Ensure custom templates work with the spec workflow tools
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Custom templates are project-specific and not included in the package distribution
|
||||||
|
- The `templates/` directory contains the default templates which are updated with each version
|
||||||
|
- Your custom templates in this directory are preserved during updates
|
||||||
|
- If a custom template has errors, the system will fall back to the default template
|
||||||
586
CHANGELOG.md
586
CHANGELOG.md
|
|
@ -2,6 +2,592 @@
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
||||||
|
|
||||||
|
## 2.2.0 (2025-11-17)
|
||||||
|
|
||||||
|
|
||||||
|
### ✅ Testing
|
||||||
|
|
||||||
|
* add Vitest and some tests ([def9a57](https://github.com/wheesys/moodist/commit/def9a57e0c6454f0e3ffd74b29153a01b33866be))
|
||||||
|
* write more tests ([9cc0ccd](https://github.com/wheesys/moodist/commit/9cc0ccd325cf769d64779f133bd2d59e6ba7ca58))
|
||||||
|
* write tests for motion lib ([d356d77](https://github.com/wheesys/moodist/commit/d356d77aa951b84a6ccbd0b1c6590286c042957b))
|
||||||
|
* write tests for random helper ([cad85c7](https://github.com/wheesys/moodist/commit/cad85c76676cff7fe8c47ccb8d332809f7276e28))
|
||||||
|
|
||||||
|
|
||||||
|
### ⚡️ Performance Improvements
|
||||||
|
|
||||||
|
* improve the breathing cricle ([3d83a14](https://github.com/wheesys/moodist/commit/3d83a1427feaec1e858953899870da06d35b4631))
|
||||||
|
|
||||||
|
|
||||||
|
### ♻️ Code Refactoring
|
||||||
|
|
||||||
|
* add constants ([81678ea](https://github.com/wheesys/moodist/commit/81678ea384bfdc00925e674c988fad85710d705a))
|
||||||
|
* add description for events ([2c8135d](https://github.com/wheesys/moodist/commit/2c8135db43b1a1dad789277926af0d1be3e987fc))
|
||||||
|
* add JSDoc for custom hooks ([0f50e6a](https://github.com/wheesys/moodist/commit/0f50e6ae8b3d1615ed52fb168a48bbb2149090ac))
|
||||||
|
* add JSDoc for helper functions ([4ae0504](https://github.com/wheesys/moodist/commit/4ae05049377506f79f5ef9f68fa7cf396d7d0528))
|
||||||
|
* add Radix ([ddae0b6](https://github.com/wheesys/moodist/commit/ddae0b660ff2bb0bc33400ad59159f4525d80429))
|
||||||
|
* add variant to container ([831a9c8](https://github.com/wheesys/moodist/commit/831a9c8ea019a3d86e994ff0e060dd4337a84d1f))
|
||||||
|
* better item structure for menu ([26bf016](https://github.com/wheesys/moodist/commit/26bf01690cfcc105b661951bcb2347394a67fb68))
|
||||||
|
* better name ([2192335](https://github.com/wheesys/moodist/commit/219233523827ed47a8ebea88a4ce73bb3c027e0c))
|
||||||
|
* better shortcut handling ([f81ea9e](https://github.com/wheesys/moodist/commit/f81ea9e7bdf7c7253587da9312e6fb6caaf14590))
|
||||||
|
* better tooltip ([5fecd38](https://github.com/wheesys/moodist/commit/5fecd383aaf757dbb563a1abd7eee0e64905902c))
|
||||||
|
* change data file structure ([c9e8bd4](https://github.com/wheesys/moodist/commit/c9e8bd41fd79f6c73c11e6fcdbe8b24c6c0bbeb4))
|
||||||
|
* change ordering config ([a43c679](https://github.com/wheesys/moodist/commit/a43c679e214b24c7f547e182aea6e2fbf826228f))
|
||||||
|
* change stores structure ([096251e](https://github.com/wheesys/moodist/commit/096251ec0a459efbbe08d88cabab75c4ad775976))
|
||||||
|
* migrate to Astro components ([ffe260f](https://github.com/wheesys/moodist/commit/ffe260f4a02238cb83cf92ed06c4f9c75ba189a4))
|
||||||
|
* move donation to React ([c505c57](https://github.com/wheesys/moodist/commit/c505c574a885004e071da63d8255062befc29921))
|
||||||
|
* move footer to React ([52176bc](https://github.com/wheesys/moodist/commit/52176bc3f9eac43d701de0e7f0ca103eeca46858))
|
||||||
|
* reduce dependency ([c893e2a](https://github.com/wheesys/moodist/commit/c893e2a6adc68bdd40f8e5dd1e2b3ab6642a0145))
|
||||||
|
* refactor the breathing tool ([d56f8be](https://github.com/wheesys/moodist/commit/d56f8be448aa746874c38ba0cc7e00e38d339f59))
|
||||||
|
* relocate folders ([f3cea66](https://github.com/wheesys/moodist/commit/f3cea668470ca06b2114a03b54660475cc560d44))
|
||||||
|
* relocate generic components ([4adfb3d](https://github.com/wheesys/moodist/commit/4adfb3ddc938a2720c26b9107c8cccdf66c0b913))
|
||||||
|
* relocate sections ([d672bf6](https://github.com/wheesys/moodist/commit/d672bf6f85fe7b3a5c20fc53668705ab3d7827c5))
|
||||||
|
* remove extra hook ([a4a31dd](https://github.com/wheesys/moodist/commit/a4a31dd43eef5c3e1d2b62cf4bb6e491e382f988))
|
||||||
|
* remove extra types ([e490a1d](https://github.com/wheesys/moodist/commit/e490a1da84d948c9db2e689414f432aaf53bc0b2))
|
||||||
|
* remove hide delay for tooltips ([48291a6](https://github.com/wheesys/moodist/commit/48291a645776b235918485b737b9272113f838a0))
|
||||||
|
* remove media session ([1f63534](https://github.com/wheesys/moodist/commit/1f635348e3e5cf73ee76e1c5fac7b5f5b7f7ea6a))
|
||||||
|
* remove sections ([3f45be3](https://github.com/wheesys/moodist/commit/3f45be3942bfeff74f3f0126de5e61037a749e61))
|
||||||
|
* remove seperate favorite store ([d7fd17e](https://github.com/wheesys/moodist/commit/d7fd17ea8bb79ab44220bedfd62c98f9abf1d9f6))
|
||||||
|
* remove seperate playing context ([daee746](https://github.com/wheesys/moodist/commit/daee7465bc4460a11b6aa5885cbd0eb7191c0026))
|
||||||
|
* remove the timer store ([5ffb06b](https://github.com/wheesys/moodist/commit/5ffb06be036acb1fe5d8fa4b91e4cbede39ebcc0))
|
||||||
|
* remove unmute and media session ([b77c817](https://github.com/wheesys/moodist/commit/b77c817db25e1a738b6770b1ae86d792e0d42240))
|
||||||
|
* rename component ([f5cdb8c](https://github.com/wheesys/moodist/commit/f5cdb8c06b44f9cdde27e6e7c7e3d4d156c21dca))
|
||||||
|
* rename components ([d73b2bc](https://github.com/wheesys/moodist/commit/d73b2bc1ff7689ff85c6453710b2d89927973066))
|
||||||
|
* rename hook file ([2f84268](https://github.com/wheesys/moodist/commit/2f84268017aa4592684c8e3ac47399d0f100669d))
|
||||||
|
* rename some functions ([0533460](https://github.com/wheesys/moodist/commit/05334606673a6268ca35083ea31e28cdb11f1b86))
|
||||||
|
* rename stores folder ([2a86a88](https://github.com/wheesys/moodist/commit/2a86a88ed6a232c4a8c2a10bbb06f586361f732d))
|
||||||
|
* reorder menu items ([ae0cbf1](https://github.com/wheesys/moodist/commit/ae0cbf1aa3392ae775bfee9404c21ed7c145166e))
|
||||||
|
* rewrite menu with floating ui ([8beb42c](https://github.com/wheesys/moodist/commit/8beb42cb1b92c99aa9656b35cd7d82094e5baf72))
|
||||||
|
* rewrite timer logic ([7c57fb6](https://github.com/wheesys/moodist/commit/7c57fb686b50fa106ad0663a44f4831295d235c3))
|
||||||
|
* separate sounds ([a1ea9a1](https://github.com/wheesys/moodist/commit/a1ea9a19e64f062c1d63ecef7fb200fbba063fe4))
|
||||||
|
* separate the migration ([c35409c](https://github.com/wheesys/moodist/commit/c35409ce0a95d8376f0d84c96ed0975c9f3a1301))
|
||||||
|
* seperate buttons ([b117a4b](https://github.com/wheesys/moodist/commit/b117a4b495bed8d7b034c42a70e080bc062ad672))
|
||||||
|
* seperate common types ([bad2d31](https://github.com/wheesys/moodist/commit/bad2d31b2dfa6a1f01c1c9cd767209c2c6f58f5c))
|
||||||
|
* seperate favorite button ([4124beb](https://github.com/wheesys/moodist/commit/4124beb5b4818f1eee322fa6a4777f2e422d04ba))
|
||||||
|
* seperate irrelevant logic ([f1688cb](https://github.com/wheesys/moodist/commit/f1688cb53ccf7199759b8a60f1d05787edd05790))
|
||||||
|
* seperate motion variants ([7fce9e1](https://github.com/wheesys/moodist/commit/7fce9e1dff3dfe2b17a92872125bb29f61fee23f))
|
||||||
|
* seperate range input ([89149dc](https://github.com/wheesys/moodist/commit/89149dca78069affadb5633ba1354dd50fb616ae))
|
||||||
|
* sort interface keys ([c5240ff](https://github.com/wheesys/moodist/commit/c5240ff507fba8d979ef842ceba05b712b76220d))
|
||||||
|
* turn footer into Astro component ([a67083c](https://github.com/wheesys/moodist/commit/a67083c0e9812acc1dd71fade41a81f307669116))
|
||||||
|
* turn hero into Astro component ([77f9fcc](https://github.com/wheesys/moodist/commit/77f9fcc50e54cecb31877eaccb3a578c291f99fe))
|
||||||
|
* turn sections into Astro components ([9398ae0](https://github.com/wheesys/moodist/commit/9398ae0eddb4fac9695569a97a829bd518500363))
|
||||||
|
* use nullish operator ([9d633a9](https://github.com/wheesys/moodist/commit/9d633a963772c3444b6e9effc7920fe190b0614d))
|
||||||
|
* use scrollIntoView instead of link ([4d2645f](https://github.com/wheesys/moodist/commit/4d2645f06c846eea791f182224be0bc6e3db76dc))
|
||||||
|
* use the ID instead of index ([7658842](https://github.com/wheesys/moodist/commit/7658842324a92210a6a612c70c5479c6bb7f3c05))
|
||||||
|
* write JSDoc for libs ([fddf75c](https://github.com/wheesys/moodist/commit/fddf75cdca1f121160f9054c82a7a1ddedd6f2fa))
|
||||||
|
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* add aria label to shuffle button ([6d02cfb](https://github.com/wheesys/moodist/commit/6d02cfb134bc925b9824040307b1b40626312fd1))
|
||||||
|
* add aria labels ([85768d8](https://github.com/wheesys/moodist/commit/85768d8bca10f2732e98d138a3d83ec3116816d4))
|
||||||
|
* add audio element ([889962b](https://github.com/wheesys/moodist/commit/889962babe6e940ff283a41b145620d2a0477c70))
|
||||||
|
* add correct count to description ([81e6666](https://github.com/wheesys/moodist/commit/81e66667765879da624544c5d915c1562f2ab34c))
|
||||||
|
* add default value ([14c331a](https://github.com/wheesys/moodist/commit/14c331ab6e692ea3fcdaa056e32728f0a1cd2772))
|
||||||
|
* add delay to cipher text ([4895a72](https://github.com/wheesys/moodist/commit/4895a7266d1b7458bc09a77dd6922058a247ea98))
|
||||||
|
* add key to categories ([38c11f1](https://github.com/wheesys/moodist/commit/38c11f124e2235bc32de1e469b00ccaa22467a7e))
|
||||||
|
* add keys to filler elements ([b7c7d40](https://github.com/wheesys/moodist/commit/b7c7d40bf9c47c4a2793335e406ac4173d98a1e0))
|
||||||
|
* add media session ([81f33d9](https://github.com/wheesys/moodist/commit/81f33d9d375f63b4dd0bf58ad28a72354d85706e))
|
||||||
|
* add media session ([216b913](https://github.com/wheesys/moodist/commit/216b913ccd0a7dfe0d03575f842aac9711ef0216))
|
||||||
|
* add min-width to inputs ([dfd6c1f](https://github.com/wheesys/moodist/commit/dfd6c1fc4a41845e686fc6ee96f71b696213fe69))
|
||||||
|
* add unmute for iOS ([e422b52](https://github.com/wheesys/moodist/commit/e422b52436c7dfc0b6cf866afa2b74dc219dcf2f))
|
||||||
|
* allow empty inputs ([601ba6d](https://github.com/wheesys/moodist/commit/601ba6def7954ca8f961c461abacfb076863ae18))
|
||||||
|
* better implement shortcuts ([e77c67b](https://github.com/wheesys/moodist/commit/e77c67bc24f1831bb6de80a4335c51e5b84009ed))
|
||||||
|
* change completion condition ([1ac5286](https://github.com/wheesys/moodist/commit/1ac52861d1de651f8245d1e343307c6cf7a13dde))
|
||||||
|
* change default times ([158cffc](https://github.com/wheesys/moodist/commit/158cffca8c4b138f33e2df03e046706d2b122478))
|
||||||
|
* change default values ([34d3c72](https://github.com/wheesys/moodist/commit/34d3c72f3512664ac8f26a637b0d0be86b5499df))
|
||||||
|
* change icon path ([09c0a6c](https://github.com/wheesys/moodist/commit/09c0a6ce93f8b0f62149928218532201e0de16c5))
|
||||||
|
* change icon path ([28c3c40](https://github.com/wheesys/moodist/commit/28c3c404ad790869b13731e4c3622abe33f1dda2))
|
||||||
|
* change icon path ([8cceb6e](https://github.com/wheesys/moodist/commit/8cceb6ecd1d0183e0d5f0aeb7af4d80b2dc41b34))
|
||||||
|
* change icon path ([dc6a9e1](https://github.com/wheesys/moodist/commit/dc6a9e120a0617761c9a36a3f1268c50d4a1b7c5))
|
||||||
|
* change icon path ([c184246](https://github.com/wheesys/moodist/commit/c184246a1280e9e8cf85c77d1de8d32bf1d7592b))
|
||||||
|
* change initial value ([a7e5368](https://github.com/wheesys/moodist/commit/a7e53685918187c47d4fc2935418786b772c189e))
|
||||||
|
* change link address ([1b4d216](https://github.com/wheesys/moodist/commit/1b4d216b0813f8d336fba93c2e3bb794a988f834))
|
||||||
|
* change page title ([3bebb3e](https://github.com/wheesys/moodist/commit/3bebb3e9d259dd7f87d17f29ea85df67c5e2ada5))
|
||||||
|
* change shortcuts ([edd15f4](https://github.com/wheesys/moodist/commit/edd15f4b9a0291b9794102fbb41048de10b0fd69))
|
||||||
|
* change z-index values ([79afb8d](https://github.com/wheesys/moodist/commit/79afb8d92f9cb8e551bf101267018af1ab58815d))
|
||||||
|
* close all modals ([f025213](https://github.com/wheesys/moodist/commit/f025213ef2e8ddbc5e6603d045c8bd4d08ad8b7b))
|
||||||
|
* coffee typo ([8e02910](https://github.com/wheesys/moodist/commit/8e0291004a90e55b67a921b9ffb483b409109ae4))
|
||||||
|
* comment out toolbox section ([a8a8c36](https://github.com/wheesys/moodist/commit/a8a8c3643478d3da531e1da6c3640eb327acad3b))
|
||||||
|
* complete donation links ([e6f768a](https://github.com/wheesys/moodist/commit/e6f768a5e6dc983ae04b70f6c434fd4c13aeb506))
|
||||||
|
* **component:** update oscillators frequency on preset change ([dcc91e0](https://github.com/wheesys/moodist/commit/dcc91e038d806994382baa19b3d238da4a8ecaae))
|
||||||
|
* connect audio context to audio element ([463667c](https://github.com/wheesys/moodist/commit/463667c868371540c46c9007e686961f9a4be7e5))
|
||||||
|
* correct link ([496c831](https://github.com/wheesys/moodist/commit/496c831552442047d5556376a212698c8931b698))
|
||||||
|
* disable the sleep timer when no sound is selected ([d42eb25](https://github.com/wheesys/moodist/commit/d42eb25f7be64b5e77cd0bacd1538949d331aff7))
|
||||||
|
* fix button disabled and reset to 0 ([58bf28b](https://github.com/wheesys/moodist/commit/58bf28bb24fd12bc28f4f5e3e79058df60095fd4))
|
||||||
|
* fix icon imports ([a3eb479](https://github.com/wheesys/moodist/commit/a3eb47914024eb7b9493adae95f916be591bb748))
|
||||||
|
* fix some animation issues ([eccba87](https://github.com/wheesys/moodist/commit/eccba87557e0f444adb740e8d6488adad8a2ce42))
|
||||||
|
* fix some types ([04061e2](https://github.com/wheesys/moodist/commit/04061e23c3063279afa493a1e120817f80447840))
|
||||||
|
* fixate the binary pattern ([4996cc8](https://github.com/wheesys/moodist/commit/4996cc893c480ab77cf27a27801dba96771eadc5))
|
||||||
|
* focus on the first new sound ([54c7772](https://github.com/wheesys/moodist/commit/54c777276deccfda20bb7f027cef40d141a445b1))
|
||||||
|
* icons path ([1a1359c](https://github.com/wheesys/moodist/commit/1a1359c989268a22cfdba20f198af192726ac2ce))
|
||||||
|
* increase decimal ([a33ae45](https://github.com/wheesys/moodist/commit/a33ae450cf2c883228c76d04df8df75839c12753))
|
||||||
|
* make inputs full width ([cc77f9e](https://github.com/wheesys/moodist/commit/cc77f9e9c0b0a0d7734353486c93b4ca1509bf36))
|
||||||
|
* make share hotkey conditional ([9ad49d0](https://github.com/wheesys/moodist/commit/9ad49d021a34d47160575ae1349f866ecb26c077))
|
||||||
|
* make sound count dynamic ([f66a6ff](https://github.com/wheesys/moodist/commit/f66a6ffde770992353a5b21fe65c20fe50ab4328))
|
||||||
|
* make sound count dynamic ([79458bb](https://github.com/wheesys/moodist/commit/79458bba54189147af8b8e3f38b34c756d4bd58e))
|
||||||
|
* play sounds when starting timer if not already playing ([2e375ad](https://github.com/wheesys/moodist/commit/2e375ad40a8001ee00c9553ba46d70f3bbe6636c))
|
||||||
|
* refocus on show more button ([b955fc9](https://github.com/wheesys/moodist/commit/b955fc93f42c1bd71d5fb5ff46f9e3a039c7fe83))
|
||||||
|
* rehydrate store only on mount ([2c443d3](https://github.com/wheesys/moodist/commit/2c443d3f33d9d9f4d00ed1e99a8b092597abce97))
|
||||||
|
* relocate focus trap ([8596a00](https://github.com/wheesys/moodist/commit/8596a0014cbbac25ec93b1bb9136219a096cb21f))
|
||||||
|
* remove auto focus on load ([3b0c229](https://github.com/wheesys/moodist/commit/3b0c22968e4209fa5a37a88b69f55492615ec389))
|
||||||
|
* remove console log ([7c6f068](https://github.com/wheesys/moodist/commit/7c6f068d158cda0f8b0fe6bd6352a65002db0e25))
|
||||||
|
* remove dropdown menu item from slider ([99e6941](https://github.com/wheesys/moodist/commit/99e694161f16a3be03cbda0854687a244df42f21))
|
||||||
|
* remove extra headings ([7390a9b](https://github.com/wheesys/moodist/commit/7390a9b3de0c52163d63b42ad48a882087886b65))
|
||||||
|
* remove extra hook ([3ef4a07](https://github.com/wheesys/moodist/commit/3ef4a076a2b48911d37f75067dc60ea15dd28405))
|
||||||
|
* remove extra play calls ([e0164c3](https://github.com/wheesys/moodist/commit/e0164c362d72fea7587f67470e4d295007e5ad5e))
|
||||||
|
* remove fading ([653d309](https://github.com/wheesys/moodist/commit/653d309e64b770c2b270d2435bcd02345b686fec))
|
||||||
|
* remove fading ([d96461d](https://github.com/wheesys/moodist/commit/d96461d1ea83c72bfe651d84cf34fabc029c200e))
|
||||||
|
* remove history on favorite toggle ([190f06a](https://github.com/wheesys/moodist/commit/190f06aa78b1aff931348a65da864404b2d0f4d5))
|
||||||
|
* remove history on select ([5bd1dd3](https://github.com/wheesys/moodist/commit/5bd1dd3016cf97ad397b4371015605473c55dee8))
|
||||||
|
* remove media session ([9338b1d](https://github.com/wheesys/moodist/commit/9338b1d30a4ae4602b339bc5c5a391a462a03de2))
|
||||||
|
* remove media session ([8d01d74](https://github.com/wheesys/moodist/commit/8d01d74bd356adce782b95065fadad332ed99e48))
|
||||||
|
* remove time from tabs array ([110356b](https://github.com/wheesys/moodist/commit/110356b2da82e0f1e971ee9dc486664027d886ff))
|
||||||
|
* remove tooltip ([b634d6f](https://github.com/wheesys/moodist/commit/b634d6f3c354a51e4403374b2e3505e4f2c09351))
|
||||||
|
* remove word counter dependency ([ae3ea8c](https://github.com/wheesys/moodist/commit/ae3ea8c74f9a94ae56a0eb4b165bc36d990dea7b))
|
||||||
|
* replace generator with static silent audio ([af09607](https://github.com/wheesys/moodist/commit/af096077aed6c42d4ff77303e6f3c1d39cd87209))
|
||||||
|
* replace the animation on button ([8307657](https://github.com/wheesys/moodist/commit/8307657628c0afc7ef11c3a829344a64777dc1d3))
|
||||||
|
* reset values on cancel ([89a8308](https://github.com/wheesys/moodist/commit/89a83089c568c619fd76a28c268ad9af9913babc))
|
||||||
|
* resume audio ([8e4d053](https://github.com/wheesys/moodist/commit/8e4d0531e0e9aaf4e52b3b3a8666b74ff0c0222e))
|
||||||
|
* rotate the spinner when unselected ([cf7600e](https://github.com/wheesys/moodist/commit/cf7600e6c72d9d9638c3a9ad0513675d353422cd))
|
||||||
|
* set aria label to ID ([7e0a9af](https://github.com/wheesys/moodist/commit/7e0a9afb179d228301effe00575c2f67b426e3da))
|
||||||
|
* stringify dependency ([1a23e00](https://github.com/wheesys/moodist/commit/1a23e004a65960ce169990211f150db25762fead))
|
||||||
|
* take remvze comments into account ([0517c31](https://github.com/wheesys/moodist/commit/0517c31fc13e0b82391e18a7d16341421488f1c2))
|
||||||
|
* turn off spell check ([c66cddc](https://github.com/wheesys/moodist/commit/c66cddc4c98c19a8c0ef46ed0ee7555a30fd5059))
|
||||||
|
* typo ([5cfb9a8](https://github.com/wheesys/moodist/commit/5cfb9a8293a215b83a826c403d996d00108d49b5))
|
||||||
|
* typo in README file ([06d0dfb](https://github.com/wheesys/moodist/commit/06d0dfbe7eb0660a97c84627b1751b9a43d2e033))
|
||||||
|
* undo changes ([32da26c](https://github.com/wheesys/moodist/commit/32da26ccfc0c5bdbe031e26ea48363ea0d8a7b23))
|
||||||
|
|
||||||
|
|
||||||
|
### 🚚 Chores
|
||||||
|
|
||||||
|
* add accessibility addon ([0300df3](https://github.com/wheesys/moodist/commit/0300df3852838135245882a8aa1c59dd1a3f8af7))
|
||||||
|
* add animation to countdown timer ([73a5c21](https://github.com/wheesys/moodist/commit/73a5c21be918e1e105214078eaef8d76b168333b))
|
||||||
|
* add autodocs for button ([3f3bcdd](https://github.com/wheesys/moodist/commit/3f3bcdda21b631683028ea1c65e674973c78291d))
|
||||||
|
* add banner ([fb82117](https://github.com/wheesys/moodist/commit/fb82117742c2a0beb8937a76fcd5f313230cd418))
|
||||||
|
* add binaural beats ([f1d212a](https://github.com/wheesys/moodist/commit/f1d212abc8b69a614bbdc4a23876e2eab7cbb574))
|
||||||
|
* add Commitizen ([9d7cdde](https://github.com/wheesys/moodist/commit/9d7cddeb8b7156033a0b5f1a9012d34de60032bb))
|
||||||
|
* add Commitlint ([50341d1](https://github.com/wheesys/moodist/commit/50341d19bbed1b75d5e9fff5948c1792e5110e52))
|
||||||
|
* add contributing guide ([5899d1b](https://github.com/wheesys/moodist/commit/5899d1bbbb8eb621882e2cbacc1bc1dc9ae2ee06))
|
||||||
|
* add contribution section to README file ([b990778](https://github.com/wheesys/moodist/commit/b9907781424ccd43babd31dd1d939d2e78ba4a11))
|
||||||
|
* add divider ([3e44516](https://github.com/wheesys/moodist/commit/3e445165090472859573e69fad0fdeec87ca858f))
|
||||||
|
* add donation link to README file ([1f806c4](https://github.com/wheesys/moodist/commit/1f806c4e561d79a00850130eda09376299d85ed2))
|
||||||
|
* add Editor Config ([a7d3495](https://github.com/wheesys/moodist/commit/a7d3495fd0cec97c8b497feb1e5435f76ffc3539))
|
||||||
|
* add emojis ([d09e598](https://github.com/wheesys/moodist/commit/d09e598297fb29f005873eb5e1cfad62774fc7f0))
|
||||||
|
* add emojis ([781adcf](https://github.com/wheesys/moodist/commit/781adcf17eecea61bc03b832d8c81f3aac304848))
|
||||||
|
* add ESLint ([be2a66e](https://github.com/wheesys/moodist/commit/be2a66e207d95b35b1aeacaf2c09f5c5206f2689))
|
||||||
|
* add features to README file ([c614e3d](https://github.com/wheesys/moodist/commit/c614e3d4f54f814fe3813bc8788a23ecba5e38c8))
|
||||||
|
* add Husky ([3bed00a](https://github.com/wheesys/moodist/commit/3bed00a1eeba7675df8873a986fa533a39f8314f))
|
||||||
|
* add library sound ([309dd89](https://github.com/wheesys/moodist/commit/309dd89a8c13eb2647217c81d7fc0a82eb3ebaae))
|
||||||
|
* add licenses to README file ([dcef777](https://github.com/wheesys/moodist/commit/dcef77729579391706047ad68afd73a07acf5122))
|
||||||
|
* add link to issue ([6fe9ce8](https://github.com/wheesys/moodist/commit/6fe9ce8915600e5ec0140b5bb635ac1a2b092339))
|
||||||
|
* add link to story ([f8fb1ed](https://github.com/wheesys/moodist/commit/f8fb1ed61e071baeba7981773e4dbd1e345c29b1))
|
||||||
|
* add Lint Staged ([6cad460](https://github.com/wheesys/moodist/commit/6cad46040d15d839c56ff6efdf54f7a93bfc7611))
|
||||||
|
* add more sounds ([095e3c7](https://github.com/wheesys/moodist/commit/095e3c795ef699e9e99c5eb364badaadce8a884b))
|
||||||
|
* add more sounds ([38f6f7d](https://github.com/wheesys/moodist/commit/38f6f7dbe6898ed78e51eb3f0c7936f003ddca08))
|
||||||
|
* add more sounds ([937bf29](https://github.com/wheesys/moodist/commit/937bf29d09cbce20ea0b6b0c87879f3a7dd1d497))
|
||||||
|
* add more sounds ([e2172fd](https://github.com/wheesys/moodist/commit/e2172fd2bbd0e12a705c9efc98c72ad99d86d006))
|
||||||
|
* add more sounds ([1f12afa](https://github.com/wheesys/moodist/commit/1f12afa3943154d70145ef6adc6aeee79f7a7af3))
|
||||||
|
* add more sounds ([cd05704](https://github.com/wheesys/moodist/commit/cd05704a73ffb33aa0ccf5d789328a4cefc320f1))
|
||||||
|
* add more sounds ([01b4bdb](https://github.com/wheesys/moodist/commit/01b4bdbb572285984bcdc9bb94c1a1b6dd2630c5))
|
||||||
|
* add more sounds ([e3864be](https://github.com/wheesys/moodist/commit/e3864bede129c102ef5b7258b4688d9177dd284c))
|
||||||
|
* add more sounds ([55e7f05](https://github.com/wheesys/moodist/commit/55e7f05892f6d3200b56a7e06b371bed4b4c4554))
|
||||||
|
* add more sounds ([318e87c](https://github.com/wheesys/moodist/commit/318e87c9f1f3e2509c2b8eeb3a7f6875dd1c02fd))
|
||||||
|
* add more sounds ([eed5a13](https://github.com/wheesys/moodist/commit/eed5a1329d6fc36d1e6375feaeaf2bba26167bf5))
|
||||||
|
* add more sounds ([5a7936f](https://github.com/wheesys/moodist/commit/5a7936f11c4510886d14400e088ac0d8977a4806))
|
||||||
|
* add more sounds ([8c75f87](https://github.com/wheesys/moodist/commit/8c75f875f0e39d392f8394d67b64d3d6d4e6f4a0))
|
||||||
|
* add npm commands to README file ([8d90344](https://github.com/wheesys/moodist/commit/8d90344b26d3d52d1649074486d10c7b0bc68b66))
|
||||||
|
* add npm config ([297f7a7](https://github.com/wheesys/moodist/commit/297f7a77af1a20ac09d0faf57008d44cc2dd2178))
|
||||||
|
* add path alias ([123839d](https://github.com/wheesys/moodist/commit/123839d166948aa9283b0342ed268399eea59cf4))
|
||||||
|
* add places category ([5970012](https://github.com/wheesys/moodist/commit/5970012fa6cbd8222c2be8ce426065f928d81b2b))
|
||||||
|
* add PostCSS ([332bd49](https://github.com/wheesys/moodist/commit/332bd496f7dc9565e05a6e467d9f76831168eb12))
|
||||||
|
* add Prettier ([110359b](https://github.com/wheesys/moodist/commit/110359b9158758f7c77d09bc884ee18f686e513f))
|
||||||
|
* add robots.txt file ([6bdf28a](https://github.com/wheesys/moodist/commit/6bdf28afdcf218c02f3bddc2a55fc1b6b88ebcff))
|
||||||
|
* add Standard Version ([afc330e](https://github.com/wheesys/moodist/commit/afc330eef05bd9fa5aeb6d12dd968e2434fd19b5))
|
||||||
|
* add story for snackbar provider ([f19d151](https://github.com/wheesys/moodist/commit/f19d151f4a5292668e87abb04111e142482baf1e))
|
||||||
|
* add Stylelint ([0e5948f](https://github.com/wheesys/moodist/commit/0e5948f0588e2c545f6557e3c8971fb961464f86))
|
||||||
|
* add support section ([672988c](https://github.com/wheesys/moodist/commit/672988c36e8630fe775fdf0707bfa3e1a8956231))
|
||||||
|
* add tech stack to README file ([8e6e690](https://github.com/wheesys/moodist/commit/8e6e6900069775df5c29c53b2d2b9a00457ad8f8))
|
||||||
|
* add toolbox copy ([cfd2744](https://github.com/wheesys/moodist/commit/cfd2744e92b7a2948597a750275bf9c900248d55))
|
||||||
|
* add transport category ([c1c3945](https://github.com/wheesys/moodist/commit/c1c3945d43e84e3011de52bffa5116d58283c473))
|
||||||
|
* add washing machine sound ([7e65bb7](https://github.com/wheesys/moodist/commit/7e65bb75f9871603c30ecfc578ad109a969a2a58))
|
||||||
|
* change docker workflow ([cb4bfea](https://github.com/wheesys/moodist/commit/cb4bfea5ab4326dee17c78554f12a08ffcb9dd0e))
|
||||||
|
* change docker-compose file ([660ee07](https://github.com/wheesys/moodist/commit/660ee07a2359ec77c9d56bbe552541246e0f79c5))
|
||||||
|
* change GitHub workflow ([faf7f78](https://github.com/wheesys/moodist/commit/faf7f78b8c10cd7b3688ed5bba3d0c077c020ad2))
|
||||||
|
* change heartbeat audio ([f43a378](https://github.com/wheesys/moodist/commit/f43a378697437f671c0c33122b1c9ec5a1e173ff))
|
||||||
|
* change README banner ([c450028](https://github.com/wheesys/moodist/commit/c450028ac7e58e961204de4789231d357d129ca1))
|
||||||
|
* change README file ([85e42f3](https://github.com/wheesys/moodist/commit/85e42f3606f9fba281f2177d0dbffc86851603f9))
|
||||||
|
* comment out the banner ([c5adffb](https://github.com/wheesys/moodist/commit/c5adffb4d777eda1e2a092e382c1cac616dd60f1))
|
||||||
|
* complete tech stack ([aeccf2d](https://github.com/wheesys/moodist/commit/aeccf2dabd7528ff7984b50b7e7c7b8f46d4cef7))
|
||||||
|
* install Storybook ([65ca7e1](https://github.com/wheesys/moodist/commit/65ca7e1c942455a41f8af794861a1875bd6190be))
|
||||||
|
* refine logo ([755c442](https://github.com/wheesys/moodist/commit/755c4422635e475b8d3b0f26e3cf493a59ff3065))
|
||||||
|
* **release:** 0.0.1 ([17cbd6e](https://github.com/wheesys/moodist/commit/17cbd6eb38ef386a124c98ee0d95f5593b62b0f0))
|
||||||
|
* **release:** 1.0.0 ([df1a21f](https://github.com/wheesys/moodist/commit/df1a21f109863da9cf47e0ff05f2dfa26a545b12))
|
||||||
|
* **release:** 1.1.0 ([69b8519](https://github.com/wheesys/moodist/commit/69b85199bb0b4ba16039d267c4bf13818f77bb99))
|
||||||
|
* **release:** 1.2.0 ([b8bc9c8](https://github.com/wheesys/moodist/commit/b8bc9c8b4c80f8ee4401eea739b98e5464591d51))
|
||||||
|
* **release:** 1.3.0 ([f877e49](https://github.com/wheesys/moodist/commit/f877e49763fdae78a00d0dfe2d4240b55ab60c3e))
|
||||||
|
* **release:** 1.3.1 ([75ff67c](https://github.com/wheesys/moodist/commit/75ff67c9e635847d3da93c59c57c5b4a0414f257))
|
||||||
|
* **release:** 1.4.0 ([6dfa998](https://github.com/wheesys/moodist/commit/6dfa998ffe571886ad15e6865092870dd0db492e))
|
||||||
|
* **release:** 1.4.1 ([42bd47b](https://github.com/wheesys/moodist/commit/42bd47bbeacbe5b215152109dea39c948a878c7e))
|
||||||
|
* **release:** 1.4.2 ([73a8e03](https://github.com/wheesys/moodist/commit/73a8e03d66ec5a440c961a71bd2599b00fff6b1a))
|
||||||
|
* **release:** 1.4.3 ([4b5456a](https://github.com/wheesys/moodist/commit/4b5456a51d8aba9b93f34c2d1064854520a74778))
|
||||||
|
* **release:** 1.5.0 ([78656bb](https://github.com/wheesys/moodist/commit/78656bb61f95791e63ffb93abc11866a7a11a429))
|
||||||
|
* **release:** 1.5.1 ([c60dcc7](https://github.com/wheesys/moodist/commit/c60dcc74edbd23c7d1052ca965ac638341aa4a63))
|
||||||
|
* **release:** 2.0.0 ([87f64e6](https://github.com/wheesys/moodist/commit/87f64e6574fe2d90153d44ecf3f2e1e01f68b600))
|
||||||
|
* **release:** 2.0.1 ([df210a1](https://github.com/wheesys/moodist/commit/df210a1246d3395c7f1fa5aef8433f52e76f40ea))
|
||||||
|
* **release:** 2.1.0 ([4c8d577](https://github.com/wheesys/moodist/commit/4c8d5775274ad0573c73e30e5aae4fc87361e0e9))
|
||||||
|
* relocate underwater audio ([37bad81](https://github.com/wheesys/moodist/commit/37bad8149e1f5170426dc745322c0e65cb9a41ff))
|
||||||
|
* remove arm/v6 ([017c27f](https://github.com/wheesys/moodist/commit/017c27fb2b20402553011db8f417717dcca6d447))
|
||||||
|
* remove arm/v7 ([fa9711a](https://github.com/wheesys/moodist/commit/fa9711a1e09e6e979b420556160c3cd69a8c3775))
|
||||||
|
* remove extra sound ([9ad1630](https://github.com/wheesys/moodist/commit/9ad16306cf534ff27e99a537589c0d3c2c483d81))
|
||||||
|
* remove heartbeat audio ([121a8f2](https://github.com/wheesys/moodist/commit/121a8f204c6b61490a7ab0e732779031278e6e8c))
|
||||||
|
* update banner ([a0a7f94](https://github.com/wheesys/moodist/commit/a0a7f94c3328c65d4fc756ca52455461a05657ab))
|
||||||
|
* update banner ([2f994c6](https://github.com/wheesys/moodist/commit/2f994c6094ad1948c14346badbc4462ae7782904))
|
||||||
|
* update GitHub action ([ee60613](https://github.com/wheesys/moodist/commit/ee606139a80121fd6ee1b8233f82af994c4e1178))
|
||||||
|
* update logos ([7a47282](https://github.com/wheesys/moodist/commit/7a472821652d1359126568836b3040ce1fa454c5))
|
||||||
|
* update logos ([2b85b27](https://github.com/wheesys/moodist/commit/2b85b276eb11d862bf1abd1e6f099740d9b85c10))
|
||||||
|
* update README file ([a9fe7f7](https://github.com/wheesys/moodist/commit/a9fe7f7b4f9ca91704d76a314e3c3368fbc4f1cf))
|
||||||
|
* update README file ([629f0a5](https://github.com/wheesys/moodist/commit/629f0a514ec1ac96f1874b8d6a466bf05577cd4d))
|
||||||
|
* update README file ([de49d37](https://github.com/wheesys/moodist/commit/de49d37f08a90523e9b9b298189b10103e833e15))
|
||||||
|
* update README file ([7cb0f1c](https://github.com/wheesys/moodist/commit/7cb0f1c7521775578bb6d794f43d04aa0da2fcba))
|
||||||
|
* update README file ([dc139e4](https://github.com/wheesys/moodist/commit/dc139e41e628a75756cea99bdca0252267541014))
|
||||||
|
* update README file ([954a1b1](https://github.com/wheesys/moodist/commit/954a1b1ce2c9f334d349fcd140ec18a5c78b7dd7))
|
||||||
|
* update README file ([383f898](https://github.com/wheesys/moodist/commit/383f8981250d2fe646b4f642b36b28b3dbdd178f))
|
||||||
|
* update the logo ([348fc1e](https://github.com/wheesys/moodist/commit/348fc1e8c4561481e5ad1d4528e8ee480d0e2fb4))
|
||||||
|
* upgrade Astro ([72fa516](https://github.com/wheesys/moodist/commit/72fa516316cf1077cf5ab09bc59b76de147c6d38))
|
||||||
|
* write story for button ([603d318](https://github.com/wheesys/moodist/commit/603d318e68ec786cfbeaad57835a812ca8918fb9))
|
||||||
|
|
||||||
|
|
||||||
|
### 💄 Styling
|
||||||
|
|
||||||
|
* add animation on active ([50687c9](https://github.com/wheesys/moodist/commit/50687c97ca483f4de3ee7633d333dfcb4def0c4d))
|
||||||
|
* add animation to modal ([7823dc7](https://github.com/wheesys/moodist/commit/7823dc7ff473278ef8ee401e69796c17b33da794))
|
||||||
|
* add animation to more/less button ([b849b3a](https://github.com/wheesys/moodist/commit/b849b3aecd6178114b3b27a2daa014b0795ddf42))
|
||||||
|
* add animation to presets ([787a9b6](https://github.com/wheesys/moodist/commit/787a9b60b51334ec2a7423d489f71c305661039e))
|
||||||
|
* add base and global styles ([05d68e4](https://github.com/wheesys/moodist/commit/05d68e4de6f55ebbc08817ed553f7760f570208b))
|
||||||
|
* add binary pattern ([ba3cd5c](https://github.com/wheesys/moodist/commit/ba3cd5ca5be8435f32b93d5a499e37388340bff8))
|
||||||
|
* add effect to about ([1a499be](https://github.com/wheesys/moodist/commit/1a499be2446730d5333dd0d0d6a06bbd47564979))
|
||||||
|
* add focus state ([af075b3](https://github.com/wheesys/moodist/commit/af075b32e64a6ab923d60282558250b79cc12da3))
|
||||||
|
* add gradient background ([77fed03](https://github.com/wheesys/moodist/commit/77fed0308ad55ca32f07b4f30e7a7936063d842a))
|
||||||
|
* add gradient line ([ea722ea](https://github.com/wheesys/moodist/commit/ea722eabd24cb966c65fa45d41f55e1e1a049939))
|
||||||
|
* add hover state to button ([ebb35de](https://github.com/wheesys/moodist/commit/ebb35deaf982348ccea49e3830af77521fbed207))
|
||||||
|
* add hover states ([2c74dd0](https://github.com/wheesys/moodist/commit/2c74dd0d604af86f99edcba2eb573641ac2010fd))
|
||||||
|
* add icon to menu items ([131ab29](https://github.com/wheesys/moodist/commit/131ab296215812e45a0c60486d75683f3de25d16))
|
||||||
|
* add line to titles ([ec1def0](https://github.com/wheesys/moodist/commit/ec1def041934d8a9f98084299a0606c5690ef23d))
|
||||||
|
* add margin to donate section ([6d30a01](https://github.com/wheesys/moodist/commit/6d30a0123e0feb509b6c560f405b98d20a89467a))
|
||||||
|
* add min width ([18987cc](https://github.com/wheesys/moodist/commit/18987cc33997c7b010aea2d4f1546ddcabe1a46b))
|
||||||
|
* add more icons ([41c5ae5](https://github.com/wheesys/moodist/commit/41c5ae5db8e72f15f5cc1b7501f397239ba9368a))
|
||||||
|
* add new font weight for links ([287d7b3](https://github.com/wheesys/moodist/commit/287d7b33fb107e81034a17a60e1cd6cd5d40d935))
|
||||||
|
* add outline for better accessibility ([e7d7a37](https://github.com/wheesys/moodist/commit/e7d7a37a12dd79f12933b3ffa91fe6e0557c4f9e))
|
||||||
|
* add outlines to toolbar buttons ([a3cfbb9](https://github.com/wheesys/moodist/commit/a3cfbb98db8a70d8055e86071a4dab4d2b7ab952))
|
||||||
|
* add pattern ([69eb883](https://github.com/wheesys/moodist/commit/69eb8832dae026706f76ba21a74fcb248ba4309d))
|
||||||
|
* add polka dot pattern ([dc22b51](https://github.com/wheesys/moodist/commit/dc22b51548f0d6830fcee79eebd75650f3f19dc1))
|
||||||
|
* add scroll lock in modals ([def69de](https://github.com/wheesys/moodist/commit/def69de6e4e11e373280c90f93af0b0369b85ac8))
|
||||||
|
* add shine to donation button ([ac24da2](https://github.com/wheesys/moodist/commit/ac24da294008a34c49dc3502374f1fcf55db5be8))
|
||||||
|
* add smooth transition ([3b33e09](https://github.com/wheesys/moodist/commit/3b33e095479340496a7a11b057daef029f40b70a))
|
||||||
|
* add smooth transition ([e7fc951](https://github.com/wheesys/moodist/commit/e7fc9513109ae48ce407745549085c9449cf3324))
|
||||||
|
* add style to generators ([5c53678](https://github.com/wheesys/moodist/commit/5c536786ea64e9722a67289ab2d7e56e7a259404))
|
||||||
|
* add text animation ([7810d21](https://github.com/wheesys/moodist/commit/7810d212259cfe19befafab33d51110126089a83))
|
||||||
|
* add theme color ([6de1394](https://github.com/wheesys/moodist/commit/6de1394628ccb6b58aec02bcd164e56e9ca0f30a))
|
||||||
|
* add title to timer ([a3c384d](https://github.com/wheesys/moodist/commit/a3c384d1054b81e056265eecd9344496c9b0b5ce))
|
||||||
|
* add wrap balancer to desc ([276639b](https://github.com/wheesys/moodist/commit/276639b0d3a70ead87dc61e2c8cb7cd621261c3e))
|
||||||
|
* better line alignment ([1f24812](https://github.com/wheesys/moodist/commit/1f24812efa3b64fdbfc794bcb546226cc2ef07d4))
|
||||||
|
* better outlines for accessibility ([3c8d75b](https://github.com/wheesys/moodist/commit/3c8d75b018e657b2c2e13d967b90b635360225fe))
|
||||||
|
* center icons ([1cf9a85](https://github.com/wheesys/moodist/commit/1cf9a85e13d50d3c5335dfb78fa57543ce6fda44))
|
||||||
|
* change border color ([85b627e](https://github.com/wheesys/moodist/commit/85b627ecb96a4f52ecacdb53ed4484c050adba5e))
|
||||||
|
* change border radius ([5c9a2aa](https://github.com/wheesys/moodist/commit/5c9a2aa23aa04f9386e7d7ac9a20759a2ed87acc))
|
||||||
|
* change border to shadow ([a53800c](https://github.com/wheesys/moodist/commit/a53800c6b194e7520d2e7ee13c5e00f77db9f5f7))
|
||||||
|
* change button animation ([6983559](https://github.com/wheesys/moodist/commit/6983559032d731ad6264ad56f0786b1a84f7cf4e))
|
||||||
|
* change button animation ([c44a863](https://github.com/wheesys/moodist/commit/c44a86361ebf3a77d68148564a2983e60b522c29))
|
||||||
|
* change button style ([8a79ccf](https://github.com/wheesys/moodist/commit/8a79ccf018cd7ee86b27b8bd187975376abea953))
|
||||||
|
* change button style ([8efb1ce](https://github.com/wheesys/moodist/commit/8efb1cee00ec0e0dcd9677729d9136ca8d69073f))
|
||||||
|
* change button styles ([e674738](https://github.com/wheesys/moodist/commit/e674738ce70d1c240c57433824a0b509f24deb88))
|
||||||
|
* change copy ([c51acd6](https://github.com/wheesys/moodist/commit/c51acd62618cc705902dc01f0574a2c9124264c5))
|
||||||
|
* change copy ([6242308](https://github.com/wheesys/moodist/commit/624230843c3328fdfb42e0e2f23084cef4dec614))
|
||||||
|
* change cursor ([6ac65c1](https://github.com/wheesys/moodist/commit/6ac65c1948ad93fed012a8203fc8c6c2b2898b5b))
|
||||||
|
* change description ([9208663](https://github.com/wheesys/moodist/commit/9208663050c340fdecf486b4835d30353852fd22))
|
||||||
|
* change description ([8930e7b](https://github.com/wheesys/moodist/commit/8930e7b76abffafd0ace5926de6c1d3f7629febd))
|
||||||
|
* change dividers ([8471a3c](https://github.com/wheesys/moodist/commit/8471a3ca493b0c738ed7de900e82403f0b1ce2b7))
|
||||||
|
* change favicon ([a82dc3f](https://github.com/wheesys/moodist/commit/a82dc3f36af098071b6be09491e9e25bda190b74))
|
||||||
|
* change font path ([43ba975](https://github.com/wheesys/moodist/commit/43ba9754085d7a710d3d629e70f873b16f267507))
|
||||||
|
* change gradient ([9e38a8f](https://github.com/wheesys/moodist/commit/9e38a8fd7da2d68c8c04c4c21cbda6444e9e247b))
|
||||||
|
* change icon backgrounds ([ef825ca](https://github.com/wheesys/moodist/commit/ef825cae68f3cd4ef58016212a45820d3b272f96))
|
||||||
|
* change icon color on selection ([e6abca6](https://github.com/wheesys/moodist/commit/e6abca61fe9eb36ca6968339a4cb67beeb5f8fdc))
|
||||||
|
* change icons ([2e1fce4](https://github.com/wheesys/moodist/commit/2e1fce46695b693c4b6aa11f18506e2f2cd9bb59))
|
||||||
|
* change input styles ([8fe90da](https://github.com/wheesys/moodist/commit/8fe90daf1e96def534c62f3241438cf62ea00b18))
|
||||||
|
* change item order ([9198315](https://github.com/wheesys/moodist/commit/919831538fea639eb60c8fb84fa93a79ec2cd9c5))
|
||||||
|
* change label cursor ([15953ef](https://github.com/wheesys/moodist/commit/15953ef8565a27da2b41330753fbc40931987aa7))
|
||||||
|
* change like color ([d8c9806](https://github.com/wheesys/moodist/commit/d8c9806a1964042b787baabf43e2852bab23dcfa))
|
||||||
|
* change logo ([4a92d2f](https://github.com/wheesys/moodist/commit/4a92d2f1c12c12b4166500149937be51e6442f71))
|
||||||
|
* change logo ([9f702db](https://github.com/wheesys/moodist/commit/9f702dbfa74b524b4553bd1686532bc7d35d9985))
|
||||||
|
* change logo color ([4b01501](https://github.com/wheesys/moodist/commit/4b015016e7c531afc3f3b1f51d62bf96232e3ea8))
|
||||||
|
* change notice ([9d1d8f8](https://github.com/wheesys/moodist/commit/9d1d8f80359097b9122673564d3d57c0827ff3db))
|
||||||
|
* change other assets ([11e0ba2](https://github.com/wheesys/moodist/commit/11e0ba2f938fc08984e4acba1ba6b4ac3239cacf))
|
||||||
|
* change outline color ([6f9c941](https://github.com/wheesys/moodist/commit/6f9c941a8749f2b006c3f352e0a047c5dc1d3d21))
|
||||||
|
* change pattern ([f3e7224](https://github.com/wheesys/moodist/commit/f3e72242670317d938cc8d9619729be95df0f4f0))
|
||||||
|
* change position for toolbar ([e7fd84b](https://github.com/wheesys/moodist/commit/e7fd84bd4e8637e34eb0a59e97fd9c49875f8776))
|
||||||
|
* change primary color ([ed9a027](https://github.com/wheesys/moodist/commit/ed9a0271f7c49b499ab07487072cfd7bab5277db))
|
||||||
|
* change reason copy ([69c4ec1](https://github.com/wheesys/moodist/commit/69c4ec150849a15e2aa222ac4b6f2982cc9536df))
|
||||||
|
* change snackbar styles ([1e5bda7](https://github.com/wheesys/moodist/commit/1e5bda707cc202407b179e2d1b95dec34bfe9420))
|
||||||
|
* change sound counter ([e1b9a17](https://github.com/wheesys/moodist/commit/e1b9a1736c1d11827faf900838769194364afbd3))
|
||||||
|
* change sound counter ([00fc5f3](https://github.com/wheesys/moodist/commit/00fc5f3a872be51eb875744e254c75ea58e93281))
|
||||||
|
* change spacing ([cc26f68](https://github.com/wheesys/moodist/commit/cc26f68097bd137bea1f62a9eba566844b1cb069))
|
||||||
|
* change tagline ([f3603e8](https://github.com/wheesys/moodist/commit/f3603e84318a9b69145ae69d3aa02447ed1235e6))
|
||||||
|
* change the about style ([4515aa8](https://github.com/wheesys/moodist/commit/4515aa8e7a7f6d0fbc839625f74f0583e1a20d18))
|
||||||
|
* change the pattern slightly ([5fc3e7e](https://github.com/wheesys/moodist/commit/5fc3e7e5d048cb4aa189683d147b181fdf2a94b6))
|
||||||
|
* change theme ([bd517f8](https://github.com/wheesys/moodist/commit/bd517f88c01202eb7e7e5acf70bf4af2e6f91d75))
|
||||||
|
* change to primary color ([c8e5122](https://github.com/wheesys/moodist/commit/c8e51226e57bfa72ad91318de25fc5f9b5751634))
|
||||||
|
* change unselected style ([586e502](https://github.com/wheesys/moodist/commit/586e502c3cc81314bc1e83f4e088a0d9289390fc))
|
||||||
|
* decorate paragraphs ([1a6ecd8](https://github.com/wheesys/moodist/commit/1a6ecd82abe89e1686538c42b31826ccc8a43b2d))
|
||||||
|
* decrease background opacity ([a071ba0](https://github.com/wheesys/moodist/commit/a071ba04c7e86b3056049492386516b58c4210c0))
|
||||||
|
* decrease dots ([182a8c7](https://github.com/wheesys/moodist/commit/182a8c7aadc9a253261c56ae7faf8ac5c3c82707))
|
||||||
|
* decrease dots ([0ad4bb7](https://github.com/wheesys/moodist/commit/0ad4bb72e15e8f7d52e7d4b036b71fdb837e3554))
|
||||||
|
* decrease dots ([2b84374](https://github.com/wheesys/moodist/commit/2b843747b41111908bbe5fb8f5abc407114e4f15))
|
||||||
|
* decrease font size ([69cb45b](https://github.com/wheesys/moodist/commit/69cb45bff74d36f654d521e9e7f6b2149b01d630))
|
||||||
|
* decrease gradient shine ([8f58794](https://github.com/wheesys/moodist/commit/8f587944fd1ad5e11bb6bc3afc7e9380afa43a6c))
|
||||||
|
* decrease margin ([d700195](https://github.com/wheesys/moodist/commit/d7001952f9ce323d746118583e0b34e001a8a517))
|
||||||
|
* decrease opacity ([56802b6](https://github.com/wheesys/moodist/commit/56802b67f2db751dbede9aa58b2158dd250a1420))
|
||||||
|
* decrease opacity ([2078648](https://github.com/wheesys/moodist/commit/2078648c6687aab79a725490335b8ae751f3d4ee))
|
||||||
|
* decrease opacity ([82e4ea7](https://github.com/wheesys/moodist/commit/82e4ea72f4ddb8658824813a64e14970400b1820))
|
||||||
|
* decrease padding ([98d2f76](https://github.com/wheesys/moodist/commit/98d2f764383eaba0dd6163b93826459b614b67d2))
|
||||||
|
* decrease scale animation ([7e668e5](https://github.com/wheesys/moodist/commit/7e668e5b393c7df71bec8bf11696acbae22d70e4))
|
||||||
|
* decrease shine ([0f32de3](https://github.com/wheesys/moodist/commit/0f32de3c0ca9f553c8917b786ddcdfb6feccf0c8))
|
||||||
|
* fix margins ([99775b7](https://github.com/wheesys/moodist/commit/99775b7c6487b009bbf87fbd834ed8730508d1ce))
|
||||||
|
* fix pointer event ([12d3255](https://github.com/wheesys/moodist/commit/12d3255d57083ff72ae919b6161922620dc1d6e2))
|
||||||
|
* fix snackbar pointer ([14c9e88](https://github.com/wheesys/moodist/commit/14c9e88bfbef4b68dce0a1a8e570c1a9d9894dfd))
|
||||||
|
* fix tooltip z-index ([fb061c3](https://github.com/wheesys/moodist/commit/fb061c3d66d3fa7c3fce63bae1e04e502fcbb891))
|
||||||
|
* fix z-index ([fa71709](https://github.com/wheesys/moodist/commit/fa71709f897cc2b7a5ba03dbc1cb60a3198bf9f4))
|
||||||
|
* hide about and features ([400ea0a](https://github.com/wheesys/moodist/commit/400ea0aeafe48587fe8596d1b5fe90755995d1c3))
|
||||||
|
* hide features ([9028675](https://github.com/wheesys/moodist/commit/902867505743dd1dcd3f1e2afef010a186586615))
|
||||||
|
* increase border radius ([e2bb4dd](https://github.com/wheesys/moodist/commit/e2bb4dd55fbf17e777ddbb6825e400bd023da328))
|
||||||
|
* increase dots ([405fccc](https://github.com/wheesys/moodist/commit/405fcccd95d9ce720f0731e8040006bd1d9b8bd2))
|
||||||
|
* increase line height ([a179c09](https://github.com/wheesys/moodist/commit/a179c09d0c637d33d310960dbf3e92af4b5c526b))
|
||||||
|
* increase menu width ([96ca376](https://github.com/wheesys/moodist/commit/96ca3768856806bbe761e773d5ef626dcd12c968))
|
||||||
|
* increase opacity ([882d440](https://github.com/wheesys/moodist/commit/882d44079cfba8c7536c3713f0abeaa075ecb069))
|
||||||
|
* increase padding ([8e50013](https://github.com/wheesys/moodist/commit/8e500136cec6ba5580146306f25a5956aa3a2a4b))
|
||||||
|
* increase padding ([eedbf53](https://github.com/wheesys/moodist/commit/eedbf53e0e07ba75161e9f397dc0554204bc004a))
|
||||||
|
* increase pattern opacity ([5b83710](https://github.com/wheesys/moodist/commit/5b83710c75515352b88c7bd361694d3067cb08fb))
|
||||||
|
* increase sounds per row ([cd8ec5e](https://github.com/wheesys/moodist/commit/cd8ec5e8649f8808d0a89a74c1426b92628efbc7))
|
||||||
|
* increase text color ([d11a6ab](https://github.com/wheesys/moodist/commit/d11a6ab062061da5809ebddd6eb39b17c2cd3862))
|
||||||
|
* lower opacity ([d4cc24e](https://github.com/wheesys/moodist/commit/d4cc24e468230df51e5676abbd828b2f2edd97f3))
|
||||||
|
* minor change ([302a71c](https://github.com/wheesys/moodist/commit/302a71cdc6472dd29d75372ddc6a3ef214dd68c4))
|
||||||
|
* minor change ([b73fd0b](https://github.com/wheesys/moodist/commit/b73fd0b16e57140350d0743aa98ec6933bdc5c64))
|
||||||
|
* minor changes ([536db4c](https://github.com/wheesys/moodist/commit/536db4cd156cb391a0b1ef9bf3e4fbbac06ccc11))
|
||||||
|
* minor changes ([7f3ac26](https://github.com/wheesys/moodist/commit/7f3ac26b982e629eef891f706004eca5f14e11c4))
|
||||||
|
* minor changes ([4cc8597](https://github.com/wheesys/moodist/commit/4cc85975e54cfd8195596e017c351a227184806b))
|
||||||
|
* minor changes ([b27f24d](https://github.com/wheesys/moodist/commit/b27f24d37484a04495a043170ccaf4b4923b31ac))
|
||||||
|
* minor changes ([a29e2c2](https://github.com/wheesys/moodist/commit/a29e2c20e4bac276495b409b20a6ffaa079122e2))
|
||||||
|
* relocate the play button ([403a755](https://github.com/wheesys/moodist/commit/403a755ca7a9d93ef6940d1954fcde058505c1b8))
|
||||||
|
* remove animation on change ([41845ff](https://github.com/wheesys/moodist/commit/41845ffe5e282c07b3c4cdea56607f1668c636bd))
|
||||||
|
* remove animations ([28abc16](https://github.com/wheesys/moodist/commit/28abc16b9cbbc3986f7fb3feb17e57e553cda5dd))
|
||||||
|
* remove cipher animation ([3feb9c1](https://github.com/wheesys/moodist/commit/3feb9c1a09b52a35d79cebb7ece54989e9faf481))
|
||||||
|
* remove extra colors ([38f05a3](https://github.com/wheesys/moodist/commit/38f05a3e757ab0c8d91b1f84938bfb8443450769))
|
||||||
|
* remove gradient line ([de03cac](https://github.com/wheesys/moodist/commit/de03cac6b374e836da65d00b7fe732bf17600554))
|
||||||
|
* remove gradient line ([6720e86](https://github.com/wheesys/moodist/commit/6720e86a0af14c8c05d73f305ee12664f3b264b7))
|
||||||
|
* remove hero pattern ([8f36c86](https://github.com/wheesys/moodist/commit/8f36c863d7f7489979691475947dbc8f29f26b39))
|
||||||
|
* remove layout animation ([ef952d0](https://github.com/wheesys/moodist/commit/ef952d0a03b2cc3490b65535f1c5707b6578836d))
|
||||||
|
* remove layout animation ([efd6f99](https://github.com/wheesys/moodist/commit/efd6f9941d1483e6a6df8db861ba221084a1f298))
|
||||||
|
* remove opacity effect on disabled ([4266557](https://github.com/wheesys/moodist/commit/4266557366977534a4fba24922904ac51aaae74d))
|
||||||
|
* remove pointer event ([c12ef12](https://github.com/wheesys/moodist/commit/c12ef12b79c6db93c457b77f4bfccb2848dc8067))
|
||||||
|
* reorder menu items ([0052b91](https://github.com/wheesys/moodist/commit/0052b917a817ca7f83fe23521077d99ae78e81cd))
|
||||||
|
* reverse gradient line ([87f3a2b](https://github.com/wheesys/moodist/commit/87f3a2b51104d635dcaf6e48281b99193a7d931a))
|
||||||
|
* revert changes ([341a896](https://github.com/wheesys/moodist/commit/341a896924a6ace70114ad2ae3349f8934a329ba))
|
||||||
|
* revert pattern ([5916e86](https://github.com/wheesys/moodist/commit/5916e86d3c6de9912b2c9bd490fa04cd9a0958dd))
|
||||||
|
* show about and features ([37505a6](https://github.com/wheesys/moodist/commit/37505a6b3f86919ac04b69519e56ddbaf5234843))
|
||||||
|
* widen the container ([7ec7ea7](https://github.com/wheesys/moodist/commit/7ec7ea74d53db85cffa3af646c03270793453009))
|
||||||
|
* widen the container ([e7c786f](https://github.com/wheesys/moodist/commit/e7c786f25986436606fa723441338588a84b00b3))
|
||||||
|
* widen the menu ([37a0736](https://github.com/wheesys/moodist/commit/37a0736a0e7edd09c33940099c884e5b48afbbf1))
|
||||||
|
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
|
||||||
|
* 完整实现中英文双语支持并修复所有声音翻译问题 ([65958f8](https://github.com/wheesys/moodist/commit/65958f84827dc2fc02f74dbc2b8a6174e90c1586))
|
||||||
|
* add about section ([d725d59](https://github.com/wheesys/moodist/commit/d725d597034ead0bb63c5f0667b64ce459477662))
|
||||||
|
* add about section ([4e84419](https://github.com/wheesys/moodist/commit/4e84419ab19f4f0c129a76a91be194bbab7f6b11))
|
||||||
|
* add active indicator for sleep timer ([82d8240](https://github.com/wheesys/moodist/commit/82d8240b9708a9d522f67ae305dc44e004ced6de))
|
||||||
|
* add active indicators ([240fd9c](https://github.com/wheesys/moodist/commit/240fd9c6e05c7385c0de92b8b57776988b902fae))
|
||||||
|
* add alarm for pomodoro timer ([0eb47ba](https://github.com/wheesys/moodist/commit/0eb47ba2e1accaee7dd7d6114ca9a69cbc0656c4))
|
||||||
|
* add animation for labels ([48a85b2](https://github.com/wheesys/moodist/commit/48a85b26016a8f3cc934e1b2298b0d897ffd9b43))
|
||||||
|
* add animation to menu box ([17027e2](https://github.com/wheesys/moodist/commit/17027e299bb9bf958aebaf735c40e7664ad71e8b))
|
||||||
|
* add aria-disabled to play button ([f390f38](https://github.com/wheesys/moodist/commit/f390f3801604c49799078298637ea63a06eb9721))
|
||||||
|
* add auto pause to play button ([7c901b2](https://github.com/wheesys/moodist/commit/7c901b2bdc525d02b80a0c42eb2f81f766947ca3))
|
||||||
|
* add auto play on select ([17d1b23](https://github.com/wheesys/moodist/commit/17d1b23c8f1a6c717d846c12d38945e7d3b47be1))
|
||||||
|
* add autofocus for note ([24a53c8](https://github.com/wheesys/moodist/commit/24a53c81dffc1a4ba0b46244a87fb49bf562e755))
|
||||||
|
* add basic animations with Framer Motion ([fa7b90e](https://github.com/wheesys/moodist/commit/fa7b90eeec5b697446fa5871f8b499a85d0d150f))
|
||||||
|
* add basic audio player ([5a7a58e](https://github.com/wheesys/moodist/commit/5a7a58e883fbb0122d8d6e2c777049a8fc0d9609))
|
||||||
|
* add basic categories ([8d7e4d2](https://github.com/wheesys/moodist/commit/8d7e4d26fd7b53a16f7ce39551b31484eefbe461))
|
||||||
|
* add basic fading effect ([6ce766a](https://github.com/wheesys/moodist/commit/6ce766af47389e9e3e57226b956b8593a4af06d4))
|
||||||
|
* add basic form ([c272914](https://github.com/wheesys/moodist/commit/c27291441625eb6528b28f55af3f88e1debd8a55))
|
||||||
|
* add basic pomodoro structure ([9f7de33](https://github.com/wheesys/moodist/commit/9f7de336e5add254b40c5694fc4c619ee00602ba))
|
||||||
|
* add basic sound components ([4adb8bf](https://github.com/wheesys/moodist/commit/4adb8bfdbc86a475d59e1d636927539592ec4b6c))
|
||||||
|
* add basic sounds for prototyping ([5791346](https://github.com/wheesys/moodist/commit/5791346a881a9f451b967f782257317d8fb04d63))
|
||||||
|
* add better aria labels ([98e5021](https://github.com/wheesys/moodist/commit/98e5021f561458465a544e2b86194e7f52a62169))
|
||||||
|
* add better aria labels ([9774532](https://github.com/wheesys/moodist/commit/977453230847790de86aa7721c059d4fe3ec7eeb))
|
||||||
|
* add binary animation ([699f49b](https://github.com/wheesys/moodist/commit/699f49bfa33420698962b56db23b49c8e14bb354))
|
||||||
|
* add binaural beat generator without styles ([f40e820](https://github.com/wheesys/moodist/commit/f40e8206f8126f1988e0e39ca522ac3c5eb8139f))
|
||||||
|
* add breathing exercise ([1f2b6b9](https://github.com/wheesys/moodist/commit/1f2b6b952c65c04828f19506134d783a7491df23))
|
||||||
|
* add breathing exercise shortcut ([a3b794d](https://github.com/wheesys/moodist/commit/a3b794d9748d4a9877e5727269178f207fbc03d5))
|
||||||
|
* add breathing exercises and other tools ([eee7553](https://github.com/wheesys/moodist/commit/eee755378a14d93d1363e8c265a908d50b9cc332))
|
||||||
|
* add breathing exercises tool ([27f2578](https://github.com/wheesys/moodist/commit/27f25785e1cfc0482d7ddd625ac1219fd5bb6863))
|
||||||
|
* add cipher animation ([29bebb3](https://github.com/wheesys/moodist/commit/29bebb3ec74d969fb42968696e470db00a07766e))
|
||||||
|
* add close event for modals ([af92b1e](https://github.com/wheesys/moodist/commit/af92b1ed902b4bf221e53315ba431f834915d7c2))
|
||||||
|
* add color noise ([7363e8d](https://github.com/wheesys/moodist/commit/7363e8d51a347adf3c53cbef9ec3e181912ecc6b))
|
||||||
|
* add comprehensive Docker deployment support and Chinese documentation ([a8718df](https://github.com/wheesys/moodist/commit/a8718df8d2c3c70325165c4057239590a470cb61))
|
||||||
|
* add confetti ([ace0d6e](https://github.com/wheesys/moodist/commit/ace0d6eeccc65c96275a24c8a96e63988cf76134))
|
||||||
|
* add controls to pomodoro ([7ed016d](https://github.com/wheesys/moodist/commit/7ed016d8558a73d8d2bf05823cf80633882c1d69))
|
||||||
|
* add copy for productivity toolbox ([3205145](https://github.com/wheesys/moodist/commit/3205145d5425c7a7a8660b46aa9de0b273a04ff0))
|
||||||
|
* add countdown timer ([edd53d8](https://github.com/wheesys/moodist/commit/edd53d8102871d53b0a11eaa9bae7323f874d988))
|
||||||
|
* add countdown timer button ([5f066a4](https://github.com/wheesys/moodist/commit/5f066a4eff91996b165de3b86549fffe93800d38))
|
||||||
|
* add countdown timer structure ([c5657d0](https://github.com/wheesys/moodist/commit/c5657d06425aea84a4ba9a4b2f48e312be8b0271))
|
||||||
|
* add counter to notepad ([2424523](https://github.com/wheesys/moodist/commit/24245235b14f9d59f86d2e988657a45a8a6d1eb7))
|
||||||
|
* add CTA button ([0e12a52](https://github.com/wheesys/moodist/commit/0e12a5203ef836bd262b3d4cc02aaeb9048f461e))
|
||||||
|
* add custom checkbox ([cb340c5](https://github.com/wheesys/moodist/commit/cb340c53a39917722137a8ee05b779af04a1203d))
|
||||||
|
* add custom presets ([2484e01](https://github.com/wheesys/moodist/commit/2484e01273cf5e7ef5b44395cab26095891118fd))
|
||||||
|
* add custom slider ([3b77c12](https://github.com/wheesys/moodist/commit/3b77c12114e5e37c0a3a17c945a0e69e034a35a4))
|
||||||
|
* add deep merge to Zustand Persist ([01f4031](https://github.com/wheesys/moodist/commit/01f40318124ad1e6e09b1f0572f623900192ba9d))
|
||||||
|
* add description for sleep timer ([77e2ec5](https://github.com/wheesys/moodist/commit/77e2ec5e798771b7719b36882bc68c10265c06f6))
|
||||||
|
* add desktop notice ([07f37ef](https://github.com/wheesys/moodist/commit/07f37ef17f8be893d3ceba8fbe4427a9ecda5c15))
|
||||||
|
* add disabled state ([ff26597](https://github.com/wheesys/moodist/commit/ff26597d22d444d18d2874a5c278eccc288972de))
|
||||||
|
* add dividers to menu items ([408734d](https://github.com/wheesys/moodist/commit/408734d49fd89fbd47d29527c03927e49c834f30))
|
||||||
|
* add donate item ([f12ca48](https://github.com/wheesys/moodist/commit/f12ca4806c9279f69f298bef770f8cac69a0860a))
|
||||||
|
* add donate section ([d449c29](https://github.com/wheesys/moodist/commit/d449c29321024a43517e92cc59223b4b22fe2e82))
|
||||||
|
* add donation header ([17b4f25](https://github.com/wheesys/moodist/commit/17b4f25ff10e09a917203e67cf963cac8358de1a))
|
||||||
|
* add done counter ([aa8161a](https://github.com/wheesys/moodist/commit/aa8161aac5eb238048c713500a091e9af1c98e6a))
|
||||||
|
* add fade in/out ([663cb92](https://github.com/wheesys/moodist/commit/663cb921350c083f1991665d147a3820bcdd9321))
|
||||||
|
* add fading to intro and outro ([5467bbb](https://github.com/wheesys/moodist/commit/5467bbbc2437a5504e157122a995ad7a565ff0b8))
|
||||||
|
* add features section ([e4e332a](https://github.com/wheesys/moodist/commit/e4e332ad75aad1a58fd97acb71660b8dec9dfa09))
|
||||||
|
* add footer component ([262bb1a](https://github.com/wheesys/moodist/commit/262bb1a9c6153a53e259e5bd9123b8035bd6b6d1))
|
||||||
|
* add form to sleep timer ([9d458fb](https://github.com/wheesys/moodist/commit/9d458fb60e8b84210f492541bab2c5dc94adcc8b))
|
||||||
|
* add global volume ([3b829fc](https://github.com/wheesys/moodist/commit/3b829fce07ed7adf11ca9993c33e33caab285763))
|
||||||
|
* add gradient line decoration ([5559152](https://github.com/wheesys/moodist/commit/5559152a8492dac335f0e6620ca4ca2d9233c889))
|
||||||
|
* add header to todos ([c6cc61a](https://github.com/wheesys/moodist/commit/c6cc61a17fcb8542ece3caccc0de536d8003b106))
|
||||||
|
* add help text ([c3521a7](https://github.com/wheesys/moodist/commit/c3521a798611aa0ad7297493aa5a790a27bbc991))
|
||||||
|
* add hero section ([dc33c2c](https://github.com/wheesys/moodist/commit/dc33c2cf9cdcb251b7a0cc4dabdd7aafae154aa9))
|
||||||
|
* add hidden selection indicator ([e2cd75a](https://github.com/wheesys/moodist/commit/e2cd75a332fab318a529f4f6e04ecf1867be7fd1))
|
||||||
|
* add Howler for sounds ([735d9eb](https://github.com/wheesys/moodist/commit/735d9ebebfa36dd3e7596e70a0549b24b7b9fc4d))
|
||||||
|
* add icon for sounds ([1994004](https://github.com/wheesys/moodist/commit/199400446cc241fb66722c79e74f882a7ef6a26c))
|
||||||
|
* add ID to presets ([78222be](https://github.com/wheesys/moodist/commit/78222be011cf93998faed0b7926a5b49dcdeb470))
|
||||||
|
* add isochronic tone generator without styles ([d759064](https://github.com/wheesys/moodist/commit/d759064373fe791f641db39549e05341068ae8a2))
|
||||||
|
* add keyboard shortcut for play button ([d3a2a12](https://github.com/wheesys/moodist/commit/d3a2a12e1fdcca502c0d3d6dc60d3e4c577165f2))
|
||||||
|
* add keyboard shortcut for unselect button ([99f3a41](https://github.com/wheesys/moodist/commit/99f3a41598ea237d2f509825d0b3c0ee27e789d7))
|
||||||
|
* add keyboard shortcuts ([669df1f](https://github.com/wheesys/moodist/commit/669df1f08264e63c0892e7d4fdd2ee7dbcb96b2e))
|
||||||
|
* add link to reasons ([e2b6eaf](https://github.com/wheesys/moodist/commit/e2b6eaf8f3278768ce142ed58594958dcc7821ad))
|
||||||
|
* add loader for favorites ([f682a91](https://github.com/wheesys/moodist/commit/f682a910da97eb53cfb90ce955e953f05088e686))
|
||||||
|
* add loading state for sounds ([aaccbee](https://github.com/wheesys/moodist/commit/aaccbee3d7dd1d4469ee26cea14df7132e8e9e0d))
|
||||||
|
* add local storage support ([856b3e6](https://github.com/wheesys/moodist/commit/856b3e668ed6f24c8aefe532ac673af5e99141d1))
|
||||||
|
* add lock while fading ([d9246b6](https://github.com/wheesys/moodist/commit/d9246b692bcb75018653cb6f437b1f46af1f925d))
|
||||||
|
* add lofi music play ([fcbe50c](https://github.com/wheesys/moodist/commit/fcbe50c78c30e4422aea2ed698fff777fcaea1c4))
|
||||||
|
* add lofi radios ([bb39b4b](https://github.com/wheesys/moodist/commit/bb39b4ba98f20da13e1e7a440441f5474a823f32))
|
||||||
|
* add media session ([5e0a842](https://github.com/wheesys/moodist/commit/5e0a84259ff5586700c4e10087485d905be7ccee))
|
||||||
|
* add media session (wip) ([34d3f07](https://github.com/wheesys/moodist/commit/34d3f075816eb821979f1d51a1177ecfa03920f3))
|
||||||
|
* add media session (wip) ([cf4870b](https://github.com/wheesys/moodist/commit/cf4870b0d6b172bd4e6b79ff517af06b2aeac7a5))
|
||||||
|
* add media session (wip) ([9f0a28d](https://github.com/wheesys/moodist/commit/9f0a28d9305954486d4f609f85811982df9710f3))
|
||||||
|
* add media session (wip) ([56b0e9b](https://github.com/wheesys/moodist/commit/56b0e9bf1a16d4e7e2c8d7a552b652f8d30dd800))
|
||||||
|
* add media session (wip) ([4f752bb](https://github.com/wheesys/moodist/commit/4f752bb6d048c0260ff6b2aada59c227624b2d17))
|
||||||
|
* add media session (wip) ([1547b0a](https://github.com/wheesys/moodist/commit/1547b0a436bd9a77c19fc5d37be3cb3e123e6117))
|
||||||
|
* add media session (wip) ([f311ec1](https://github.com/wheesys/moodist/commit/f311ec114e3a8ca61954819334e43195d0980219))
|
||||||
|
* add media session (wip) ([df1b05f](https://github.com/wheesys/moodist/commit/df1b05f7ce3e26128d0bc4a9a022b5300ea88f85))
|
||||||
|
* add media session (wip) ([ea0dfff](https://github.com/wheesys/moodist/commit/ea0dfff9c1e7d8e6e03bccdc0ab15d098b31a10d))
|
||||||
|
* add media session (wip) ([fc1bd07](https://github.com/wheesys/moodist/commit/fc1bd07b7de9532383c66d7e59cc13bbe41f415a))
|
||||||
|
* add media session (wip) ([f79e941](https://github.com/wheesys/moodist/commit/f79e941527e09e96b5eba6ca8c4e2e3df583c071))
|
||||||
|
* add media session (wip) ([11a4514](https://github.com/wheesys/moodist/commit/11a4514a0f63f09954361fdef8145869d369fd29))
|
||||||
|
* add menu button ([184bb09](https://github.com/wheesys/moodist/commit/184bb09f5ab09fcf877e6a904023d9de72be9a89))
|
||||||
|
* add Moodist description to tools ([5b3972b](https://github.com/wheesys/moodist/commit/5b3972b3470f3c43903d9a20925ed49321f07440))
|
||||||
|
* add more and less button for sounds ([13cd72a](https://github.com/wheesys/moodist/commit/13cd72a0655d90f0a6b7658b3357d1e8902f8fb7))
|
||||||
|
* add more sounds ([d2e289e](https://github.com/wheesys/moodist/commit/d2e289e5d5cccd050ca94860f05f00740b3cf139))
|
||||||
|
* add more sounds ([554309e](https://github.com/wheesys/moodist/commit/554309ebd87da2bce4555f09e5c9f34735d0b794))
|
||||||
|
* add more sounds ([be38b92](https://github.com/wheesys/moodist/commit/be38b92647209ce17032987b3d6f5d1800322db5))
|
||||||
|
* add more sounds ([b497d16](https://github.com/wheesys/moodist/commit/b497d16fd8b7d6ccf34c0c91b596fca75dff2f34))
|
||||||
|
* add more sounds ([0888aaa](https://github.com/wheesys/moodist/commit/0888aaa0f09ed549afdb21166ad6d2f048604275))
|
||||||
|
* add more sounds ([63ed396](https://github.com/wheesys/moodist/commit/63ed396a5a74ed2b6e25882a72511ee93935fe04))
|
||||||
|
* add move up and down functionality ([3e11fb6](https://github.com/wheesys/moodist/commit/3e11fb6123e4c6b6be9668ef4c274390a5acd16a))
|
||||||
|
* add new logo ([c1ece58](https://github.com/wheesys/moodist/commit/c1ece582f445906308a0d856181ebaca464ec25a))
|
||||||
|
* add new sounds ([759e6b0](https://github.com/wheesys/moodist/commit/759e6b0ce8f0acc3eb0eed508f7c587804097748))
|
||||||
|
* add notepad tool ([a80289d](https://github.com/wheesys/moodist/commit/a80289db57c1b002edd586b323444d3a474587ad))
|
||||||
|
* add notepad tool page ([1fd02f9](https://github.com/wheesys/moodist/commit/1fd02f927c55155ecd8d1af6325995c4635e0a29))
|
||||||
|
* add open-source section ([f7302de](https://github.com/wheesys/moodist/commit/f7302dec5b7e182ad465bc30b63457a6e629a5b3))
|
||||||
|
* add persist mode to the modal ([4c0f417](https://github.com/wheesys/moodist/commit/4c0f417469fb15adbe33cab9bb66459225653e68))
|
||||||
|
* add play button ([31c087e](https://github.com/wheesys/moodist/commit/31c087ebc8e66220d488226029dcc1435667ce04))
|
||||||
|
* add pomodoro timer ([d2edeb4](https://github.com/wheesys/moodist/commit/d2edeb48becef62f1002359a41ebe8ebfa1f34bb))
|
||||||
|
* add pomodoro timer tool ([bee391a](https://github.com/wheesys/moodist/commit/bee391acfecdaf36488c48ef1022b16a83059d58))
|
||||||
|
* add PWA ([761c730](https://github.com/wheesys/moodist/commit/761c7301295a3e5645326be804225431f823f808))
|
||||||
|
* add ready section ([e372d2f](https://github.com/wheesys/moodist/commit/e372d2f398dbdcfad1069b50911ba840f0c9a1fe))
|
||||||
|
* add reverse timer ([105f53e](https://github.com/wheesys/moodist/commit/105f53ea028fadae4bd2ff7d8a1856e94f070b1a))
|
||||||
|
* add scroll for lower heights ([758f2f4](https://github.com/wheesys/moodist/commit/758f2f48dc6a4e520b7a1e937f96eed28c8e8c20))
|
||||||
|
* add scroll to top component ([3c1c27b](https://github.com/wheesys/moodist/commit/3c1c27b2fd378eb0f7351a3f511375cbc62f2a7b))
|
||||||
|
* add share modal ([35e3215](https://github.com/wheesys/moodist/commit/35e32152b153f4dfaf9e071f526f6d7602ea97fc))
|
||||||
|
* add share placeholder ([fe2357c](https://github.com/wheesys/moodist/commit/fe2357c995713cd0fb8335b325266859dc47a769))
|
||||||
|
* add shine effect ([d9df0d4](https://github.com/wheesys/moodist/commit/d9df0d4b2c5071c12cecc6452acc0f160c57deb5))
|
||||||
|
* add shortcut for breathing exercise ([60cb453](https://github.com/wheesys/moodist/commit/60cb453847f0968a4d1abc0fbb66773a54ebdfd9))
|
||||||
|
* add shortcuts list ([60f167c](https://github.com/wheesys/moodist/commit/60f167c4d734bc6238f7c2bb7b39c89ed45ed9eb))
|
||||||
|
* add shortcuts to items ([42f82ab](https://github.com/wheesys/moodist/commit/42f82ab95d684163826e76231fb1dd554f773d68))
|
||||||
|
* add simple breathing exercise tool ([fc4f521](https://github.com/wheesys/moodist/commit/fc4f52146e2142a0c711b6d6a334c0107b1e1daa))
|
||||||
|
* add simple notepad ([e923559](https://github.com/wheesys/moodist/commit/e923559709796698257772cced4e20de584c6c80))
|
||||||
|
* add simple tooltip ([f2efe3c](https://github.com/wheesys/moodist/commit/f2efe3c490ab5429824d10e97979694a4de11dd4))
|
||||||
|
* add singing bowl sound ([0b49f66](https://github.com/wheesys/moodist/commit/0b49f66e5879642da10054046700a160411448a3))
|
||||||
|
* add sleep timer ([71b62ed](https://github.com/wheesys/moodist/commit/71b62ed3dd365744435dc4499b9c53684f72849c))
|
||||||
|
* add sound count to hero ([42ccc7a](https://github.com/wheesys/moodist/commit/42ccc7ada780fd5db5c038fa9210ec0e3e75be6e))
|
||||||
|
* add source code item ([d055e66](https://github.com/wheesys/moodist/commit/d055e66dd9dd5789c88d1a002686457ea89db073))
|
||||||
|
* add special button ([a514a36](https://github.com/wheesys/moodist/commit/a514a364ec5b6e2e34e7901ad51219d7be2aee86))
|
||||||
|
* add store to the notepad ([47a63a7](https://github.com/wheesys/moodist/commit/47a63a774ebede5db65f17a29a36f0b76d9ed85a))
|
||||||
|
* add story for modal ([9b7d3c6](https://github.com/wheesys/moodist/commit/9b7d3c645b8c3469231641e6ec8bbdef88732bbc))
|
||||||
|
* add story for snackbar ([43f6245](https://github.com/wheesys/moodist/commit/43f62452275573f948449190dcfcef89faa4ec51))
|
||||||
|
* add timer for breathing exercises ([5865fc8](https://github.com/wheesys/moodist/commit/5865fc867dc97e03d0f0c79ea8c465e0c0f27411))
|
||||||
|
* add titles ([5f40435](https://github.com/wheesys/moodist/commit/5f40435c0ccfec0cb87d9ac0c14723fb8265fa8d))
|
||||||
|
* add toolbar and portal ([ede4801](https://github.com/wheesys/moodist/commit/ede480186c4b3f187c82e1d64e4d521ee59da31a))
|
||||||
|
* add toolbar to notepad ([7463334](https://github.com/wheesys/moodist/commit/7463334053ecd35a53cae535674169f5b50c81c2))
|
||||||
|
* add tooltip to scroll button ([d4401fa](https://github.com/wheesys/moodist/commit/d4401faaffcb4351be1a152b89f94c9db63ca213))
|
||||||
|
* add why section ([3ed610b](https://github.com/wheesys/moodist/commit/3ed610bb468293f6b08c2b2444bc47cd570383eb))
|
||||||
|
* allow using spacebar or enter to trigger buttons ([60cc2e9](https://github.com/wheesys/moodist/commit/60cc2e9369aff3a374458cf1c3234eec8cd0530e))
|
||||||
|
* basic structure for share link ([ef81f19](https://github.com/wheesys/moodist/commit/ef81f198baeb927e3b1768570f75e6638a7bd0b6))
|
||||||
|
* better heading ([10259d0](https://github.com/wheesys/moodist/commit/10259d013f7cb1ae41808f7a78e836ddee3b07f1))
|
||||||
|
* bring back all tools ([6a4dc1e](https://github.com/wheesys/moodist/commit/6a4dc1ed95072c402cb553fa5b1becb646062c45))
|
||||||
|
* bring back all tools ([e1de5c4](https://github.com/wheesys/moodist/commit/e1de5c48b299e815f071f15c00424ba1b0189419))
|
||||||
|
* change alignments ([1a01a00](https://github.com/wheesys/moodist/commit/1a01a0086648c7564ecab30b79df0d67e93eb392))
|
||||||
|
* change and add shortcuts ([a59db41](https://github.com/wheesys/moodist/commit/a59db41dc5eaa7be5ab86c5cc407274eb7b57dfe))
|
||||||
|
* change lofi icon ([066af9e](https://github.com/wheesys/moodist/commit/066af9e2f31bc9201d349d888c6dc19cd5ad7750))
|
||||||
|
* change logos ([3d1d45c](https://github.com/wheesys/moodist/commit/3d1d45cd4933335cfbe20381c0e758969a3bdcb9))
|
||||||
|
* change shortcuts ([4f45279](https://github.com/wheesys/moodist/commit/4f45279938f60ee6934c3e6047898b9833c2b9c6))
|
||||||
|
* change shortcuts ([251f309](https://github.com/wheesys/moodist/commit/251f30930c72a50120412c6b2182fdf4183b9d62))
|
||||||
|
* change shortcuts to shift ([837826f](https://github.com/wheesys/moodist/commit/837826fbc13599e51bb7b65cf8b7bdcb1f1fc503))
|
||||||
|
* change sound count from round to exact ([8c49453](https://github.com/wheesys/moodist/commit/8c49453011d127669774f46720ce6e98ca01aa13))
|
||||||
|
* change the copy for features ([38da02a](https://github.com/wheesys/moodist/commit/38da02a0d3b08e8f8802d6cf76a04ae656e10b76))
|
||||||
|
* change tooltip content ([941e1f0](https://github.com/wheesys/moodist/commit/941e1f024189143340d663a0c117c08a0b315599))
|
||||||
|
* close notepad on escape ([583578b](https://github.com/wheesys/moodist/commit/583578b31592b3c0e7f5ae6ad3f83e99e64fb6ff))
|
||||||
|
* complete meta tags ([1cfbf0d](https://github.com/wheesys/moodist/commit/1cfbf0dd092d35d2f098c29baf6d6adbc1107cc0))
|
||||||
|
* create reusable tooltip ([c637e2d](https://github.com/wheesys/moodist/commit/c637e2d63109e12886b6f688c643146707967c7a))
|
||||||
|
* **docker:** add dockerfile ([a234bc1](https://github.com/wheesys/moodist/commit/a234bc17a66331acbbc1d980cd1f53d58646f534))
|
||||||
|
* extract the provider for the tooltip ([95b641a](https://github.com/wheesys/moodist/commit/95b641a88f2eee264b59b5bd62206bb84119da57))
|
||||||
|
* fix modal and scrollbar layout shift ([e399673](https://github.com/wheesys/moodist/commit/e3996734621b33c0598db29e82371f1258396147))
|
||||||
|
* implement basic snackbar ([8090599](https://github.com/wheesys/moodist/commit/8090599f2bc9ce58cdb36a6a04555afdb7af2bb2))
|
||||||
|
* implement basic Zustand store ([22bb65d](https://github.com/wheesys/moodist/commit/22bb65de0d4ea9f485e4923b9c8715233df3114e))
|
||||||
|
* implement countdown timer functionality ([2bfb9b1](https://github.com/wheesys/moodist/commit/2bfb9b181c490c9836e2410199e6a1cf8687e7aa))
|
||||||
|
* implement favorite sounds functionality ([cb34b59](https://github.com/wheesys/moodist/commit/cb34b59d864fb80b930c0c9e1c1269bb7e9c2b18))
|
||||||
|
* implement override feature ([0f62f07](https://github.com/wheesys/moodist/commit/0f62f0795c5a9e06fa4e62b6b7b1e6c0774dfe0f))
|
||||||
|
* implement sharing URL ([93ff72a](https://github.com/wheesys/moodist/commit/93ff72a052484b36c9ac821b94b632865b4a3550))
|
||||||
|
* implement shuffle functionality ([26ba017](https://github.com/wheesys/moodist/commit/26ba017815d7338f49d2017eda75f86f493bf050))
|
||||||
|
* implement shuffling functionality ([3ac211e](https://github.com/wheesys/moodist/commit/3ac211e3554d26c48fb8e0a588a67f1a4901e9b9))
|
||||||
|
* implement time setting ([f3cb2a1](https://github.com/wheesys/moodist/commit/f3cb2a1b63e40f4f742ee2591b9353aa373f9783))
|
||||||
|
* implement unselect all functionality ([8966d59](https://github.com/wheesys/moodist/commit/8966d59d758496cc94247364833788dcc555ce8b))
|
||||||
|
* make sound file addresses relative ([81d9d7c](https://github.com/wheesys/moodist/commit/81d9d7ca03f6c7410ca750e069c9c8b935114950))
|
||||||
|
* make the modal more accessible ([0252fa9](https://github.com/wheesys/moodist/commit/0252fa96abed18de71472ffc671b13c263754ed9))
|
||||||
|
* media session support ([18ed2e6](https://github.com/wheesys/moodist/commit/18ed2e6f055d7e32b4a9df33cdb724eaf1f930aa))
|
||||||
|
* migrate to motion and fix some animations ([b191e60](https://github.com/wheesys/moodist/commit/b191e6067ddc3233689a34946c602db36d6133ba))
|
||||||
|
* persist pomodoro setting ([665e217](https://github.com/wheesys/moodist/commit/665e2173f4083128687a6302a6f2fd82674f07c1))
|
||||||
|
* persist presets ([38a9a23](https://github.com/wheesys/moodist/commit/38a9a23790248d5af522fc0d3cf6e99970e59637))
|
||||||
|
* remove all extra tools ([973e0df](https://github.com/wheesys/moodist/commit/973e0df6fb3a6749fd4b0f8d1cd976c67a7e8c43))
|
||||||
|
* remove all tools ([2bbdc7e](https://github.com/wheesys/moodist/commit/2bbdc7e09e053bd6e8bb052abb7aff723cb14eaa))
|
||||||
|
* remove all tools ([b32d8b2](https://github.com/wheesys/moodist/commit/b32d8b28034e018eeaf1c544e4128b91f4a95172))
|
||||||
|
* remove lofi modal ([13d26b3](https://github.com/wheesys/moodist/commit/13d26b3337b2e79d52c774807795b5924a4dcb76))
|
||||||
|
* remove pre-made binaurals ([b8ed79f](https://github.com/wheesys/moodist/commit/b8ed79f48ad2a315b93aedf1f932b6c5f075b157))
|
||||||
|
* remove the breathing exercises ([76fdc74](https://github.com/wheesys/moodist/commit/76fdc747100bc15ced92b77b1fefc8cba519d37f))
|
||||||
|
* remove the countdown timer ([d6ed3fd](https://github.com/wheesys/moodist/commit/d6ed3fd251df029100caba5df304996e723acd78))
|
||||||
|
* reorder sounds in favorites ([dc9da85](https://github.com/wheesys/moodist/commit/dc9da85e6825b3cb70e2e6ad4f35c0db3aeb26c2))
|
||||||
|
* replace reverse timer ([a6c7ac4](https://github.com/wheesys/moodist/commit/a6c7ac41ad5210b9a98e0fe62f5cb387fe9c4e9a))
|
||||||
|
* scroll into view after marking favorite ([74f6b58](https://github.com/wheesys/moodist/commit/74f6b5851d3a0fac5f97d97cd24f12507c2c3b35))
|
||||||
|
* scroll the new timer into view ([f4c66e3](https://github.com/wheesys/moodist/commit/f4c66e309277414951b191e627b1f52aab79af6f))
|
||||||
|
* update the menu items ([1768ba1](https://github.com/wheesys/moodist/commit/1768ba1548a444c57dbfd5e351d77838238aed0d))
|
||||||
|
* use custom slider in binaural and isochronic ([e61307a](https://github.com/wheesys/moodist/commit/e61307a30263dca8cc016ec5136d52c4b18e5c3c))
|
||||||
|
|
||||||
## [2.1.0](https://github.com/remvze/moodist/compare/v2.0.1...v2.1.0) (2025-07-19)
|
## [2.1.0](https://github.com/remvze/moodist/compare/v2.0.1...v2.1.0) (2025-07-19)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
305
DOCKER_DEPLOY.md
Normal file
305
DOCKER_DEPLOY.md
Normal file
|
|
@ -0,0 +1,305 @@
|
||||||
|
# Moodist Docker 部署指南
|
||||||
|
|
||||||
|
## 🐳 Docker 镜像构建和部署
|
||||||
|
|
||||||
|
### 📋 镜像信息
|
||||||
|
|
||||||
|
- **镜像名称**: `walllee/moodist`
|
||||||
|
- **Docker Hub**: https://hub.docker.com/r/walllee/moodist
|
||||||
|
- **支持平台**: `linux/amd64`, `linux/arm64`
|
||||||
|
- **基础镜像**: `nginx:alpine`
|
||||||
|
- **镜像大小**: ~30MB
|
||||||
|
|
||||||
|
### 🚀 快速开始
|
||||||
|
|
||||||
|
#### 1. 直接拉取并运行
|
||||||
|
```bash
|
||||||
|
# 拉取镜像
|
||||||
|
docker pull walllee/moodist:latest
|
||||||
|
|
||||||
|
# 运行容器
|
||||||
|
docker run -d \
|
||||||
|
--name moodist \
|
||||||
|
-p 8080:8080 \
|
||||||
|
--restart unless-stopped \
|
||||||
|
walllee/moodist:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 使用 Docker Compose
|
||||||
|
```bash
|
||||||
|
# 简单版本
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 或使用优化版本
|
||||||
|
docker-compose -f docker-compose.optimized.yml up -d
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
docker-compose logs -f
|
||||||
|
# 或
|
||||||
|
docker-compose -f docker-compose.optimized.yml logs -f
|
||||||
|
|
||||||
|
# 停止服务
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🔨 自定义构建
|
||||||
|
|
||||||
|
#### 1. 简化本地构建(推荐)
|
||||||
|
```bash
|
||||||
|
# 克隆仓库
|
||||||
|
git clone https://github.com/wheesys/moodist.git
|
||||||
|
cd moodist
|
||||||
|
|
||||||
|
# 简化构建(推荐,兼容性最好)
|
||||||
|
npm run docker:push
|
||||||
|
|
||||||
|
# 或带版本号构建
|
||||||
|
./scripts/build-docker-simple.sh 2.1.0
|
||||||
|
|
||||||
|
# 构建并推送到 Docker Hub
|
||||||
|
npm run docker:push-and-upload
|
||||||
|
```
|
||||||
|
|
||||||
|
**特点:**
|
||||||
|
- ✅ 完全兼容,不依赖 Docker Buildx
|
||||||
|
- ✅ 先本地构建再打包,避免容器内依赖问题
|
||||||
|
- ✅ 构建速度快,使用缓存优化
|
||||||
|
- ✅ 支持版本标签和自动 latest 标签
|
||||||
|
|
||||||
|
#### 2. 多平台构建
|
||||||
|
```bash
|
||||||
|
# 克隆仓库
|
||||||
|
git clone https://github.com/wheesys/moodist.git
|
||||||
|
cd moodist
|
||||||
|
|
||||||
|
# 本地构建和测试
|
||||||
|
./scripts/build-local.sh
|
||||||
|
|
||||||
|
# 查看构建结果
|
||||||
|
docker images | grep moodist
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 推送到 Docker Hub
|
||||||
|
```bash
|
||||||
|
# 登录 Docker Hub
|
||||||
|
docker login
|
||||||
|
|
||||||
|
# 使用简化脚本推送
|
||||||
|
npm run docker:push-and-upload
|
||||||
|
|
||||||
|
# 或手动推送指定版本
|
||||||
|
docker push walllee/moodist:2.1.0
|
||||||
|
docker push walllee/moodist:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📦 部署配置
|
||||||
|
|
||||||
|
#### 生产环境配置
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
moodist:
|
||||||
|
image: wheeysys/moodist:latest
|
||||||
|
container_name: moodist-prod
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "80:8080"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8080/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 开发环境配置
|
||||||
|
```bash
|
||||||
|
# 使用开发配置
|
||||||
|
docker-compose -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
# 或者使用开发工具
|
||||||
|
docker-compose -f docker-compose.dev.yml --profile tools up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🔧 环境变量
|
||||||
|
|
||||||
|
| 变量名 | 默认值 | 说明 |
|
||||||
|
|--------|--------|------|
|
||||||
|
| `NODE_ENV` | `production` | 运行环境 |
|
||||||
|
| `TZ` | `Asia/Shanghai` | 时区设置 |
|
||||||
|
|
||||||
|
### 📊 性能优化
|
||||||
|
|
||||||
|
#### 镜像特性
|
||||||
|
- ✅ **多阶段构建**: 优化镜像大小
|
||||||
|
- ✅ **多平台支持**: AMD64 + ARM64
|
||||||
|
- ✅ **非root用户**: 提高安全性
|
||||||
|
- ✅ **健康检查**: 自动监控应用状态
|
||||||
|
- ✅ **静态优化**: Nginx + Gzip 压缩
|
||||||
|
|
||||||
|
#### 资源使用
|
||||||
|
- **内存占用**: ~32MB (运行时)
|
||||||
|
- **CPU占用**: < 0.1 (空闲时)
|
||||||
|
- **启动时间**: ~2秒
|
||||||
|
- **镜像大小**: ~30MB
|
||||||
|
|
||||||
|
### 🔍 监控和日志
|
||||||
|
|
||||||
|
#### 查看容器状态
|
||||||
|
```bash
|
||||||
|
# 查看容器状态
|
||||||
|
docker ps | grep moodist
|
||||||
|
|
||||||
|
# 查看健康检查状态
|
||||||
|
docker inspect moodist | grep Health -A 10
|
||||||
|
|
||||||
|
# 查看资源使用
|
||||||
|
docker stats moodist
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 日志管理
|
||||||
|
```bash
|
||||||
|
# 查看实时日志
|
||||||
|
docker logs -f moodist
|
||||||
|
|
||||||
|
# 查看最近日志
|
||||||
|
docker logs --tail 100 moodist
|
||||||
|
|
||||||
|
# 日志轮转(在 docker-compose 中配置)
|
||||||
|
logging:
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🛠️ 故障排除
|
||||||
|
|
||||||
|
#### 常见问题
|
||||||
|
|
||||||
|
1. **容器无法启动**
|
||||||
|
```bash
|
||||||
|
# 检查端口占用
|
||||||
|
netstat -tlnp | grep 8080
|
||||||
|
|
||||||
|
# 查看容器日志
|
||||||
|
docker logs moodist
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **健康检查失败**
|
||||||
|
```bash
|
||||||
|
# 手动检查应用是否响应
|
||||||
|
curl -f http://localhost:8080/
|
||||||
|
|
||||||
|
# 查看健康检查状态
|
||||||
|
docker inspect moodist | grep Health
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **构建失败**
|
||||||
|
```bash
|
||||||
|
# 清理Docker缓存
|
||||||
|
docker system prune -a
|
||||||
|
|
||||||
|
# 重新构建
|
||||||
|
docker build --no-cache -f Dockerfile.optimized -t moodist:test .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🔄 更新部署
|
||||||
|
|
||||||
|
#### 滚动更新
|
||||||
|
```bash
|
||||||
|
# 拉取新版本
|
||||||
|
docker pull wheeysys/moodist:latest
|
||||||
|
|
||||||
|
# 停止旧容器
|
||||||
|
docker stop moodist
|
||||||
|
|
||||||
|
# 启动新容器
|
||||||
|
docker run -d \
|
||||||
|
--name moodist \
|
||||||
|
-p 8080:8080 \
|
||||||
|
--restart unless-stopped \
|
||||||
|
wheeysys/moodist:latest
|
||||||
|
|
||||||
|
# 删除旧容器
|
||||||
|
docker rm $(docker ps -aq --filter "status=exited")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 使用 Docker Compose 更新
|
||||||
|
```bash
|
||||||
|
# 拉取新镜像
|
||||||
|
docker-compose -f docker-compose.optimized.yml pull
|
||||||
|
|
||||||
|
# 重启服务
|
||||||
|
docker-compose -f docker-compose.optimized.yml up -d
|
||||||
|
|
||||||
|
# 清理旧镜像
|
||||||
|
docker image prune -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🔐 安全配置
|
||||||
|
|
||||||
|
#### 生产环境安全建议
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
moodist:
|
||||||
|
image: wheeysys/moodist:latest
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /var/cache/nginx
|
||||||
|
- /var/run
|
||||||
|
user: "nginx"
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
cap_add:
|
||||||
|
- CHOWN
|
||||||
|
- SETGID
|
||||||
|
- SETUID
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📈 扩展部署
|
||||||
|
|
||||||
|
#### 使用反向代理
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name moodist.example.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 负载均衡配置
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
moodist:
|
||||||
|
image: wheeysys/moodist:latest
|
||||||
|
deploy:
|
||||||
|
replicas: 3
|
||||||
|
# ... 其他配置
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📞 支持
|
||||||
|
|
||||||
|
- **GitHub**: https://github.com/wheesys/moodist
|
||||||
|
- **Docker Hub**: https://hub.docker.com/r/walllee/moodist
|
||||||
|
- **问题反馈**: 请在 GitHub Issues 中提交
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*最后更新: 2024-11-16*
|
||||||
56
Dockerfile.dev-server
Normal file
56
Dockerfile.dev-server
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
# 开发模式 Dockerfile - 完整开发环境
|
||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
# 安装必要的系统依赖
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
bash
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 设置环境变量
|
||||||
|
ENV NODE_ENV=development
|
||||||
|
ENV PORT=8080
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PATH="/app/node_modules/.bin:${PATH}"
|
||||||
|
|
||||||
|
# 复制package文件
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# 安装所有依赖(包括devDependencies),跳过 prepare 脚本
|
||||||
|
RUN npm ci --ignore-scripts && \
|
||||||
|
npm cache clean --force && \
|
||||||
|
npm install husky --save-dev
|
||||||
|
|
||||||
|
# 复制所有源代码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 创建数据目录
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8080/ || exit 1
|
||||||
|
|
||||||
|
# 启动开发服务器(类似生产模式,稳定运行)
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "8080"]
|
||||||
|
|
||||||
|
# 定义构建参数
|
||||||
|
ARG VERSION=latest
|
||||||
|
ARG BUILD_DATE
|
||||||
|
ARG VCS_REF
|
||||||
|
|
||||||
|
# 添加标签信息
|
||||||
|
LABEL maintainer="walllee" \
|
||||||
|
org.opencontainers.image.title="Moodist Development" \
|
||||||
|
org.opencontainers.image.description="Ambient sounds for focus and calm - 开发环境(完整功能)" \
|
||||||
|
org.opencontainers.image.version="${VERSION}" \
|
||||||
|
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||||
|
org.opencontainers.image.revision="${VCS_REF}" \
|
||||||
|
org.opencontainers.image.source="https://github.com/wheesys/moodist" \
|
||||||
|
org.opencontainers.image.licenses="MIT"
|
||||||
83
Dockerfile.multiplatform
Normal file
83
Dockerfile.multiplatform
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
# 多平台构建 Dockerfile
|
||||||
|
# 使用: docker buildx build --platform linux/amd64,linux/arm64 -f Dockerfile.multiplatform -t wheesys/moodist:latest --push .
|
||||||
|
|
||||||
|
# 使用官方Node.js镜像作为构建环境
|
||||||
|
FROM --platform=linux/amd64,linux/arm64 node:20-alpine AS base
|
||||||
|
|
||||||
|
# 安装必要的系统依赖
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制package文件
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# 安装依赖(使用npm ci进行更快、更可靠的安装)
|
||||||
|
RUN npm ci --only=production && npm cache clean --force
|
||||||
|
|
||||||
|
# 构建阶段
|
||||||
|
FROM base AS builder
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 设置构建环境变量
|
||||||
|
ARG NODE_ENV=production
|
||||||
|
ARG BUILD_DATE
|
||||||
|
ARG VERSION
|
||||||
|
ARG VCS_REF
|
||||||
|
|
||||||
|
ENV NODE_ENV=$NODE_ENV
|
||||||
|
ENV BUILD_DATE=$BUILD_DATE
|
||||||
|
ENV VERSION=$VERSION
|
||||||
|
ENV VCS_REF=$VCS_REF
|
||||||
|
|
||||||
|
# 构建应用
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# 生产运行阶段 - 使用轻量级Nginx
|
||||||
|
FROM nginx:alpine AS runtime
|
||||||
|
|
||||||
|
# 安装curl用于健康检查
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
|
# 创建非root用户提高安全性
|
||||||
|
RUN addgroup -g 1001 -S nginx && \
|
||||||
|
adduser -S nginx -u 1001 -G nginx
|
||||||
|
|
||||||
|
# 复制自定义nginx配置
|
||||||
|
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
# 从构建阶段复制构建产物
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# 设置正确的权限
|
||||||
|
RUN chown -R nginx:nginx /usr/share/nginx/html && \
|
||||||
|
chown -R nginx:nginx /var/cache/nginx && \
|
||||||
|
chown -R nginx:nginx /var/log/nginx && \
|
||||||
|
chown -R nginx:nginx /etc/nginx/conf.d
|
||||||
|
|
||||||
|
# 创建nginx运行时需要的目录
|
||||||
|
RUN touch /var/run/nginx.pid && \
|
||||||
|
chown -R nginx:nginx /var/run/nginx.pid
|
||||||
|
|
||||||
|
# 切换到非root用户
|
||||||
|
USER nginx
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8080/ || exit 1
|
||||||
|
|
||||||
|
# 启动nginx
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|
||||||
|
# 添加标签信息
|
||||||
|
LABEL maintainer="walllee" \
|
||||||
|
org.opencontainers.image.title="Moodist" \
|
||||||
|
org.opencontainers.image.description="Ambient sounds for focus and calm - 多语言环境音应用" \
|
||||||
|
org.opencontainers.image.version=$VERSION \
|
||||||
|
org.opencontainers.image.created=$BUILD_DATE \
|
||||||
|
org.opencontainers.image.revision=$VCS_REF \
|
||||||
|
org.opencontainers.image.source="https://github.com/wheesys/moodist" \
|
||||||
|
org.opencontainers.image.licenses="MIT"
|
||||||
89
Dockerfile.optimized
Normal file
89
Dockerfile.optimized
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
# 使用官方Node.js镜像作为构建环境
|
||||||
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
|
# 安装必要的系统依赖
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制package文件
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# 构建阶段
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制package文件
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# 设置构建环境变量
|
||||||
|
ARG NODE_ENV=production
|
||||||
|
ARG BUILD_DATE
|
||||||
|
ARG VERSION
|
||||||
|
ARG VCS_REF
|
||||||
|
|
||||||
|
ENV NODE_ENV=$NODE_ENV
|
||||||
|
ENV BUILD_DATE=$BUILD_DATE
|
||||||
|
ENV VERSION=$VERSION
|
||||||
|
ENV VCS_REF=$VCS_REF
|
||||||
|
|
||||||
|
# 安装所有依赖(构建需要所有依赖,包括devDependencies)
|
||||||
|
RUN npm ci --ignore-scripts && \
|
||||||
|
npm install --save-dev autoprefixer postcss postcss-nesting && \
|
||||||
|
npm cache clean --force
|
||||||
|
|
||||||
|
# 复制源代码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 构建应用
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# 生产运行阶段 - 使用轻量级Nginx
|
||||||
|
FROM nginx:alpine AS runtime
|
||||||
|
|
||||||
|
# 安装curl用于健康检查
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
|
# 创建非root用户提高安全性
|
||||||
|
RUN addgroup -g 1001 -S nginx && \
|
||||||
|
adduser -S nginx -u 1001 -G nginx
|
||||||
|
|
||||||
|
# 复制自定义nginx配置
|
||||||
|
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
# 从构建阶段复制构建产物
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# 设置正确的权限
|
||||||
|
RUN chown -R nginx:nginx /usr/share/nginx/html && \
|
||||||
|
chown -R nginx:nginx /var/cache/nginx && \
|
||||||
|
chown -R nginx:nginx /var/log/nginx && \
|
||||||
|
chown -R nginx:nginx /etc/nginx/conf.d
|
||||||
|
|
||||||
|
# 创建nginx运行时需要的目录
|
||||||
|
RUN touch /var/run/nginx.pid && \
|
||||||
|
chown -R nginx:nginx /var/run/nginx.pid
|
||||||
|
|
||||||
|
# 切换到非root用户
|
||||||
|
USER nginx
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8080/ || exit 1
|
||||||
|
|
||||||
|
# 启动nginx
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|
||||||
|
# 添加标签信息
|
||||||
|
LABEL maintainer="walllee" \
|
||||||
|
org.opencontainers.image.title="Moodist" \
|
||||||
|
org.opencontainers.image.description="Ambient sounds for focus and calm - 多语言环境音应用" \
|
||||||
|
org.opencontainers.image.version=$VERSION \
|
||||||
|
org.opencontainers.image.created=$BUILD_DATE \
|
||||||
|
org.opencontainers.image.revision=$VCS_REF \
|
||||||
|
org.opencontainers.image.source="https://github.com/wheesys/moodist" \
|
||||||
|
org.opencontainers.image.licenses="MIT"
|
||||||
58
Dockerfile.prod-like
Normal file
58
Dockerfile.prod-like
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
# 类生产模式 Dockerfile - 开发服务器但无热重载
|
||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
# 安装必要的系统依赖
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
bash
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 设置环境变量
|
||||||
|
ENV NODE_ENV=development
|
||||||
|
ENV PORT=8080
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PATH="/app/node_modules/.bin:${PATH}"
|
||||||
|
|
||||||
|
# 复制package文件
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# 安装所有依赖
|
||||||
|
RUN npm ci && \
|
||||||
|
npm cache clean --force
|
||||||
|
|
||||||
|
# 在容器内构建应用
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 创建数据目录
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
# 构建应用
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8080/ || exit 1
|
||||||
|
|
||||||
|
# 启动应用(类似生产模式,无热重载)
|
||||||
|
CMD ["node", "./dist/server/entry.mjs"]
|
||||||
|
|
||||||
|
# 定义构建参数
|
||||||
|
ARG VERSION=latest
|
||||||
|
ARG BUILD_DATE
|
||||||
|
ARG VCS_REF
|
||||||
|
|
||||||
|
# 添加标签信息
|
||||||
|
LABEL maintainer="walllee" \
|
||||||
|
org.opencontainers.image.title="Moodist Production-like" \
|
||||||
|
org.opencontainers.image.description="Ambient sounds for focus and calm - 类生产模式(无热重载)" \
|
||||||
|
org.opencontainers.image.version="${VERSION}" \
|
||||||
|
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||||
|
org.opencontainers.image.revision="${VCS_REF}" \
|
||||||
|
org.opencontainers.image.source="https://github.com/wheesys/moodist" \
|
||||||
|
org.opencontainers.image.licenses="MIT"
|
||||||
58
Dockerfile.server
Normal file
58
Dockerfile.server
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
# 使用现有的本地构建成果 - Node.js 服务器版本
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
# 安装必要的系统依赖
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
|
# 创建应用用户
|
||||||
|
RUN addgroup -g 1001 -S nodejs && \
|
||||||
|
adduser -S nodejs -u 1001
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 设置环境变量
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=8080
|
||||||
|
|
||||||
|
# 复制package文件
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# 安装所有依赖(运行时需要adapter)
|
||||||
|
RUN npm ci --ignore-scripts && \
|
||||||
|
npm cache clean --force
|
||||||
|
|
||||||
|
# 复制本地构建的完整产物
|
||||||
|
COPY --chown=nodejs:nodejs dist/ ./dist
|
||||||
|
|
||||||
|
# 创建数据目录
|
||||||
|
RUN mkdir -p /app/data && \
|
||||||
|
chown -R nodejs:nodejs /app
|
||||||
|
|
||||||
|
# 切换到非root用户
|
||||||
|
USER nodejs
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8080/ || exit 1
|
||||||
|
|
||||||
|
# 启动应用
|
||||||
|
CMD ["node", "./dist/server/entry.mjs"]
|
||||||
|
|
||||||
|
# 定义构建参数
|
||||||
|
ARG VERSION=latest
|
||||||
|
ARG BUILD_DATE
|
||||||
|
ARG VCS_REF
|
||||||
|
|
||||||
|
# 添加标签信息
|
||||||
|
LABEL maintainer="walllee" \
|
||||||
|
org.opencontainers.image.title="Moodist" \
|
||||||
|
org.opencontainers.image.description="Ambient sounds for focus and calm - 多语言环境音应用 (Full Stack)" \
|
||||||
|
org.opencontainers.image.version="${VERSION}" \
|
||||||
|
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||||
|
org.opencontainers.image.revision="${VCS_REF}" \
|
||||||
|
org.opencontainers.image.source="https://github.com/wheesys/moodist" \
|
||||||
|
org.opencontainers.image.licenses="MIT"
|
||||||
49
Dockerfile.simple
Normal file
49
Dockerfile.simple
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
# 简化的Dockerfile - 使用本地构建产物
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# 安装curl用于健康检查
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
|
# 复制自定义nginx配置
|
||||||
|
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
# 复制本地构建的静态文件
|
||||||
|
COPY dist/ /usr/share/nginx/html
|
||||||
|
|
||||||
|
# 设置正确的权限
|
||||||
|
RUN chown -R nginx:nginx /usr/share/nginx/html && \
|
||||||
|
chown -R nginx:nginx /var/cache/nginx && \
|
||||||
|
chown -R nginx:nginx /var/log/nginx && \
|
||||||
|
chown -R nginx:nginx /etc/nginx/conf.d
|
||||||
|
|
||||||
|
# 创建nginx运行时需要的目录
|
||||||
|
RUN touch /var/run/nginx.pid && \
|
||||||
|
chown -R nginx:nginx /var/run/nginx.pid
|
||||||
|
|
||||||
|
# 切换到非root用户
|
||||||
|
USER nginx
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8080/ || exit 1
|
||||||
|
|
||||||
|
# 启动nginx
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|
||||||
|
# 定义构建参数
|
||||||
|
ARG VERSION=latest
|
||||||
|
ARG BUILD_DATE
|
||||||
|
ARG VCS_REF
|
||||||
|
|
||||||
|
# 添加标签信息
|
||||||
|
LABEL maintainer="walllee" \
|
||||||
|
org.opencontainers.image.title="Moodist" \
|
||||||
|
org.opencontainers.image.description="Ambient sounds for focus and calm - 多语言环境音应用" \
|
||||||
|
org.opencontainers.image.version="${VERSION}" \
|
||||||
|
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||||
|
org.opencontainers.image.revision="${VCS_REF}" \
|
||||||
|
org.opencontainers.image.source="https://github.com/wheesys/moodist" \
|
||||||
|
org.opencontainers.image.licenses="MIT"
|
||||||
95
README.en.md
Normal file
95
README.en.md
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
## 🌍 Language / 语言
|
||||||
|
|
||||||
|
**[English](README.en.md)** | **[简体中文](README.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="/assets/banner.png" alt="Moodist Logo Banner" />
|
||||||
|
<h2>Moodist 🌲</h2>
|
||||||
|
<p>Ambient sounds for focus and calm.</p>
|
||||||
|
<a href="https://moodist.mvze.net">Visit <strong>Moodist</strong></a> | <a href="https://buymeacoffee.com/remvze">Buy Me a Coffee</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- ⚡ [Features](#features)
|
||||||
|
- 🧰 [Tools](#tools)
|
||||||
|
- 🔮 [Commands](#commands)
|
||||||
|
- 🚧 [Contributing](#contributing)
|
||||||
|
- ⭐ [Support](#support-moodist)
|
||||||
|
- 📜 [License](#license)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
1. 🎵 Over 75 ambient sounds.
|
||||||
|
1. 📝 Persistent sound selection.
|
||||||
|
1. ✈️ Sharing sound selections with others.
|
||||||
|
1. 🧰 Custom sound presets.
|
||||||
|
1. 🌙 Sleep timer for sounds.
|
||||||
|
1. 📓 Notepad for quick notes.
|
||||||
|
1. 🍅 Pomodoro timer.
|
||||||
|
1. ✅ Simple to-do list (soon).
|
||||||
|
1. ⏯️ Media controls.
|
||||||
|
1. ⌨️ Keyboard shortcuts for everything.
|
||||||
|
1. 🥷 Privacy focused: no data collection.
|
||||||
|
1. 💰 Completely free, open-source, and self-hostable.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
- ⚡ **TypeScript**: Programming Language
|
||||||
|
- 🔨 **React**: UI Library
|
||||||
|
- 🧑🚀 **Astro**: Meta Framework
|
||||||
|
- 🎨 **CSS Modules**: Styling
|
||||||
|
- 🐻 **Zustand**: State Management
|
||||||
|
- 🎭 **Framer Motion**: Animation Library
|
||||||
|
- ⚙️ **Radix**: Accessible Components
|
||||||
|
- 📕 **Storybook**: Component Documentation
|
||||||
|
- 🧪 **Vitest**: Unit Testing (soon)
|
||||||
|
- 🔭 **Playwright**: End-To-End Testing (soon)
|
||||||
|
- 🔍 **ESLint**: Code Linting
|
||||||
|
- 🧹 **Prettier**: Code Formatting
|
||||||
|
- 🧼 **Stylelint**: CSS Linting
|
||||||
|
- 🐶 **Husky**: Git Hooks
|
||||||
|
- 📝 **Lint Staged**: Running Linters on Staged Files
|
||||||
|
- 🧽 **Commitlint**: Git Commit Linting
|
||||||
|
- 🧭 **Commitizen**: Git Commit Message Helper
|
||||||
|
- 📓 **Standard Version**: Versioning and CHANGLOG Generation
|
||||||
|
- 🧰 **PostCSS**: CSS Transformations
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- `npm run dev`: run development server
|
||||||
|
- `npm run build`: build for production
|
||||||
|
- `npm run preview`: preview the built app
|
||||||
|
- `npm run lint`: lint files using ESLint
|
||||||
|
- `npm run lint:fix`: lint and fix using ESLint
|
||||||
|
- `npm run lint:style`: lint styles using Stylelint
|
||||||
|
- `npm run lint:style:fix`: lint and fix styles using Stylelint
|
||||||
|
- `npm run format`: format files using Prettier
|
||||||
|
- `npm run commit`: commit message using Commitizen
|
||||||
|
- `npm run release:major`: release major version
|
||||||
|
- `npm run release:minor`: release minor version
|
||||||
|
- `npm run release:patch`: release patch version
|
||||||
|
- `npm run storybook`: run Storybook
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
🚧 Please check [CONTRIBUTING.md](CONTRIBUTING.md) file.
|
||||||
|
|
||||||
|
## Support Moodist
|
||||||
|
|
||||||
|
⭐ Give a star if you liked this project.
|
||||||
|
|
||||||
|
☕ [Buy Me a Coffee](https://buymeacoffee.com/remvze) to help me maintain Moodist.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
### ⚠️ Third-Party Assets
|
||||||
|
|
||||||
|
Some sounds used in this project are sourced from third-party providers and **are subject to different licenses**:
|
||||||
|
|
||||||
|
- Sounds licensed under the **Pixabay Content License**: [Pixabay Content License](https://pixabay.com/service/license-summary/)
|
||||||
|
- Sounds licensed under **CC0**: [Creative Commons Zero License](https://creativecommons.org/publicdomain/zero/1.0/)
|
||||||
208
README.md
208
README.md
|
|
@ -1,35 +1,157 @@
|
||||||
|
## 🌍 Language / 语言
|
||||||
|
|
||||||
|
**[English](README.en.md)** | **[简体中文](README.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="/assets/banner.png" alt="Moodist Logo Banner" />
|
<img src="/assets/banner.png" alt="Moodist Logo Banner" />
|
||||||
<h2>Moodist 🌲</h2>
|
<h2>Moodist 🌲</h2>
|
||||||
<p>Ambient sounds for focus and calm.</p>
|
<p>环境音工具,助你专注与平静</p>
|
||||||
<a href="https://moodist.mvze.net">Visit <strong>Moodist</strong></a> | <a href="https://buymeacoffee.com/remvze">Buy Me a Coffee</a>
|
<a href="https://moodist.mvze.net">访问 <strong>Moodist</strong></a> | <a href="https://calm.zlext.com/">在线体验地址</a> | <a href="https://buymeacoffee.com/remvze">支持开发者</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Table of Contents
|
## 目录
|
||||||
|
|
||||||
- ⚡ [Features](#features)
|
- ⚡ [功能特性](#features)
|
||||||
- 🧰 [Tools](#tools)
|
- 🎮 [使用说明](#-使用说明)
|
||||||
- 🔮 [Commands](#commands)
|
- 🐳 [Docker 部署](#-docker-部署)
|
||||||
- 🚧 [Contributing](#contributing)
|
- 🌐 [在线体验](#-在线体验)
|
||||||
- ⭐ [Support](#support-moodist)
|
- 🧰 [技术栈](#tools)
|
||||||
- 📜 [License](#license)
|
- 🔮 [命令](#commands)
|
||||||
|
- 🚧 [贡献指南](#contributing)
|
||||||
|
- ⭐ [支持项目](#support-moodist)
|
||||||
|
- 📜 [许可证](#license)
|
||||||
|
|
||||||
## Features
|
## 功能特性
|
||||||
|
|
||||||
1. 🎵 Over 75 ambient sounds.
|
1. 🎵 75+ 种环境音效
|
||||||
1. 📝 Persistent sound selection.
|
2. 📝 声音选择持久化存储
|
||||||
1. ✈️ Sharing sound selections with others.
|
3. ✈️ 分享声音组合给他人
|
||||||
1. 🧰 Custom sound presets.
|
4. 🧰 自定义声音预设
|
||||||
1. 🌙 Sleep timer for sounds.
|
5. 🌙 声音睡眠定时器
|
||||||
1. 📓 Notepad for quick notes.
|
6. 📓 便签快速记录
|
||||||
1. 🍅 Pomodoro timer.
|
7. 🍅 番茄钟计时器
|
||||||
1. ✅ Simple to-do list (soon).
|
8. ✅ 简单待办事项(即将推出)
|
||||||
1. ⏯️ Media controls.
|
9. ⏯️ 媒体控制键
|
||||||
1. ⌨️ Keyboard shortcuts for everything.
|
10. ⌨️ 全功能快捷键支持
|
||||||
1. 🥷 Privacy focused: no data collection.
|
11. 🥷 隐私保护:无数据收集
|
||||||
1. 💰 Completely free, open-source, and self-hostable.
|
12. 💰 完全免费、开源、可自托管
|
||||||
|
|
||||||
## Tools
|
## 🎮 使用说明
|
||||||
|
|
||||||
|
### 基本操作
|
||||||
|
- **播放/暂停声音**:点击声音卡片即可播放,再次点击暂停
|
||||||
|
- **音量调节**:拖动声音卡片下方的音量进度条
|
||||||
|
- **速度调节**:拖动第二条进度条调整播放速度
|
||||||
|
- **音调调节**:拖动第三条进度条调整音调(Rate)
|
||||||
|
|
||||||
|
### 高级功能
|
||||||
|
- **收藏功能**:点击声音卡片右上角的❤️图标收藏常用声音
|
||||||
|
- **随机效果**:点击❤️下方的🔀图标启用随机变化:
|
||||||
|
- 每次只随机调整一个参数(速度/音调/音量)
|
||||||
|
- 随机变化频率约为1分钟一次
|
||||||
|
- 速度和音调:默认值 ±0.25 范围内随机
|
||||||
|
- 音量:30%-70% 范围内随机
|
||||||
|
- **键盘快捷键**:
|
||||||
|
- 空格键:播放/暂停
|
||||||
|
- 方向键:调节选中声音的音量
|
||||||
|
- 数字键:快速选择声音
|
||||||
|
|
||||||
|
### 主题切换
|
||||||
|
- **昼夜模式**:点击右上角的🌞/🌙按钮切换主题
|
||||||
|
- **自动适配**:系统会根据您的设备主题自动选择合适的颜色方案
|
||||||
|
- **全面适配**:主题切换会影响整个页面背景及所有组件的颜色
|
||||||
|
|
||||||
|
## 🐳 Docker 部署
|
||||||
|
|
||||||
|
### 使用 Docker Compose(推荐)
|
||||||
|
|
||||||
|
1. **克隆项目**
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/your-username/moodist.git
|
||||||
|
cd moodist
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **创建 docker-compose.yml 文件**
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
moodist:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "4321:4321"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **启动服务**
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **访问应用**
|
||||||
|
|
||||||
|
打开浏览器访问:http://localhost:4321
|
||||||
|
|
||||||
|
### 使用 Docker 命令
|
||||||
|
|
||||||
|
1. **构建镜像**
|
||||||
|
```bash
|
||||||
|
docker build -t moodist .
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **运行容器**
|
||||||
|
```bash
|
||||||
|
docker run -d -p 4321:4321 --name moodist moodist
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **访问应用**
|
||||||
|
|
||||||
|
打开浏览器访问:http://localhost:4321
|
||||||
|
|
||||||
|
### 生产环境部署
|
||||||
|
|
||||||
|
对于生产环境,建议使用反向代理(如 Nginx)并配置 HTTPS:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com;
|
||||||
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
ssl_certificate /path/to/certificate.crt;
|
||||||
|
ssl_certificate_key /path/to/private.key;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:4321;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 环境变量配置
|
||||||
|
|
||||||
|
- `NODE_ENV`: 设置为 `production` 以启用生产模式优化
|
||||||
|
- `PORT`: 应用运行端口(默认:4321)
|
||||||
|
|
||||||
|
## 🌐 在线体验
|
||||||
|
|
||||||
|
- **官方站点**:https://moodist.mvze.net
|
||||||
|
- **体验地址**:https://calm.zlext.com(可直接使用)
|
||||||
|
- **完全免费**:无需注册,即开即用
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
- ⚡ **TypeScript**: Programming Language
|
- ⚡ **TypeScript**: Programming Language
|
||||||
- 🔨 **React**: UI Library
|
- 🔨 **React**: UI Library
|
||||||
|
|
@ -51,33 +173,33 @@
|
||||||
- 📓 **Standard Version**: Versioning and CHANGLOG Generation
|
- 📓 **Standard Version**: Versioning and CHANGLOG Generation
|
||||||
- 🧰 **PostCSS**: CSS Transformations
|
- 🧰 **PostCSS**: CSS Transformations
|
||||||
|
|
||||||
## Commands
|
## 命令
|
||||||
|
|
||||||
- `npm run dev`: run development server
|
- `npm run dev`: 启动开发服务器
|
||||||
- `npm run build`: build for production
|
- `npm run build`: 构建生产版本
|
||||||
- `npm run preview`: preview the built app
|
- `npm run preview`: 预览构建的应用
|
||||||
- `npm run lint`: lint files using ESLint
|
- `npm run lint`: 使用 ESLint 检查代码
|
||||||
- `npm run lint:fix`: lint and fix using ESLint
|
- `npm run lint:fix`: 使用 ESLint 检查并修复代码
|
||||||
- `npm run lint:style`: lint styles using Stylelint
|
- `npm run lint:style`: 使用 Stylelint 检查样式
|
||||||
- `npm run lint:style:fix`: lint and fix styles using Stylelint
|
- `npm run lint:style:fix`: 使用 Stylelint 检查并修复样式
|
||||||
- `npm run format`: format files using Prettier
|
- `npm run format`: 使用 Prettier 格式化代码
|
||||||
- `npm run commit`: commit message using Commitizen
|
- `npm run commit`: 使用 Commitizen 提交代码
|
||||||
- `npm run release:major`: release major version
|
- `npm run release:major`: 发布主版本
|
||||||
- `npm run release:minor`: release minor version
|
- `npm run release:minor`: 发布次版本
|
||||||
- `npm run release:patch`: release patch version
|
- `npm run release:patch`: 发布补丁版本
|
||||||
- `npm run storybook`: run Storybook
|
- `npm run storybook`: 运行 Storybook
|
||||||
|
|
||||||
## Contributing
|
## 贡献指南
|
||||||
|
|
||||||
🚧 Please check [CONTRIBUTING.md](CONTRIBUTING.md) file.
|
🚧 请查看 [CONTRIBUTING.md](CONTRIBUTING.md) 文件。
|
||||||
|
|
||||||
## Support Moodist
|
## 支持项目
|
||||||
|
|
||||||
⭐ Give a star if you liked this project.
|
⭐ 如果您喜欢这个项目,请给我们一个星标。
|
||||||
|
|
||||||
☕ [Buy Me a Coffee](https://buymeacoffee.com/remvze) to help me maintain Moodist.
|
☕ [请我喝咖啡](https://buymeacoffee.com/remvze) 来帮助我维护 Moodist。
|
||||||
|
|
||||||
## License
|
## 许可证
|
||||||
|
|
||||||
This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details.
|
This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
|
|
||||||
168
README.zh-CN.md
Normal file
168
README.zh-CN.md
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
## 🌍 Language / 语言
|
||||||
|
|
||||||
|
**[English](README.md)** | **[简体中文](README.zh-CN.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="/assets/banner.png" alt="Moodist Logo Banner" />
|
||||||
|
<h2>Moodist 🌲</h2>
|
||||||
|
<p>环境音生成器 - 专注与平静的声音伴侣。</p>
|
||||||
|
<a href="https://moodist.mvze.net">访问 <strong>Moodist</strong></a> | <a href="https://buymeacoffee.com/remvze">请我喝杯咖啡</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- ⚡ [功能特性](#功能特性)
|
||||||
|
- 🚀 [快速开始](#快速开始)
|
||||||
|
- 🐳 [Docker 部署](#docker-部署)
|
||||||
|
- 🧰 [技术工具](#技术工具)
|
||||||
|
- 🔮 [命令说明](#命令说明)
|
||||||
|
- 🚧 [贡献指南](#贡献指南)
|
||||||
|
- ⭐ [支持项目](#支持-moodist)
|
||||||
|
- 📜 [许可证](#许可证)
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
1. 🎵 超过 75 种环境音
|
||||||
|
1. 📝 持久化的声音选择
|
||||||
|
1. ✈️ 与他人分享声音选择
|
||||||
|
1. 🧰 自定义声音预设
|
||||||
|
1. 🌙 睡眠定时器
|
||||||
|
1. 📓 快速记事本
|
||||||
|
1. 🍅 番茄钟计时器
|
||||||
|
1. ✅ 简单的待办事项列表(即将推出)
|
||||||
|
1. ⏯️ 媒体控制
|
||||||
|
1. ⌨️ 全功能键盘快捷键
|
||||||
|
1. 🥷 注重隐私:不收集任何数据
|
||||||
|
1. 💰 完全免费、开源、可自托管
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 本地开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 克隆项目
|
||||||
|
git clone https://github.com/wheesys/moodist.git
|
||||||
|
cd moodist
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 启动开发服务器
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 [http://localhost:4321](http://localhost:4321) 查看应用。
|
||||||
|
|
||||||
|
### 构建生产版本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建应用
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 预览构建结果
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐳 Docker 部署
|
||||||
|
|
||||||
|
### 直接运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 拉取镜像
|
||||||
|
docker pull walllee/moodist:latest
|
||||||
|
|
||||||
|
# 运行容器
|
||||||
|
docker run -d \
|
||||||
|
--name moodist \
|
||||||
|
-p 8080:8080 \
|
||||||
|
--restart unless-stopped \
|
||||||
|
walllee/moodist:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用 Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 简单版本
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 或使用优化版本
|
||||||
|
docker-compose -f docker-compose.optimized.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 [http://localhost:8080](http://localhost:8080) 查看应用。
|
||||||
|
|
||||||
|
### 自定义构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建镜像
|
||||||
|
npm run docker:push
|
||||||
|
|
||||||
|
# 带版本号构建
|
||||||
|
./scripts/build-docker-simple.sh 2.1.0
|
||||||
|
|
||||||
|
# 构建并推送到 Docker Hub
|
||||||
|
npm run docker:push-and-upload
|
||||||
|
```
|
||||||
|
|
||||||
|
📖 详细部署指南请查看 [DOCKER_DEPLOY.md](DOCKER_DEPLOY.md)
|
||||||
|
|
||||||
|
## 技术工具
|
||||||
|
|
||||||
|
- ⚡ **TypeScript**: 编程语言
|
||||||
|
- 🔨 **React**: UI 库
|
||||||
|
- 🧑🚀 **Astro**: 元框架
|
||||||
|
- 🎨 **CSS Modules**: 样式方案
|
||||||
|
- 🐻 **Zustand**: 状态管理
|
||||||
|
- 🎭 **Framer Motion**: 动画库
|
||||||
|
- ⚙️ **Radix**: 无障碍组件
|
||||||
|
- 📕 **Storybook**: 组件文档
|
||||||
|
- 🧪 **Vitest**: 单元测试(即将推出)
|
||||||
|
- 🔭 **Playwright**: 端到端测试(即将推出)
|
||||||
|
- 🔍 **ESLint**: 代码检查
|
||||||
|
- 🧹 **Prettier**: 代码格式化
|
||||||
|
- 🧼 **Stylelint**: CSS 检查
|
||||||
|
- 🐶 **Husky**: Git 钩子
|
||||||
|
- 📝 **Lint Staged**: 暂存文件检查器
|
||||||
|
- 🧽 **Commitlint**: Git 提交信息检查
|
||||||
|
- 🧭 **Commitizen**: Git 提交信息助手
|
||||||
|
- 📓 **Standard Version**: 版本管理和更新日志生成
|
||||||
|
- 🧰 **PostCSS**: CSS 转换
|
||||||
|
|
||||||
|
## 命令说明
|
||||||
|
|
||||||
|
- `npm run dev`: 运行开发服务器
|
||||||
|
- `npm run build`: 构建生产版本
|
||||||
|
- `npm run preview`: 预览构建的应用
|
||||||
|
- `npm run lint`: 使用 ESLint 检查代码
|
||||||
|
- `npm run lint:fix`: 使用 ESLint 检查并修复代码
|
||||||
|
- `npm run lint:style`: 使用 Stylelint 检查样式
|
||||||
|
- `npm run lint:style:fix`: 使用 Stylelint 检查并修复样式
|
||||||
|
- `npm run format`: 使用 Prettier 格式化文件
|
||||||
|
- `npm run commit`: 使用 Commitizen 提交代码
|
||||||
|
- `npm run release:major`: 发布主版本
|
||||||
|
- `npm run release:minor`: 发布次版本
|
||||||
|
- `npm run release:patch`: 发布补丁版本
|
||||||
|
- `npm run storybook`: 运行 Storybook
|
||||||
|
|
||||||
|
## 贡献指南
|
||||||
|
|
||||||
|
🚧 请查看 [CONTRIBUTING.md](CONTRIBUTING.md) 文件。
|
||||||
|
|
||||||
|
## 支持 Moodist
|
||||||
|
|
||||||
|
⭐ 如果您喜欢这个项目,请给它一个星标。
|
||||||
|
|
||||||
|
☕ [请我喝杯咖啡](https://buymeacoffee.com/remvze) 来帮助我维护 Moodist。
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
本项目基于 **MIT 许可证** - 详情请查看 [LICENSE](LICENSE) 文件。
|
||||||
|
|
||||||
|
### ⚠️ 第三方资源
|
||||||
|
|
||||||
|
本项目使用的部分声音来源于第三方提供商,并**遵循不同的许可证**:
|
||||||
|
|
||||||
|
- 遵循 **Pixabay 内容许可证** 的声音:[Pixabay 内容许可证](https://pixabay.com/service/license-summary/)
|
||||||
|
- 遵循 **CC0** 的声音:[知识共享署名许可协议](https://creativecommons.org/publicdomain/zero/1.0/)
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from 'astro/config';
|
||||||
|
|
||||||
import react from '@astrojs/react';
|
import react from '@astrojs/react';
|
||||||
|
import node from '@astrojs/node';
|
||||||
import AstroPWA from '@vite-pwa/astro';
|
import AstroPWA from '@vite-pwa/astro';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
output: 'server',
|
||||||
|
adapter: node({
|
||||||
|
mode: 'standalone'
|
||||||
|
}),
|
||||||
integrations: [
|
integrations: [
|
||||||
react(),
|
react(),
|
||||||
AstroPWA({
|
AstroPWA({
|
||||||
|
|
@ -33,4 +38,9 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
vite: {
|
||||||
|
define: {
|
||||||
|
global: 'globalThis',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
BIN
data/users.db
Normal file
BIN
data/users.db
Normal file
Binary file not shown.
BIN
data/users.db-shm
Normal file
BIN
data/users.db-shm
Normal file
Binary file not shown.
BIN
data/users.db-wal
Normal file
BIN
data/users.db-wal
Normal file
Binary file not shown.
90
docker-compose.dev.yml
Normal file
90
docker-compose.dev.yml
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
moodist-dev:
|
||||||
|
# 构建上下文
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.optimized
|
||||||
|
target: builder # 只构建到builder阶段用于开发
|
||||||
|
args:
|
||||||
|
- NODE_ENV=development
|
||||||
|
- BUILD_DATE=${BUILD_DATE:-$(date -u +'%Y-%m-%dT%H:%M:%SZ')}
|
||||||
|
- VERSION=dev
|
||||||
|
- VCS_REF=${VCS_REF:-dev}
|
||||||
|
|
||||||
|
container_name: moodist-dev
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# 开发端口映射
|
||||||
|
ports:
|
||||||
|
- "3000:3000" # Astro开发服务器
|
||||||
|
- "8080:8080" # 预览服务器
|
||||||
|
|
||||||
|
# 开发环境变量
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=development
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
# 卷挂载用于开发
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- /app/node_modules # 防止node_modules被覆盖
|
||||||
|
- moodist-dist:/app/dist
|
||||||
|
# 挂载 SQLite 数据库文件目录
|
||||||
|
- ./data:/app/data:rw
|
||||||
|
|
||||||
|
# 工作目录
|
||||||
|
working_dir: /app
|
||||||
|
|
||||||
|
# 开发命令
|
||||||
|
command: npm run dev
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:3000/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
# 开发资源配置
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '1.0'
|
||||||
|
memory: 512M
|
||||||
|
reservations:
|
||||||
|
cpus: '0.25'
|
||||||
|
memory: 128M
|
||||||
|
|
||||||
|
# 日志配置
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "5m"
|
||||||
|
max-file: "2"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- moodist-network
|
||||||
|
|
||||||
|
# 开发工具容器
|
||||||
|
dev-tools:
|
||||||
|
image: node:20-alpine
|
||||||
|
container_name: moodist-dev-tools
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
networks:
|
||||||
|
- moodist-network
|
||||||
|
profiles:
|
||||||
|
- tools
|
||||||
|
command: sh -c "npm install && tail -f /dev/null"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
moodist-dist:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
moodist-network:
|
||||||
|
driver: bridge
|
||||||
95
docker-compose.optimized.yml
Normal file
95
docker-compose.optimized.yml
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
moodist:
|
||||||
|
# 使用优化的镜像名称
|
||||||
|
image: walllee/moodist:latest
|
||||||
|
container_name: moodist-app
|
||||||
|
|
||||||
|
# 重启策略
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# 端口映射
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
|
||||||
|
# 环境变量
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8080/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
# 资源限制
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '0.5'
|
||||||
|
memory: 128M
|
||||||
|
reservations:
|
||||||
|
cpus: '0.1'
|
||||||
|
memory: 32M
|
||||||
|
|
||||||
|
# 日志配置
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# 网络配置
|
||||||
|
networks:
|
||||||
|
- moodist-network
|
||||||
|
|
||||||
|
# 安全选项
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
# 数据卷挂载
|
||||||
|
volumes:
|
||||||
|
# 挂载 SQLite 数据库文件目录(需要读写权限)
|
||||||
|
- ./data:/app/data:rw
|
||||||
|
# 挂载临时目录用于 SQLite WAL 文件
|
||||||
|
- moodist-temp:/tmp:rw
|
||||||
|
|
||||||
|
# 只读根文件系统(除了数据目录)
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /var/cache/nginx
|
||||||
|
- /var/run
|
||||||
|
- /var/log/nginx
|
||||||
|
|
||||||
|
# Nginx反向代理(可选)
|
||||||
|
nginx-proxy:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: moodist-nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./docker/nginx-proxy/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./docker/nginx-proxy/ssl:/etc/nginx/ssl:ro
|
||||||
|
depends_on:
|
||||||
|
- moodist
|
||||||
|
networks:
|
||||||
|
- moodist-network
|
||||||
|
profiles:
|
||||||
|
- proxy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
moodist-temp:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
moodist-network:
|
||||||
|
driver: bridge
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: 172.20.0.0/16
|
||||||
|
|
@ -1,10 +1,26 @@
|
||||||
version: '3.9'
|
|
||||||
services:
|
services:
|
||||||
moodist:
|
moodist:
|
||||||
image: ghcr.io/remvze/moodist
|
image: walllee/moodist:latest
|
||||||
logging:
|
logging:
|
||||||
options:
|
options:
|
||||||
max-size: 1g
|
max-size: 1g
|
||||||
restart: always
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- '8080:8080'
|
- '11081:8080'
|
||||||
|
volumes:
|
||||||
|
# 挂载源代码用于热重载(保持用户权限)
|
||||||
|
- .:/app:cached
|
||||||
|
# 使用独立的 node_modules 避免权限冲突
|
||||||
|
- node_modules_volume:/app/node_modules
|
||||||
|
# 挂载 SQLite 数据库文件和 WAL 文件
|
||||||
|
- ./data:/app/data:rw
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=development
|
||||||
|
- PORT=8080
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
# 启动时确保安装了必要的开发依赖
|
||||||
|
command: sh -c "cd /app && npm install @astrojs/node autoprefixer --no-save && npm run dev -- --host 0.0.0.0 --port 8080"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
node_modules_volume:
|
||||||
|
|
|
||||||
113
docker-database-mount.md
Normal file
113
docker-database-mount.md
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
# Docker 数据库挂载说明
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本项目已配置 SQLite 数据库文件挂载,确保数据在容器重启后不会丢失。
|
||||||
|
|
||||||
|
## 数据库文件位置
|
||||||
|
|
||||||
|
SQLite 数据库文件位于项目的 `./data` 目录中:
|
||||||
|
- `./data/users.db` - 主数据库文件
|
||||||
|
- `./data/users.db-wal` - Write-Ahead Log 文件
|
||||||
|
- `./data/users.db-shm` - 共享内存文件
|
||||||
|
|
||||||
|
## Docker Compose 配置
|
||||||
|
|
||||||
|
### 1. 基础配置 (`docker-compose.yml`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
moodist:
|
||||||
|
volumes:
|
||||||
|
# 挂载 SQLite 数据库文件和 WAL 文件
|
||||||
|
- ./data:/app/data:rw
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 优化配置 (`docker-compose.optimized.yml`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
moodist:
|
||||||
|
volumes:
|
||||||
|
# 挂载 SQLite 数据库文件目录(需要读写权限)
|
||||||
|
- ./data:/app/data:rw
|
||||||
|
# 挂载临时目录用于 SQLite WAL 文件
|
||||||
|
- moodist-temp:/tmp:rw
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
moodist-temp:
|
||||||
|
driver: local
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 开发配置 (`docker-compose.dev.yml`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
moodist-dev:
|
||||||
|
volumes:
|
||||||
|
# 挂载 SQLite 数据库文件目录
|
||||||
|
- ./data:/app/data:rw
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 生产环境
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 优化环境
|
||||||
|
docker-compose -f docker-compose.optimized.yml up -d
|
||||||
|
|
||||||
|
# 开发环境
|
||||||
|
docker-compose -f docker-compose.dev.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据持久化
|
||||||
|
|
||||||
|
- 数据库文件会自动创建在 `./data` 目录中
|
||||||
|
- 容器重启或重新创建后数据不会丢失
|
||||||
|
- 支持数据库备份和迁移
|
||||||
|
|
||||||
|
### 备份数据库
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 备份数据库
|
||||||
|
cp ./data/users.db ./data/users.db.backup.$(date +%Y%m%d_%H%M%S)
|
||||||
|
|
||||||
|
# 查看数据库文件
|
||||||
|
ls -la ./data/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **权限问题**: 确保 `./data` 目录有正确的读写权限
|
||||||
|
2. **WAL 模式**: SQLite 使用 WAL (Write-Ahead Logging) 模式,会产生额外的 WAL 和 SHM 文件
|
||||||
|
3. **并发访问**: Docker 挂载确保文件系统的一致性
|
||||||
|
4. **备份策略**: 建议定期备份数据库文件
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
### 数据库锁定问题
|
||||||
|
如果遇到数据库锁定错误:
|
||||||
|
1. 停止容器:`docker-compose down`
|
||||||
|
2. 删除 WAL 文件:`rm ./data/users.db-wal ./data/users.db-shm`
|
||||||
|
3. 重新启动容器:`docker-compose up -d`
|
||||||
|
|
||||||
|
### 权限问题
|
||||||
|
如果遇到权限错误:
|
||||||
|
```bash
|
||||||
|
# 设置正确的目录权限
|
||||||
|
sudo chown -R 1000:1000 ./data
|
||||||
|
chmod 755 ./data
|
||||||
|
```
|
||||||
|
|
||||||
|
## 开发环境注意事项
|
||||||
|
|
||||||
|
开发环境中,数据库文件会被实时同步到本地文件系统,便于:
|
||||||
|
- 调试和测试
|
||||||
|
- 数据分析
|
||||||
|
- 快速重置测试数据
|
||||||
1206
package-lock.json
generated
1206
package-lock.json
generated
File diff suppressed because it is too large
Load diff
19
package.json
19
package.json
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "moodist",
|
"name": "moodist",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "2.1.0",
|
"version": "3.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"start": "astro dev",
|
"start": "astro dev",
|
||||||
|
|
@ -21,7 +21,14 @@
|
||||||
"release:minor": "npm run release -- --release-as minor",
|
"release:minor": "npm run release -- --release-as minor",
|
||||||
"release:patch": "npm run release -- --release-as patch",
|
"release:patch": "npm run release -- --release-as patch",
|
||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
"build-storybook": "storybook build"
|
"build-storybook": "storybook build",
|
||||||
|
"docker:build": "./scripts/build-local.sh",
|
||||||
|
"docker:push": "./scripts/build-docker-simple.sh",
|
||||||
|
"docker:push-and-upload": "./scripts/build-docker-simple.sh latest push",
|
||||||
|
"docker:multi": "./scripts/build-docker-compatible.sh",
|
||||||
|
"docker:dev": "docker-compose -f docker-compose.dev.yml up -d",
|
||||||
|
"docker:prod": "docker-compose -f docker-compose.optimized.yml up -d",
|
||||||
|
"docker:logs": "docker-compose -f docker-compose.optimized.yml logs -f"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/react": "3.6.0",
|
"@astrojs/react": "3.6.0",
|
||||||
|
|
@ -31,20 +38,27 @@
|
||||||
"@radix-ui/react-dropdown-menu": "2.0.6",
|
"@radix-ui/react-dropdown-menu": "2.0.6",
|
||||||
"@radix-ui/react-slider": "1.2.3",
|
"@radix-ui/react-slider": "1.2.3",
|
||||||
"@radix-ui/react-tooltip": "1.2.8",
|
"@radix-ui/react-tooltip": "1.2.8",
|
||||||
|
"@types/bcryptjs": "2.4.6",
|
||||||
|
"@types/better-sqlite3": "7.6.13",
|
||||||
"@types/howler": "2.2.10",
|
"@types/howler": "2.2.10",
|
||||||
"@types/react": "^18.2.25",
|
"@types/react": "^18.2.25",
|
||||||
"@types/react-dom": "^18.2.10",
|
"@types/react-dom": "^18.2.10",
|
||||||
"@vite-pwa/astro": "0.5.0",
|
"@vite-pwa/astro": "0.5.0",
|
||||||
"astro": "4.10.3",
|
"astro": "4.10.3",
|
||||||
|
"astro-i18next": "1.0.0-beta.21",
|
||||||
|
"bcryptjs": "3.0.3",
|
||||||
|
"better-sqlite3": "11.10.0",
|
||||||
"deepmerge": "4.3.1",
|
"deepmerge": "4.3.1",
|
||||||
"focus-trap-react": "10.2.3",
|
"focus-trap-react": "10.2.3",
|
||||||
"framer-motion": "10.16.4",
|
"framer-motion": "10.16.4",
|
||||||
"howler": "2.2.4",
|
"howler": "2.2.4",
|
||||||
|
"i18next": "25.6.2",
|
||||||
"js-confetti": "0.12.0",
|
"js-confetti": "0.12.0",
|
||||||
"motion": "12.23.24",
|
"motion": "12.23.24",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hotkeys-hook": "3.2.1",
|
"react-hotkeys-hook": "3.2.1",
|
||||||
|
"react-i18next": "16.3.3",
|
||||||
"react-icons": "4.11.0",
|
"react-icons": "4.11.0",
|
||||||
"react-wrap-balancer": "1.1.0",
|
"react-wrap-balancer": "1.1.0",
|
||||||
"react-youtube": "10.1.0",
|
"react-youtube": "10.1.0",
|
||||||
|
|
@ -52,6 +66,7 @@
|
||||||
"zustand": "4.4.3"
|
"zustand": "4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@astrojs/node": "8.3.4",
|
||||||
"@chromatic-com/storybook": "1.3.3",
|
"@chromatic-com/storybook": "1.3.3",
|
||||||
"@commitlint/cli": "17.7.2",
|
"@commitlint/cli": "17.7.2",
|
||||||
"@commitlint/config-conventional": "17.7.0",
|
"@commitlint/config-conventional": "17.7.0",
|
||||||
|
|
|
||||||
152
scripts/build-docker-compatible.sh
Executable file
152
scripts/build-docker-compatible.sh
Executable file
|
|
@ -0,0 +1,152 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Moodist Docker 构建脚本 - 兼容版本
|
||||||
|
# 支持标准Docker和Docker Buildx(可选)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 配置变量
|
||||||
|
IMAGE_NAME="walllee/moodist"
|
||||||
|
VERSION=${1:-latest}
|
||||||
|
BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||||
|
VCS_REF=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||||
|
USE_BUILDX=${2:-false}
|
||||||
|
|
||||||
|
echo "🐳 开始构建 Moodist Docker 镜像..."
|
||||||
|
echo "📦 镜像名称: ${IMAGE_NAME}"
|
||||||
|
echo "🏷️ 版本标签: ${VERSION}"
|
||||||
|
echo "📅 构建时间: ${BUILD_DATE}"
|
||||||
|
echo "🔗 Git提交: ${VCS_REF}"
|
||||||
|
echo "🔧 使用Buildx: ${USE_BUILDX}"
|
||||||
|
|
||||||
|
# 检查Docker是否安装并运行
|
||||||
|
if ! docker info &> /dev/null; then
|
||||||
|
echo "❌ Docker未运行,请启动Docker服务"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 构建参数
|
||||||
|
BUILD_ARGS="--build-arg BUILD_DATE=${BUILD_DATE}"
|
||||||
|
BUILD_ARGS="${BUILD_ARGS} --build-arg VERSION=${VERSION}"
|
||||||
|
BUILD_ARGS="${BUILD_ARGS} --build-arg VCS_REF=${VCS_REF}"
|
||||||
|
BUILD_ARGS="${BUILD_ARGS} --build-arg NODE_ENV=production"
|
||||||
|
|
||||||
|
# 构建函数
|
||||||
|
build_image() {
|
||||||
|
local tag=$1
|
||||||
|
local push_flag=$2
|
||||||
|
local build_cmd="docker build"
|
||||||
|
|
||||||
|
if [ "$USE_BUILDX" = "true" ]; then
|
||||||
|
# 检查buildx是否可用
|
||||||
|
if docker buildx version &> /dev/null; then
|
||||||
|
echo "🔨 使用Docker Buildx构建..."
|
||||||
|
build_cmd="docker buildx build"
|
||||||
|
|
||||||
|
# 创建buildx构建器(如果不存在)
|
||||||
|
if ! docker buildx ls | grep -q "moodist-builder"; then
|
||||||
|
echo "🔨 创建Docker Buildx构建器..."
|
||||||
|
docker buildx create --name moodist-builder --use 2>/dev/null || true
|
||||||
|
docker buildx inspect --bootstrap 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 多平台构建参数
|
||||||
|
PLATFORM_ARGS="--platform linux/amd64,linux/arm64"
|
||||||
|
PUSH_FLAG="--push"
|
||||||
|
else
|
||||||
|
echo "⚠️ Docker Buildx不可用,回退到标准Docker构建..."
|
||||||
|
USE_BUILDX="false"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🏗️ 开始构建镜像: ${tag}"
|
||||||
|
|
||||||
|
if [ "$USE_BUILDX" = "true" ]; then
|
||||||
|
# 使用buildx
|
||||||
|
$build_cmd \
|
||||||
|
$PLATFORM_ARGS \
|
||||||
|
--tag "${tag}" \
|
||||||
|
$BUILD_ARGS \
|
||||||
|
--file ./Dockerfile.optimized \
|
||||||
|
$PUSH_FLAG \
|
||||||
|
.
|
||||||
|
else
|
||||||
|
# 使用标准Docker
|
||||||
|
$build_cmd \
|
||||||
|
--tag "${tag}" \
|
||||||
|
$BUILD_ARGS \
|
||||||
|
--file ./Dockerfile.optimized \
|
||||||
|
.
|
||||||
|
|
||||||
|
# 如果需要推送,使用标准Docker push
|
||||||
|
if [ "$push_flag" = "true" ]; then
|
||||||
|
echo "📤 推送镜像: ${tag}"
|
||||||
|
docker push "${tag}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查是否需要登录Docker Hub
|
||||||
|
if [ "$USE_BUILDX" = "true" ] || [ "${2}" = "push" ]; then
|
||||||
|
if ! docker info 2>/dev/null | grep -q "Username"; then
|
||||||
|
echo "⚠️ 未检测到Docker登录,请先运行: docker login"
|
||||||
|
echo "💡 如果您有Docker Hub账号,请使用以下命令登录:"
|
||||||
|
echo " docker login"
|
||||||
|
read -p "是否继续构建?(y/N): " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo "❌ 构建已取消"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 构建镜像
|
||||||
|
if [ "$USE_BUILDX" = "true" ]; then
|
||||||
|
# Buildx模式(支持多平台)
|
||||||
|
build_image "${IMAGE_NAME}:${VERSION}" true
|
||||||
|
|
||||||
|
# 如果指定了特定版本,同时创建version-specific标签
|
||||||
|
if [ "$VERSION" != "latest" ]; then
|
||||||
|
echo "🏷️ 添加版本标签: ${VERSION}"
|
||||||
|
build_image "${IMAGE_NAME}:${VERSION}" true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# 标准Docker模式
|
||||||
|
build_image "${IMAGE_NAME}:${VERSION}" false
|
||||||
|
|
||||||
|
# 如果指定了特定版本,创建额外的标签
|
||||||
|
if [ "$VERSION" != "latest" ]; then
|
||||||
|
docker tag "${IMAGE_NAME}:${VERSION}" "${IMAGE_NAME}:latest"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 如果需要推送
|
||||||
|
if [ "${2}" = "push" ]; then
|
||||||
|
echo "📤 推送镜像到Docker Hub..."
|
||||||
|
docker push "${IMAGE_NAME}:${VERSION}"
|
||||||
|
|
||||||
|
if [ "$VERSION" != "latest" ]; then
|
||||||
|
docker push "${IMAGE_NAME}:latest"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🎉 镜像构建完成!"
|
||||||
|
echo "📋 镜像信息:"
|
||||||
|
echo " 🔗 镜像名称: ${IMAGE_NAME}"
|
||||||
|
echo " 🏷️ 标签: ${VERSION}${VERSION != "latest" ? ", latest" : ""}"
|
||||||
|
echo " 📅 构建时间: ${BUILD_DATE}"
|
||||||
|
echo " 🔗 Git提交: ${VCS_REF}"
|
||||||
|
echo ""
|
||||||
|
echo "🚀 使用方法:"
|
||||||
|
echo " docker run -d -p 8080:8080 ${IMAGE_NAME}:${VERSION}"
|
||||||
|
echo ""
|
||||||
|
echo "📝 查看镜像信息:"
|
||||||
|
echo " docker images | grep ${IMAGE_NAME}"
|
||||||
|
echo " docker inspect ${IMAGE_NAME}:${VERSION}"
|
||||||
|
|
||||||
|
# 显示镜像大小
|
||||||
|
echo ""
|
||||||
|
echo "📊 镜像大小:"
|
||||||
|
docker images ${IMAGE_NAME}:${VERSION} --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"
|
||||||
111
scripts/build-docker-simple.sh
Executable file
111
scripts/build-docker-simple.sh
Executable file
|
|
@ -0,0 +1,111 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Moodist Docker 简化构建脚本
|
||||||
|
# 先本地构建,再打包成Docker镜像
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 配置变量
|
||||||
|
IMAGE_NAME="walllee/moodist"
|
||||||
|
VERSION=${1:-latest}
|
||||||
|
BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||||
|
VCS_REF=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||||
|
|
||||||
|
echo "🐳 开始构建 Moodist Docker 镜像(简化版)..."
|
||||||
|
echo "📦 镜像名称: ${IMAGE_NAME}"
|
||||||
|
echo "🏷️ 版本标签: ${VERSION}"
|
||||||
|
echo "📅 构建时间: ${BUILD_DATE}"
|
||||||
|
echo "🔗 Git提交: ${VCS_REF}"
|
||||||
|
|
||||||
|
# 检查Docker是否安装并运行
|
||||||
|
if ! docker info &> /dev/null; then
|
||||||
|
echo "❌ Docker未运行,请启动Docker服务"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 步骤1: 本地构建
|
||||||
|
echo ""
|
||||||
|
echo "📦 步骤 1: 本地构建应用..."
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "❌ 本地构建失败"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ 本地构建完成!"
|
||||||
|
|
||||||
|
# 步骤2: 构建Docker镜像
|
||||||
|
echo ""
|
||||||
|
echo "🐳 步骤 2: 构建 Docker镜像..."
|
||||||
|
|
||||||
|
# 构建参数
|
||||||
|
BUILD_ARGS="--build-arg BUILD_DATE=${BUILD_DATE}"
|
||||||
|
BUILD_ARGS="${BUILD_ARGS} --build-arg VERSION=${VERSION}"
|
||||||
|
BUILD_ARGS="${BUILD_ARGS} --build-arg VCS_REF=${VCS_REF}"
|
||||||
|
|
||||||
|
# 构建镜像
|
||||||
|
docker build \
|
||||||
|
${BUILD_ARGS} \
|
||||||
|
--tag "${IMAGE_NAME}:${VERSION}" \
|
||||||
|
--file ./Dockerfile.simple \
|
||||||
|
.
|
||||||
|
|
||||||
|
echo "✅ Docker镜像构建完成!"
|
||||||
|
|
||||||
|
# 步骤3: 可选推送
|
||||||
|
if [ "${2}" = "push" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "📤 步骤 3: 推送镜像到Docker Hub..."
|
||||||
|
|
||||||
|
# 检查是否登录Docker Hub
|
||||||
|
if ! docker info 2>/dev/null | grep -q "Username"; then
|
||||||
|
echo "⚠️ 未检测到Docker登录,请先运行: docker login"
|
||||||
|
read -p "是否继续推送?(y/N): " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo "❌ 推送已取消"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 推送镜像
|
||||||
|
docker push "${IMAGE_NAME}:${VERSION}"
|
||||||
|
|
||||||
|
# 如果指定了特定版本,同时创建latest标签
|
||||||
|
if [ "$VERSION" != "latest" ]; then
|
||||||
|
docker tag "${IMAGE_NAME}:${VERSION}" "${IMAGE_NAME}:latest"
|
||||||
|
docker push "${IMAGE_NAME}:latest"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ 镜像推送完成!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🎉 简化构建完成!"
|
||||||
|
echo "📋 镜像信息:"
|
||||||
|
echo " 🔗 镜像名称: ${IMAGE_NAME}"
|
||||||
|
if [ "$VERSION" != "latest" ]; then
|
||||||
|
echo " 🏷️ 标签: ${VERSION}, latest"
|
||||||
|
else
|
||||||
|
echo " 🏷️ 标签: ${VERSION}"
|
||||||
|
fi
|
||||||
|
echo " 📅 构建时间: ${BUILD_DATE}"
|
||||||
|
echo " 🔗 Git提交: ${VCS_REF}"
|
||||||
|
echo ""
|
||||||
|
echo "🚀 使用方法:"
|
||||||
|
echo " docker run -d -p 8080:8080 ${IMAGE_NAME}:${VERSION}"
|
||||||
|
echo ""
|
||||||
|
echo "📝 查看镜像信息:"
|
||||||
|
echo " docker images | grep ${IMAGE_NAME}"
|
||||||
|
echo " docker inspect ${IMAGE_NAME}:${VERSION}"
|
||||||
|
|
||||||
|
# 显示镜像大小
|
||||||
|
echo ""
|
||||||
|
echo "📊 镜像大小:"
|
||||||
|
docker images ${IMAGE_NAME}:${VERSION} --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"
|
||||||
|
|
||||||
|
# 显示构建产物大小
|
||||||
|
echo ""
|
||||||
|
echo "📦 构建产物大小:"
|
||||||
|
du -sh dist/ | tail -1
|
||||||
91
scripts/build-docker.sh
Executable file
91
scripts/build-docker.sh
Executable file
|
|
@ -0,0 +1,91 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Moodist Docker 构建和推送脚本
|
||||||
|
# 支持多平台构建并推送到 Docker Hub
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 配置变量
|
||||||
|
IMAGE_NAME="walllee/moodist"
|
||||||
|
VERSION=${1:-latest}
|
||||||
|
BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||||
|
VCS_REF=$(git rev-parse --short HEAD)
|
||||||
|
|
||||||
|
echo "🐳 开始构建 Moodist Docker 镜像..."
|
||||||
|
echo "📦 镜像名称: ${IMAGE_NAME}"
|
||||||
|
echo "🏷️ 版本标签: ${VERSION}"
|
||||||
|
echo "📅 构建时间: ${BUILD_DATE}"
|
||||||
|
echo "🔗 Git提交: ${VCS_REF}"
|
||||||
|
|
||||||
|
# 检查Docker是否安装并运行
|
||||||
|
if ! docker info &> /dev/null; then
|
||||||
|
echo "❌ Docker未运行,请启动Docker服务"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查是否登录Docker Hub
|
||||||
|
if ! docker info | grep -q "Username"; then
|
||||||
|
echo "⚠️ 未检测到Docker登录,请先运行: docker login"
|
||||||
|
echo "💡 如果您有Docker Hub账号,请使用以下命令登录:"
|
||||||
|
echo " docker login"
|
||||||
|
echo " # 输入您的用户名和密码或访问令牌"
|
||||||
|
read -p "是否继续构建?(y/N): " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo "❌ 构建已取消"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 创建buildx构建器(如果不存在)
|
||||||
|
if ! docker buildx ls | grep -q "moodist-builder"; then
|
||||||
|
echo "🔨 创建Docker Buildx构建器..."
|
||||||
|
docker buildx create --name moodist-builder --use
|
||||||
|
docker buildx inspect --bootstrap
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 构建参数
|
||||||
|
BUILD_ARGS="--build-arg BUILD_DATE=${BUILD_DATE}"
|
||||||
|
BUILD_ARGS="${BUILD_ARGS} --build-arg VERSION=${VERSION}"
|
||||||
|
BUILD_ARGS="${BUILD_ARGS} --build-arg VCS_REF=${VCS_REF}"
|
||||||
|
BUILD_ARGS="${BUILD_ARGS} --build-arg NODE_ENV=production"
|
||||||
|
|
||||||
|
echo "🏗️ 开始构建多平台镜像..."
|
||||||
|
|
||||||
|
# 构建并推送多平台镜像
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
--tag "${IMAGE_NAME}:${VERSION}" \
|
||||||
|
--tag "${IMAGE_NAME}:latest" \
|
||||||
|
${BUILD_ARGS} \
|
||||||
|
--file ./Dockerfile.optimized \
|
||||||
|
--push \
|
||||||
|
.
|
||||||
|
|
||||||
|
echo "✅ 构建完成!"
|
||||||
|
|
||||||
|
# 如果指定了特定版本,同时创建version-specific标签
|
||||||
|
if [ "$VERSION" != "latest" ]; then
|
||||||
|
echo "🏷️ 添加版本标签: ${VERSION}"
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
--tag "${IMAGE_NAME}:${VERSION}" \
|
||||||
|
${BUILD_ARGS} \
|
||||||
|
--file ./Dockerfile.optimized \
|
||||||
|
--push \
|
||||||
|
.
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🎉 镜像构建和推送完成!"
|
||||||
|
echo "📋 镜像信息:"
|
||||||
|
echo " 🔗 Docker Hub: https://hub.docker.com/r/${IMAGE_NAME}"
|
||||||
|
echo " 🏷️ 标签: ${VERSION}, latest"
|
||||||
|
echo " 🏗️ 平台: linux/amd64, linux/arm64"
|
||||||
|
echo ""
|
||||||
|
echo "🚀 使用方法:"
|
||||||
|
echo " docker run -d -p 8080:8080 ${IMAGE_NAME}:${VERSION}"
|
||||||
|
echo ""
|
||||||
|
echo "📝 查看镜像信息:"
|
||||||
|
echo " docker pull ${IMAGE_NAME}:${VERSION}"
|
||||||
|
echo " docker inspect ${IMAGE_NAME}:${VERSION}"
|
||||||
83
scripts/build-local.sh
Executable file
83
scripts/build-local.sh
Executable file
|
|
@ -0,0 +1,83 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Moodist Docker 本地构建脚本
|
||||||
|
# 用于本地测试和开发
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 配置变量
|
||||||
|
IMAGE_NAME="moodist-local"
|
||||||
|
VERSION=${1:-dev}
|
||||||
|
BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||||
|
VCS_REF=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||||
|
|
||||||
|
echo "🐳 开始本地构建 Moodist Docker 镜像..."
|
||||||
|
echo "📦 镜像名称: ${IMAGE_NAME}"
|
||||||
|
echo "🏷️ 版本标签: ${VERSION}"
|
||||||
|
echo "📅 构建时间: ${BUILD_DATE}"
|
||||||
|
echo "🔗 Git提交: ${VCS_REF}"
|
||||||
|
|
||||||
|
# 检查Docker是否安装并运行
|
||||||
|
if ! docker info &> /dev/null; then
|
||||||
|
echo "❌ Docker未运行,请启动Docker服务"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 构建参数
|
||||||
|
BUILD_ARGS="--build-arg BUILD_DATE=${BUILD_DATE}"
|
||||||
|
BUILD_ARGS="${BUILD_ARGS} --build-arg VERSION=${VERSION}"
|
||||||
|
BUILD_ARGS="${BUILD_ARGS} --build-arg VCS_REF=${VCS_REF}"
|
||||||
|
BUILD_ARGS="${BUILD_ARGS} --build-arg NODE_ENV=production"
|
||||||
|
|
||||||
|
echo "🏗️ 开始本地构建..."
|
||||||
|
|
||||||
|
# 构建本地镜像
|
||||||
|
docker build \
|
||||||
|
${BUILD_ARGS} \
|
||||||
|
--tag "${IMAGE_NAME}:${VERSION}" \
|
||||||
|
--file ./Dockerfile.optimized \
|
||||||
|
.
|
||||||
|
|
||||||
|
echo "✅ 本地构建完成!"
|
||||||
|
|
||||||
|
# 运行容器进行测试
|
||||||
|
echo "🧪 启动测试容器..."
|
||||||
|
|
||||||
|
# 停止并删除现有容器(如果存在)
|
||||||
|
docker stop moodist-test 2>/dev/null || true
|
||||||
|
docker rm moodist-test 2>/dev/null || true
|
||||||
|
|
||||||
|
# 启动新容器
|
||||||
|
docker run -d \
|
||||||
|
--name moodist-test \
|
||||||
|
-p 8081:8080 \
|
||||||
|
--restart unless-stopped \
|
||||||
|
"${IMAGE_NAME}:${VERSION}"
|
||||||
|
|
||||||
|
echo "🚀 测试容器已启动!"
|
||||||
|
echo ""
|
||||||
|
echo "📋 访问信息:"
|
||||||
|
echo " 🌐 本地访问: http://localhost:8081"
|
||||||
|
echo " 🐳 容器名称: moodist-test"
|
||||||
|
echo " 🏷️ 镜像标签: ${IMAGE_NAME}:${VERSION}"
|
||||||
|
echo ""
|
||||||
|
echo "🔧 常用命令:"
|
||||||
|
echo " 查看日志: docker logs moodist-test"
|
||||||
|
echo " 停止容器: docker stop moodist-test"
|
||||||
|
echo " 删除容器: docker rm moodist-test"
|
||||||
|
echo " 进入容器: docker exec -it moodist-test /bin/sh"
|
||||||
|
echo ""
|
||||||
|
echo "⏳ 等待容器启动..."
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
echo "🔍 执行健康检查..."
|
||||||
|
if curl -f http://localhost:8081/ &> /dev/null; then
|
||||||
|
echo "✅ 健康检查通过!应用正常运行"
|
||||||
|
else
|
||||||
|
echo "⚠️ 健康检查失败,请查看容器日志"
|
||||||
|
echo " docker logs moodist-test"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🎉 本地构建和测试完成!"
|
||||||
119
src/components/about-unified.astro
Normal file
119
src/components/about-unified.astro
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
---
|
||||||
|
import { Container } from '@/components/container';
|
||||||
|
import { count as soundCount } from '@/lib/sounds';
|
||||||
|
import { getTranslation } from '@/data/i18n';
|
||||||
|
|
||||||
|
// Get language from URL path
|
||||||
|
const url = Astro.url;
|
||||||
|
const pathname = url.pathname;
|
||||||
|
const isZhPage = pathname === '/zh' || pathname.startsWith('/zh/');
|
||||||
|
const lang = isZhPage ? 'zh-CN' : 'en';
|
||||||
|
const t = getTranslation(lang);
|
||||||
|
const count = soundCount();
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="about">
|
||||||
|
<div class="effect"></div>
|
||||||
|
|
||||||
|
<Container tight>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.about {
|
||||||
|
padding-top: 10px;
|
||||||
|
|
||||||
|
& .effect {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 80px;
|
||||||
|
background: linear-gradient(var(--bg-secondary), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .paragraph {
|
||||||
|
padding: 30px 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
transparent,
|
||||||
|
var(--bg-secondary) 10%,
|
||||||
|
var(--bg-secondary) 90%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
|
||||||
|
&:last-of-type {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .counter {
|
||||||
|
width: max-content;
|
||||||
|
padding: 6px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: var(--font-xsm);
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
background: linear-gradient(var(--bg-secondary), transparent);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 20px 20px 20px 8px;
|
||||||
|
|
||||||
|
& span {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .title {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: var(--font-md);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .body {
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px 16px;
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: var(--font-xsm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 50px;
|
||||||
|
outline: none;
|
||||||
|
transition: 0.2s;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
position: absolute;
|
||||||
|
top: -1px;
|
||||||
|
left: 50%;
|
||||||
|
width: 70%;
|
||||||
|
height: 1px;
|
||||||
|
content: '';
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
var(--color-muted),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-muted);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
173
src/components/about-zh.astro
Normal file
173
src/components/about-zh.astro
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
---
|
||||||
|
import { Container } from '@/components/container';
|
||||||
|
|
||||||
|
import { count as soundCount } from '@/lib/sounds';
|
||||||
|
|
||||||
|
const count = soundCount();
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="about">
|
||||||
|
<div class="effect"></div>
|
||||||
|
|
||||||
|
<Container tight>
|
||||||
|
<div class="paragraph">
|
||||||
|
<div class="counter">
|
||||||
|
<span>01</span> / <span>04</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="title">免费环境音</h2>
|
||||||
|
<p class="body">
|
||||||
|
渴望从日常繁杂中获得片刻宁静?需要完美的声音环境来提升专注力或帮助入眠?
|
||||||
|
Moodist 就是您的最佳选择——免费开源的环境音生成器!无需订阅注册,使用 Moodist,
|
||||||
|
您可以免费享受舒缓沉浸的音频体验。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="paragraph">
|
||||||
|
<div class="counter">
|
||||||
|
<span>02</span> / <span>04</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="title">精心挑选的声音</h2>
|
||||||
|
<p class="body">
|
||||||
|
探索包含 <span class="sound-count">{count}</span> 个精心挑选声音的庞大音库。
|
||||||
|
自然爱好者可以在溪流的轻柔潺潺声中、海浪的节拍拍岸声中、或篝火的温暖噼啪声中获得慰藉。
|
||||||
|
城市景观在咖啡馆的轻柔嗡嗡声、火车的节拍咔嗒声、或交通的舒缓白噪声中变得生动。
|
||||||
|
对于寻求更深专注或放松的人,Moodist 提供了专门设计来增强心境的双节拍和色彩噪声。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="paragraph">
|
||||||
|
<div class="counter">
|
||||||
|
<span>03</span> / <span>04</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="title">创造您的声音景观</h2>
|
||||||
|
<p class="body">
|
||||||
|
Moodist 的美妙之处在于其简洁性和自定义性。没有复杂的菜单或令人困惑的选项——只需选择您喜欢的声音,
|
||||||
|
调整音量平衡,然后点击播放。想要将鸟儿的轻柔啾鸣与雨水的舒缓声音融合?没问题!
|
||||||
|
随心所欲地叠加多个声音,创建个性化的声音绿洲。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="paragraph">
|
||||||
|
<div class="counter">
|
||||||
|
<span>04</span> / <span>04</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="title">适合每个时刻的声音</h2>
|
||||||
|
<p class="body">
|
||||||
|
无论您是想在漫长一天后放松身心,在工作中提升专注力,还是让自己进入宁静的睡眠,
|
||||||
|
Moodist 都有完美的声音景观等着您。最棒的是什么?它完全免费开源,您可以毫无负担地享受它的好处。
|
||||||
|
今天就开始使用 Moodist,发现您新的宁静和专注天堂吧!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="button" id="use-moodist">使用 Moodist</button>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
const button = document.getElementById('use-moodist');
|
||||||
|
|
||||||
|
button?.addEventListener('click', () => {
|
||||||
|
const app = document.getElementById('app');
|
||||||
|
|
||||||
|
app?.scrollIntoView();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.about {
|
||||||
|
padding-top: 10px;
|
||||||
|
|
||||||
|
& .effect {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 80px;
|
||||||
|
background: linear-gradient(var(--color-neutral-50), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .paragraph {
|
||||||
|
padding: 30px 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
transparent,
|
||||||
|
var(--color-neutral-50) 10%,
|
||||||
|
var(--color-neutral-50) 90%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
|
||||||
|
&:last-of-type {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .counter {
|
||||||
|
width: max-content;
|
||||||
|
padding: 6px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: var(--font-xsm);
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
background: linear-gradient(var(--color-neutral-100), transparent);
|
||||||
|
border: 1px solid var(--color-neutral-300);
|
||||||
|
border-radius: 20px 20px 20px 8px;
|
||||||
|
|
||||||
|
& span {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .title {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: var(--font-md);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .body {
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px 16px;
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: var(--font-xsm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid var(--color-neutral-200);
|
||||||
|
border-radius: 50px;
|
||||||
|
outline: none;
|
||||||
|
transition: 0.2s;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
position: absolute;
|
||||||
|
top: -1px;
|
||||||
|
left: 50%;
|
||||||
|
width: 70%;
|
||||||
|
height: 1px;
|
||||||
|
content: '';
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
var(--color-neutral-300),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
background-color: var(--color-neutral-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-neutral-400);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -4,45 +4,53 @@ import { Container } from '@/components/container';
|
||||||
import { count as soundCount } from '@/lib/sounds';
|
import { count as soundCount } from '@/lib/sounds';
|
||||||
|
|
||||||
const count = soundCount();
|
const count = soundCount();
|
||||||
|
|
||||||
const paragraphs = [
|
|
||||||
{
|
|
||||||
body: 'Craving a calming escape from the daily grind? Do you need the perfect soundscape to boost your focus or lull you into peaceful sleep? Look no further than Moodist, your free and open-source ambient sound generator! Ditch the subscriptions and registrations – with Moodist, you unlock a world of soothing and immersive audio experiences, entirely for free.',
|
|
||||||
title: 'Free Ambient Sounds',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
body: `Dive into an expansive library of ${count} carefully curated sounds. Nature lovers will find solace in the gentle murmur of streams, the rhythmic crash of waves, or the crackling warmth of a campfire. Cityscapes come alive with the soft hum of cafes, the rhythmic clatter of trains, or the calming white noise of traffic. And for those seeking deeper focus or relaxation, Moodist offers binaural beats and color noise designed to enhance your state of mind.`,
|
|
||||||
title: 'Carefully Curated Sounds',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
body: 'The beauty of Moodist lies in its simplicity and customization. No complex menus or confusing options – just choose your desired sounds, adjust the volume balance, and hit play. Want to blend the gentle chirping of birds with the soothing sound of rain? No problem! Layer as many sounds as you like to create your personalized soundscape oasis.',
|
|
||||||
title: 'Create Your Soundscape',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
body: "Whether you're looking to unwind after a long day, enhance your focus during work, or lull yourself into a peaceful sleep, Moodist has the perfect soundscape waiting for you. The best part? It's completely free and open-source, so you can enjoy its benefits without any strings attached. Start using Moodist today and discover your new haven of tranquility and focus!",
|
|
||||||
title: 'Sounds for Every Moment',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<section class="about">
|
<section class="about">
|
||||||
<div class="effect"></div>
|
<div class="effect"></div>
|
||||||
|
|
||||||
<Container tight>
|
<Container tight>
|
||||||
{
|
<div class="paragraph">
|
||||||
paragraphs.map((paragraph, index) => (
|
<div class="counter">
|
||||||
<div class="paragraph">
|
<span>01</span> / <span>04</span>
|
||||||
<div class="counter">
|
</div>
|
||||||
<span>0{index + 1}</span> / 0{paragraphs.length}
|
<h2 class="title" data-i18n="about.freeAmbientSounds.title">Free Ambient Sounds</h2>
|
||||||
</div>
|
<p class="body" data-i18n="about.freeAmbientSounds.body">
|
||||||
|
Craving a calming escape from the daily grind? Do you need the perfect soundscape to boost your focus or lull you into peaceful sleep? Look no further than Moodist, your free and open-source ambient sound generator! Ditch the subscriptions and registrations – with Moodist, you unlock a world of soothing and immersive audio experiences, entirely for free.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2 class="title">{paragraph.title}</h2>
|
<div class="paragraph">
|
||||||
<p class="body">{paragraph.body}</p>
|
<div class="counter">
|
||||||
</div>
|
<span>02</span> / <span>04</span>
|
||||||
))
|
</div>
|
||||||
}
|
<h2 class="title" data-i18n="about.carefullyCuratedSounds.title">Carefully Curated Sounds</h2>
|
||||||
|
<p class="body" data-i18n-count={count} data-i18n="about.carefullyCuratedSounds.body">
|
||||||
|
Dive into an expansive library of <span class="sound-count">{count}</span> carefully curated sounds. Nature lovers will find solace in the gentle murmur of streams, the rhythmic crash of waves, or the crackling warmth of a campfire. Cityscapes come alive with the soft hum of cafes, the rhythmic clatter of trains, or the calming white noise of traffic. And for those seeking deeper focus or relaxation, Moodist offers binaural beats and color noise designed to enhance your state of mind.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="button" id="use-moodist"> Use Moodist</button>
|
<div class="paragraph">
|
||||||
|
<div class="counter">
|
||||||
|
<span>03</span> / <span>04</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="title" data-i18n="about.createYourSoundscape.title">Create Your Soundscape</h2>
|
||||||
|
<p class="body" data-i18n="about.createYourSoundscape.body">
|
||||||
|
The beauty of Moodist lies in its simplicity and customization. No complex menus or confusing options – just choose your desired sounds, adjust the volume balance, and hit play. Want to blend the gentle chirping of birds with the soothing sound of rain? No problem! Layer as many sounds as you like to create your personalized soundscape oasis.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="paragraph">
|
||||||
|
<div class="counter">
|
||||||
|
<span>04</span> / <span>04</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="title" data-i18n="about.soundsForEveryMoment.title">Sounds for Every Moment</h2>
|
||||||
|
<p class="body" data-i18n="about.soundsForEveryMoment.body">
|
||||||
|
Whether you're looking to unwind after a long day, enhance your focus during work, or lull yourself into a peaceful sleep, Moodist has the perfect soundscape waiting for you. The best part? It's completely free and open-source, so you can enjoy its benefits without any strings attached. Start using Moodist today and discover your new haven of tranquility and focus!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="button" id="use-moodist" data-i18n="about.useMoodist">Use Moodist</button>
|
||||||
</Container>
|
</Container>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,13 @@ import { BiSolidHeart } from 'react-icons/bi/index';
|
||||||
import { Howler } from 'howler';
|
import { Howler } from 'howler';
|
||||||
|
|
||||||
import { useSoundStore } from '@/stores/sound';
|
import { useSoundStore } from '@/stores/sound';
|
||||||
|
import { useLocalizedSounds } from '@/hooks/useLocalizedSounds';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
|
||||||
import { Container } from '@/components/container';
|
import { Container } from '@/components/container';
|
||||||
import { StoreConsumer } from '@/components/store-consumer';
|
import { StoreConsumer } from '@/components/store-consumer';
|
||||||
import { Buttons } from '@/components/buttons';
|
import { Buttons } from '@/components/buttons';
|
||||||
|
import { SelectedSoundsDisplay } from '@/components/selected-sounds-display';
|
||||||
import { Categories } from '@/components/categories';
|
import { Categories } from '@/components/categories';
|
||||||
import { SharedModal } from '@/components/modals/shared';
|
import { SharedModal } from '@/components/modals/shared';
|
||||||
import { Toolbar } from '@/components/toolbar';
|
import { Toolbar } from '@/components/toolbar';
|
||||||
|
|
@ -21,15 +24,17 @@ import type { Sound } from '@/data/types';
|
||||||
import { subscribe } from '@/lib/event';
|
import { subscribe } from '@/lib/event';
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const categories = useMemo(() => sounds.categories, []);
|
const localizedCategories = useLocalizedSounds();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const categories = useMemo(() => sounds.categories, []);
|
||||||
const favorites = useSoundStore(useShallow(state => state.getFavorites()));
|
const favorites = useSoundStore(useShallow(state => state.getFavorites()));
|
||||||
const pause = useSoundStore(state => state.pause);
|
const pause = useSoundStore(state => state.pause);
|
||||||
const lock = useSoundStore(state => state.lock);
|
const lock = useSoundStore(state => state.lock);
|
||||||
const unlock = useSoundStore(state => state.unlock);
|
const unlock = useSoundStore(state => state.unlock);
|
||||||
|
|
||||||
const favoriteSounds = useMemo(() => {
|
const favoriteSounds = useMemo(() => {
|
||||||
const favoriteSounds = categories
|
const favoriteSounds = localizedCategories
|
||||||
.map(category => category.sounds)
|
.map(category => category.sounds)
|
||||||
.flat()
|
.flat()
|
||||||
.filter(sound => favorites.includes(sound.id));
|
.filter(sound => favorites.includes(sound.id));
|
||||||
|
|
@ -40,7 +45,7 @@ export function App() {
|
||||||
return favorites.map(favorite =>
|
return favorites.map(favorite =>
|
||||||
favoriteSounds.find(sound => sound.id === favorite),
|
favoriteSounds.find(sound => sound.id === favorite),
|
||||||
);
|
);
|
||||||
}, [favorites, categories]);
|
}, [favorites, localizedCategories]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onChange = () => {
|
const onChange = () => {
|
||||||
|
|
@ -79,12 +84,12 @@ export function App() {
|
||||||
icon: <BiSolidHeart />,
|
icon: <BiSolidHeart />,
|
||||||
id: 'favorites',
|
id: 'favorites',
|
||||||
sounds: favoriteSounds as Array<Sound>,
|
sounds: favoriteSounds as Array<Sound>,
|
||||||
title: 'Favorites',
|
title: t('favorite'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...favorites, ...categories];
|
return [...favorites, ...localizedCategories];
|
||||||
}, [favoriteSounds, categories]);
|
}, [favoriteSounds, localizedCategories, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SnackbarProvider>
|
<SnackbarProvider>
|
||||||
|
|
@ -93,6 +98,7 @@ export function App() {
|
||||||
<Container>
|
<Container>
|
||||||
<div id="app" />
|
<div id="app" />
|
||||||
<Buttons />
|
<Buttons />
|
||||||
|
<SelectedSoundsDisplay />
|
||||||
<Categories categories={allCategories} />
|
<Categories categories={allCategories} />
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
|
|
|
||||||
285
src/components/auth-button/auth-button.module.css
Normal file
285
src/components/auth-button/auth-button.module.css
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
.authButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-foreground, #1e293b);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
right: 4rem; /* 主题切换按钮左侧 */
|
||||||
|
z-index: 1000;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authButton:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authButton:focus {
|
||||||
|
outline: 2px solid var(--color-foreground);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authButton:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.userIndicator {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2px;
|
||||||
|
right: 2px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: #10b981;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗色主题下的特殊样式 */
|
||||||
|
:global(.dark-theme) .authButton {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .authButton:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .userIndicator {
|
||||||
|
border-color: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 认证表单样式 */
|
||||||
|
.authFormOverlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1001;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authForm {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--component-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authForm h3 {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
font-size: var(--font-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authForm form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authInput {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authInput::placeholder {
|
||||||
|
color: var(--color-foreground-subtler);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authInput:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-muted);
|
||||||
|
box-shadow: 0 0 0 2px var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authButtons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authSubmitButton {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--color-foreground);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authSubmitButton:hover:not(:disabled) {
|
||||||
|
background: var(--color-foreground-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authSubmitButton:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authCancelButton {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-foreground-subtler);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authCancelButton:hover {
|
||||||
|
background: var(--component-hover);
|
||||||
|
border-color: var(--color-foreground-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authToggle {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authToggleButton {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: var(--font-xsm);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authToggleButton:hover {
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 用户菜单样式 */
|
||||||
|
.userMenu {
|
||||||
|
position: fixed;
|
||||||
|
top: 4rem;
|
||||||
|
right: 4rem;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userInfo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--component-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userAvatar {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-foreground);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userName {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoutButton {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: var(--font-xsm);
|
||||||
|
color: #ef4444;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoutButton:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗色主题下的样式优化 */
|
||||||
|
:global(.dark-theme) .authForm {
|
||||||
|
background: var(--component-bg);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .authInput {
|
||||||
|
background: var(--input-bg);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .userInfo {
|
||||||
|
background: var(--component-bg);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.authButton {
|
||||||
|
top: 0.75rem;
|
||||||
|
right: 3.5rem;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userMenu {
|
||||||
|
top: 3.5rem;
|
||||||
|
right: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authFormOverlay {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authForm {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
143
src/components/auth-button/auth-button.tsx
Normal file
143
src/components/auth-button/auth-button.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { FaUser } from 'react-icons/fa/index';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import styles from './auth-button.module.css';
|
||||||
|
|
||||||
|
export function AuthButton() {
|
||||||
|
const { isAuthenticated, user, login, logout, isLoading } = useAuthStore();
|
||||||
|
const [showAuthForm, setShowAuthForm] = useState(false);
|
||||||
|
const [isLogin, setIsLogin] = useState(true);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
if (isLogin) {
|
||||||
|
await login(formData);
|
||||||
|
} else {
|
||||||
|
// 注册功能先用登录代替
|
||||||
|
await login(formData);
|
||||||
|
}
|
||||||
|
setShowAuthForm(false);
|
||||||
|
setFormData({ username: '', password: '' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('认证失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[e.target.name]: e.target.value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
// 如果已登录,显示用户菜单
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// 如果未登录,显示登录表单
|
||||||
|
setShowAuthForm(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<motion.button
|
||||||
|
className={styles.authButton}
|
||||||
|
onClick={handleClick}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
aria-label={isAuthenticated ? `用户: ${user?.username}` : '登录'}
|
||||||
|
>
|
||||||
|
<FaUser />
|
||||||
|
{isAuthenticated && user && (
|
||||||
|
<span className={styles.userIndicator}></span>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
{showAuthForm && (
|
||||||
|
<div className={styles.authFormOverlay} onClick={() => setShowAuthForm(false)}>
|
||||||
|
<div className={styles.authForm} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>{isLogin ? '登录' : '注册'}</h3>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
placeholder="用户名"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
minLength={3}
|
||||||
|
className={styles.authInput}
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
placeholder="密码"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
className={styles.authInput}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
<div className={styles.authButtons}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className={styles.authSubmitButton}
|
||||||
|
>
|
||||||
|
{isLoading ? '处理中...' : (isLogin ? '登录' : '注册')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAuthForm(false)}
|
||||||
|
className={styles.authCancelButton}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.authToggle}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsLogin(!isLogin)}
|
||||||
|
className={styles.authToggleButton}
|
||||||
|
>
|
||||||
|
{isLogin ? '没有账号?注册' : '已有账号?登录'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAuthenticated && (
|
||||||
|
<div className={styles.userMenu}>
|
||||||
|
<div className={styles.userInfo}>
|
||||||
|
<div className={styles.userAvatar}>
|
||||||
|
{user?.username.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span className={styles.userName}>{user?.username}</span>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className={styles.logoutButton}
|
||||||
|
>
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/auth-button/index.ts
Normal file
1
src/components/auth-button/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { AuthButton } from './auth-button';
|
||||||
146
src/components/auth/auth-form.module.css
Normal file
146
src/components/auth/auth-form.module.css
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
.authContainer {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authCard {
|
||||||
|
background: var(--component-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 32px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 0 16px;
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: var(--font-xl);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: var(--font-base);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-muted);
|
||||||
|
box-shadow: 0 0 0 2px var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--color-foreground-subtler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitButton {
|
||||||
|
padding: 14px 24px;
|
||||||
|
background: var(--color-foreground);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: var(--font-base);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s, transform 0.1s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--color-foreground-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.successMessage {
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorMessage {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleSection {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleText {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--color-foreground-subtler);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleButton {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
transition: color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
}
|
||||||
|
}
|
||||||
131
src/components/auth/auth-form.tsx
Normal file
131
src/components/auth/auth-form.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
import styles from './auth-form.module.css';
|
||||||
|
|
||||||
|
export function AuthForm() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isLogin, setIsLogin] = useState(true);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
});
|
||||||
|
const [showSuccess, setShowSuccess] = useState(false);
|
||||||
|
const [successMessage, setSuccessMessage] = useState('');
|
||||||
|
|
||||||
|
const { login, register, isLoading, error, clearError } = useAuthStore();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearError();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isLogin) {
|
||||||
|
await login(formData);
|
||||||
|
setSuccessMessage('登录成功!');
|
||||||
|
} else {
|
||||||
|
await register(formData);
|
||||||
|
setSuccessMessage('注册成功!');
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowSuccess(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowSuccess(false);
|
||||||
|
}, 3000);
|
||||||
|
} catch (error) {
|
||||||
|
// 错误已经在 store 中处理了
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[e.target.name]: e.target.value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMode = () => {
|
||||||
|
setIsLogin(!isLogin);
|
||||||
|
clearError();
|
||||||
|
setFormData({ username: '', password: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.authContainer}>
|
||||||
|
<div className={styles.authCard}>
|
||||||
|
<h2 className={styles.title}>
|
||||||
|
{isLogin ? '登录' : '注册'}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{showSuccess && (
|
||||||
|
<div className={styles.successMessage}>
|
||||||
|
{successMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className={styles.errorMessage}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className={styles.form}>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label htmlFor="username" className={styles.label}>
|
||||||
|
用户名
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={handleChange}
|
||||||
|
className={styles.input}
|
||||||
|
required
|
||||||
|
minLength={3}
|
||||||
|
placeholder="请输入用户名(至少3个字符)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label htmlFor="password" className={styles.label}>
|
||||||
|
密码
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
className={styles.input}
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
placeholder="请输入密码(至少6个字符)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className={styles.submitButton}
|
||||||
|
>
|
||||||
|
{isLoading ? '处理中...' : (isLogin ? '登录' : '注册')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className={styles.toggleSection}>
|
||||||
|
<span className={styles.toggleText}>
|
||||||
|
{isLogin ? '还没有账号?' : '已有账号?'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleMode}
|
||||||
|
className={styles.toggleButton}
|
||||||
|
>
|
||||||
|
{isLogin ? '立即注册' : '立即登录'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/components/auth/index.ts
Normal file
3
src/components/auth/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { AuthForm } from './auth-form';
|
||||||
|
export { UserInfo } from './user-info';
|
||||||
|
export { LoginTrigger } from './login-trigger';
|
||||||
26
src/components/auth/login-trigger.module.css
Normal file
26
src/components/auth/login-trigger.module.css
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
.loginButton {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--component-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s, border-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--component-hover);
|
||||||
|
border-color: var(--color-foreground-subtle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
z-index: 9998;
|
||||||
|
}
|
||||||
26
src/components/auth/login-trigger.tsx
Normal file
26
src/components/auth/login-trigger.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { AuthForm } from './auth-form';
|
||||||
|
import styles from './login-trigger.module.css';
|
||||||
|
|
||||||
|
export function LoginTrigger() {
|
||||||
|
const [showAuth, setShowAuth] = useState(false);
|
||||||
|
|
||||||
|
const openAuth = () => {
|
||||||
|
setShowAuth(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeAuth = () => {
|
||||||
|
setShowAuth(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button className={styles.loginButton} onClick={openAuth}>
|
||||||
|
登录
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showAuth && <AuthForm />}
|
||||||
|
{showAuth && <div className={styles.backdrop} onClick={closeAuth} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
src/components/auth/user-info.module.css
Normal file
140
src/components/auth/user-info.module.css
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
.userContainer {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--component-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s, border-color 0.2s;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.userButton:hover {
|
||||||
|
background: var(--component-hover);
|
||||||
|
border-color: var(--color-foreground-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.userAvatar {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-foreground);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userName {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownArrow {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--color-foreground-subtler);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userButton:hover .dropdownArrow {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
min-width: 280px;
|
||||||
|
background: var(--component-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 1000;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userInfo {
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userAvatarLarge {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-foreground);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: var(--font-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userDetails {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userFullname {
|
||||||
|
font-size: var(--font-base);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userJoined {
|
||||||
|
font-size: var(--font-xsm);
|
||||||
|
color: var(--color-foreground-subtler);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoutButton {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
63
src/components/auth/user-info.tsx
Normal file
63
src/components/auth/user-info.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import styles from './user-info.module.css';
|
||||||
|
|
||||||
|
export function UserInfo() {
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
setShowDropdown(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.userContainer}>
|
||||||
|
<button
|
||||||
|
className={styles.userButton}
|
||||||
|
onClick={() => setShowDropdown(!showDropdown)}
|
||||||
|
aria-label="用户菜单"
|
||||||
|
>
|
||||||
|
<div className={styles.userAvatar}>
|
||||||
|
{user.username.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span className={styles.userName}>{user.username}</span>
|
||||||
|
<span className={styles.dropdownArrow}>▼</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showDropdown && (
|
||||||
|
<div className={styles.dropdown}>
|
||||||
|
<div className={styles.userInfo}>
|
||||||
|
<div className={styles.userAvatarLarge}>
|
||||||
|
{user.username.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className={styles.userDetails}>
|
||||||
|
<div className={styles.userFullname}>{user.username}</div>
|
||||||
|
<div className={styles.userJoined}>
|
||||||
|
加入时间: {new Date(user.created_at).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.divider}></div>
|
||||||
|
<button
|
||||||
|
className={styles.logoutButton}
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showDropdown && (
|
||||||
|
<div
|
||||||
|
className={styles.backdrop}
|
||||||
|
onClick={() => setShowDropdown(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
src/components/buttons/delete-music/delete-music.module.css
Normal file
155
src/components/buttons/delete-music/delete-music.module.css
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
.deleteDropdownContainer {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
width: 60px;
|
||||||
|
height: 32px; /* 与输入框内容区域高度一致 */
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px; /* 与输入框圆角一致 */
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteButton:hover:not(:disabled) {
|
||||||
|
background: #dc2626;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteButton:disabled {
|
||||||
|
background: var(--color-muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteButton.disabled {
|
||||||
|
background: var(--color-muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteDropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
width: 280px;
|
||||||
|
max-height: 320px;
|
||||||
|
background: var(--component-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 1000;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownHeader h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton:hover {
|
||||||
|
background: var(--component-hover);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.empty {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.musicList {
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.musicItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.musicItem:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.musicItem:hover {
|
||||||
|
background: var(--component-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.musicName {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
margin-right: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteItemButton {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteItemButton:hover:not(:disabled) {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteItemButton:disabled {
|
||||||
|
background: var(--color-muted);
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
186
src/components/buttons/delete-music/delete-music.tsx
Normal file
186
src/components/buttons/delete-music/delete-music.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { FaTrash } from 'react-icons/fa';
|
||||||
|
import { useSoundStore } from '@/stores/sound';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { useSnackbar } from '@/contexts/snackbar';
|
||||||
|
import { useLocalizedSounds } from '@/hooks/useLocalizedSounds';
|
||||||
|
import { ApiClient } from '@/lib/api-client';
|
||||||
|
import { cn } from '@/helpers/styles';
|
||||||
|
|
||||||
|
import styles from './delete-music.module.css';
|
||||||
|
|
||||||
|
interface SavedMusic {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
sounds: string[];
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteMusicButton() {
|
||||||
|
const { isAuthenticated, user } = useAuthStore();
|
||||||
|
const sounds = useSoundStore(state => state.sounds);
|
||||||
|
const selectedSoundIds = useSoundStore(state =>
|
||||||
|
Object.keys(state.sounds).filter(id => state.sounds[id].isSelected)
|
||||||
|
);
|
||||||
|
const showSnackbar = useSnackbar();
|
||||||
|
const localizedCategories = useLocalizedSounds();
|
||||||
|
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [showDeleteDropdown, setShowDeleteDropdown] = useState(false);
|
||||||
|
const [savedMusicList, setSavedMusicList] = useState<SavedMusic[]>([]);
|
||||||
|
const [isLoadingMusic, setIsLoadingMusic] = useState(false);
|
||||||
|
|
||||||
|
// 获取选中的声音详细信息
|
||||||
|
const selectedSounds = selectedSoundIds
|
||||||
|
.map(id => {
|
||||||
|
const allSounds = localizedCategories
|
||||||
|
.map(category => category.sounds)
|
||||||
|
.flat();
|
||||||
|
return allSounds.find(sound => sound.id === id);
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const noSelected = selectedSounds.length === 0;
|
||||||
|
const hasSelected = selectedSounds.length > 0;
|
||||||
|
|
||||||
|
// 获取用户保存的音乐列表
|
||||||
|
const fetchSavedMusic = useCallback(async () => {
|
||||||
|
if (!isAuthenticated || !user) {
|
||||||
|
setSavedMusicList([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoadingMusic(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await ApiClient.post('/api/auth/music/list');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('获取音乐列表失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
setSavedMusicList(data.musicList || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 获取音乐列表失败:', error);
|
||||||
|
setSavedMusicList([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingMusic(false);
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, user]);
|
||||||
|
|
||||||
|
// 删除音乐
|
||||||
|
const deleteMusic = useCallback(async (musicId: string, musicName: string) => {
|
||||||
|
if (!isAuthenticated || !user) return;
|
||||||
|
if (!confirm(`确定要删除"${musicName}"吗?`)) return;
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await ApiClient.post('/api/auth/music/delete', {
|
||||||
|
musicId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('删除失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
setSavedMusicList(prev => prev.filter(music => music.id !== parseInt(musicId)));
|
||||||
|
showSnackbar(`已删除音乐: ${musicName}`);
|
||||||
|
console.log('✅ 音乐删除成功');
|
||||||
|
} else {
|
||||||
|
showSnackbar(data.error || '删除失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 删除音乐失败:', error);
|
||||||
|
showSnackbar('删除失败');
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, user, showSnackbar]);
|
||||||
|
|
||||||
|
// 当用户认证状态改变时,获取音乐列表
|
||||||
|
const handleToggleDropdown = useCallback(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
showSnackbar('请先登录后再删除音乐');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!showDeleteDropdown && savedMusicList.length === 0) {
|
||||||
|
fetchSavedMusic();
|
||||||
|
}
|
||||||
|
setShowDeleteDropdown(!showDeleteDropdown);
|
||||||
|
}, [isAuthenticated, showDeleteDropdown, savedMusicList.length, fetchSavedMusic, showSnackbar]);
|
||||||
|
|
||||||
|
// 点击外部关闭下拉菜单
|
||||||
|
const handleDocumentClick = useCallback((event: MouseEvent) => {
|
||||||
|
const target = event.target as Element;
|
||||||
|
if (showDeleteDropdown && !target.closest(`.${styles.deleteDropdownContainer}`)) {
|
||||||
|
setShowDeleteDropdown(false);
|
||||||
|
}
|
||||||
|
}, [showDeleteDropdown]);
|
||||||
|
|
||||||
|
// 添加和移除事件监听器
|
||||||
|
useEffect(() => {
|
||||||
|
if (showDeleteDropdown) {
|
||||||
|
document.addEventListener('click', handleDocumentClick);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', handleDocumentClick);
|
||||||
|
};
|
||||||
|
}, [showDeleteDropdown, handleDocumentClick]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.deleteDropdownContainer}>
|
||||||
|
<button
|
||||||
|
className={cn(styles.deleteButton, !isAuthenticated && styles.disabled)}
|
||||||
|
onClick={handleToggleDropdown}
|
||||||
|
disabled={!isAuthenticated}
|
||||||
|
title={isAuthenticated ? '删除保存的音乐' : '请先登录后再删除'}
|
||||||
|
>
|
||||||
|
<FaTrash />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 删除下拉菜单 */}
|
||||||
|
{showDeleteDropdown && (
|
||||||
|
<div className={styles.deleteDropdown}>
|
||||||
|
<div className={styles.dropdownHeader}>
|
||||||
|
<h4>删除音乐</h4>
|
||||||
|
<button
|
||||||
|
className={styles.closeButton}
|
||||||
|
onClick={() => setShowDeleteDropdown(false)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoadingMusic ? (
|
||||||
|
<div className={styles.loading}>加载中...</div>
|
||||||
|
) : savedMusicList.length === 0 ? (
|
||||||
|
<div className={styles.empty}>没有可删除的音乐</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.musicList}>
|
||||||
|
{savedMusicList.map((music) => (
|
||||||
|
<div key={music.id} className={styles.musicItem}>
|
||||||
|
<span className={styles.musicName}>{music.name}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteMusic(music.id.toString(), music.name)}
|
||||||
|
className={styles.deleteItemButton}
|
||||||
|
title={`删除 ${music.name}`}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{isDeleting ? '删除中...' : '删除'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,15 +7,15 @@
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-heading);
|
||||||
font-size: var(--font-base);
|
font-size: var(--font-base);
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
color: var(--color-neutral-200);
|
color: var(--color-foreground);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-color: var(--color-neutral-950);
|
background-color: var(--bg-secondary);
|
||||||
border: 1px solid var(--color-neutral-50);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 100px;
|
border-radius: 100px;
|
||||||
transition: 0.2s;
|
transition: 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--color-neutral-800);
|
background-color: var(--bg-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:not(.disabled):active {
|
&:not(.disabled):active {
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
outline: 2px solid var(--color-neutral-400);
|
outline: 2px solid var(--color-muted);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,13 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
|
||||||
import { useSoundStore } from '@/stores/sound';
|
import { useSoundStore } from '@/stores/sound';
|
||||||
import { useSnackbar } from '@/contexts/snackbar';
|
import { useSnackbar } from '@/contexts/snackbar';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { cn } from '@/helpers/styles';
|
import { cn } from '@/helpers/styles';
|
||||||
|
|
||||||
import styles from './play.module.css';
|
import styles from './play.module.css';
|
||||||
|
|
||||||
export function PlayButton() {
|
export function PlayButton() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const isPlaying = useSoundStore(state => state.isPlaying);
|
const isPlaying = useSoundStore(state => state.isPlaying);
|
||||||
const pause = useSoundStore(state => state.pause);
|
const pause = useSoundStore(state => state.pause);
|
||||||
const toggle = useSoundStore(state => state.togglePlay);
|
const toggle = useSoundStore(state => state.togglePlay);
|
||||||
|
|
@ -42,14 +44,14 @@ export function PlayButton() {
|
||||||
<span aria-hidden="true">
|
<span aria-hidden="true">
|
||||||
<BiPause />
|
<BiPause />
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
Pause
|
{t('pause')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span aria-hidden="true">
|
<span aria-hidden="true">
|
||||||
<BiPlay />
|
<BiPlay />
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
Play
|
{t('play')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
48
src/components/buttons/save-music/save-music.module.css
Normal file
48
src/components/buttons/save-music/save-music.module.css
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
.saveButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 60px;
|
||||||
|
height: 32px; /* 与输入框内容区域高度一致 (14px字体 + 8px*2 = 30px) */
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px; /* 与输入框圆角一致 */
|
||||||
|
transition: 0.2s;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.disabled):active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled,
|
||||||
|
&.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
& span {
|
||||||
|
font-size: var(--font-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-muted);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.saving {
|
||||||
|
background-color: var(--color-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 使用统一的 .loginPrompt 样式,定义在 sounds.module.css 中 */
|
||||||
156
src/components/buttons/save-music/save-music.tsx
Normal file
156
src/components/buttons/save-music/save-music.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { FaSave } from 'react-icons/fa';
|
||||||
|
import { useSoundStore } from '@/stores/sound';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { useNotification } from '@/hooks/useNotification';
|
||||||
|
import { useLocalizedSounds } from '@/hooks/useLocalizedSounds';
|
||||||
|
import { Notification } from '@/components/notification/notification';
|
||||||
|
import { ApiClient } from '@/lib/api-client';
|
||||||
|
import { cn } from '@/helpers/styles';
|
||||||
|
|
||||||
|
import styles from './save-music.module.css';
|
||||||
|
import soundsStyles from '@/components/sounds/sounds.module.css';
|
||||||
|
|
||||||
|
export function SaveMusicButton() {
|
||||||
|
const { isAuthenticated, user } = useAuthStore();
|
||||||
|
const sounds = useSoundStore(state => state.sounds);
|
||||||
|
const selectedSoundIds = useSoundStore(state =>
|
||||||
|
Object.keys(state.sounds).filter(id => state.sounds[id].isSelected)
|
||||||
|
);
|
||||||
|
const { showNotificationMessage, ...notificationState } = useNotification();
|
||||||
|
const localizedCategories = useLocalizedSounds();
|
||||||
|
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
|
||||||
|
|
||||||
|
// 获取选中的声音详细信息
|
||||||
|
const selectedSounds = selectedSoundIds
|
||||||
|
.map(id => {
|
||||||
|
const allSounds = localizedCategories
|
||||||
|
.map(category => category.sounds)
|
||||||
|
.flat();
|
||||||
|
return allSounds.find(sound => sound.id === id);
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const noSelected = selectedSounds.length === 0;
|
||||||
|
|
||||||
|
// 获取音乐名称输入框的值
|
||||||
|
const getMusicName = useCallback(() => {
|
||||||
|
const musicInput = document.querySelector('input[placeholder="音乐名称"]') as HTMLInputElement;
|
||||||
|
return musicInput?.value?.trim() || '';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (noSelected) return showNotificationMessage('请先选择声音', 'error');
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
setShowLoginPrompt(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证音乐名称输入
|
||||||
|
const musicName = getMusicName();
|
||||||
|
if (!musicName) {
|
||||||
|
showNotificationMessage('请输入音乐名称', 'error');
|
||||||
|
const musicInput = document.querySelector('input[placeholder="音乐名称"]') as HTMLInputElement;
|
||||||
|
musicInput?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 准备保存的数据
|
||||||
|
const volume: Record<string, number> = {};
|
||||||
|
const speed: Record<string, number> = {};
|
||||||
|
const rate: Record<string, number> = {};
|
||||||
|
const random_effects: Record<string, boolean> = {};
|
||||||
|
|
||||||
|
selectedSounds.forEach(sound => {
|
||||||
|
if (sound) {
|
||||||
|
volume[sound.id] = sounds[sound.id]?.volume || 50;
|
||||||
|
speed[sound.id] = sounds[sound.id]?.speed || 1;
|
||||||
|
rate[sound.id] = sounds[sound.id]?.rate || 1;
|
||||||
|
random_effects[sound.id] = sounds[sound.id]?.isRandomSpeed || sounds[sound.id]?.isRandomVolume || sounds[sound.id]?.isRandomRate || false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const musicData = {
|
||||||
|
name: musicName,
|
||||||
|
sounds: selectedSoundIds,
|
||||||
|
volume,
|
||||||
|
speed,
|
||||||
|
rate,
|
||||||
|
random_effects
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await ApiClient.post('/api/auth/music/save', musicData);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
showNotificationMessage('音乐保存成功!', 'success');
|
||||||
|
console.log('✅ 音乐保存成功:', result.music);
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
console.error('❌ 保存音乐失败:', errorData.error);
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
// JWT认证失败,显示登录提示
|
||||||
|
setShowLoginPrompt(true);
|
||||||
|
}
|
||||||
|
showNotificationMessage(errorData.error || '保存失败', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 保存音乐失败:', error);
|
||||||
|
if (error instanceof Error && error.message.includes('401')) {
|
||||||
|
setShowLoginPrompt(true);
|
||||||
|
}
|
||||||
|
showNotificationMessage('保存失败,请重试', 'error');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}, [noSelected, isAuthenticated, user, selectedSounds, selectedSoundIds, sounds, showNotificationMessage, getMusicName]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className={cn(styles.saveButton, noSelected && styles.disabled, isSaving && styles.saving)}
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving || noSelected}
|
||||||
|
title={isAuthenticated ? '保存当前音乐配置' : '请先登录后再保存'}
|
||||||
|
>
|
||||||
|
<FaSave />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 登录提示 */}
|
||||||
|
{showLoginPrompt && (
|
||||||
|
<div className={soundsStyles.loginPrompt}>
|
||||||
|
<p>请先登录后再保存音乐</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowLoginPrompt(false);
|
||||||
|
// 触发LanguageSwitcher的登录表单
|
||||||
|
const event = new CustomEvent('showLoginForm', { bubbles: true });
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
去登录
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowLoginPrompt(false)}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{/* 通用通知组件 */}
|
||||||
|
<Notification
|
||||||
|
show={notificationState.showNotification}
|
||||||
|
message={notificationState.notificationMessage}
|
||||||
|
type={notificationState.notificationType}
|
||||||
|
onClose={notificationState.hideNotification}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -9,8 +9,8 @@
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
color: var(--color-foreground);
|
color: var(--color-foreground);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-color: var(--color-neutral-100);
|
background-color: var(--bg-secondary);
|
||||||
border: 1px solid var(--color-neutral-300);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 100px;
|
border-radius: 100px;
|
||||||
transition: 0.2s;
|
transition: 0.2s;
|
||||||
|
|
||||||
|
|
@ -25,11 +25,11 @@
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
background-color: var(--color-neutral-200);
|
background-color: var(--bg-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
outline: 2px solid var(--color-neutral-400);
|
outline: 2px solid var(--color-muted);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -37,7 +37,7 @@
|
||||||
.tooltip {
|
.tooltip {
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
font-size: var(--font-xsm);
|
font-size: var(--font-xsm);
|
||||||
background-color: var(--color-neutral-100);
|
background-color: var(--bg-secondary);
|
||||||
border: 1px solid var(--color-neutral-200);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 100px;
|
border-radius: 100px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { AnimatePresence } from 'motion/react';
|
import { AnimatePresence } from 'motion/react';
|
||||||
|
|
||||||
import { Category } from './category';
|
import { Category } from './category';
|
||||||
import { Donate } from './donate';
|
|
||||||
|
|
||||||
import type { Categories } from '@/data/types';
|
import type { Categories } from '@/data/types';
|
||||||
|
|
||||||
|
|
@ -12,12 +11,8 @@ interface CategoriesProps {
|
||||||
export function Categories({ categories }: CategoriesProps) {
|
export function Categories({ categories }: CategoriesProps) {
|
||||||
return (
|
return (
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{categories.map((category, index) => (
|
{categories.map((category) => (
|
||||||
<div key={category.id}>
|
<Category key={category.id} functional={category.id !== 'favorites'} {...category} />
|
||||||
<Category functional={category.id !== 'favorites'} {...category} />
|
|
||||||
|
|
||||||
{index === 3 && <Donate />}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
& .tail {
|
& .tail {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 75px;
|
height: 75px;
|
||||||
background: linear-gradient(transparent, var(--color-neutral-300));
|
background: linear-gradient(transparent, var(--color-muted));
|
||||||
}
|
}
|
||||||
|
|
||||||
& .icon {
|
& .icon {
|
||||||
|
|
@ -23,10 +23,10 @@
|
||||||
height: 45px;
|
height: 45px;
|
||||||
font-size: var(--font-md);
|
font-size: var(--font-md);
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
var(--color-neutral-50),
|
var(--bg-secondary),
|
||||||
var(--color-neutral-100)
|
var(--bg-tertiary)
|
||||||
);
|
);
|
||||||
border: 1px solid var(--color-neutral-300);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import { FaCoffee } from 'react-icons/fa/index';
|
import { FaCoffee } from 'react-icons/fa/index';
|
||||||
|
|
||||||
import { SpecialButton } from '@/components/special-button';
|
import { SpecialButton } from '@/components/special-button';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
|
||||||
import styles from './donate.module.css';
|
import styles from './donate.module.css';
|
||||||
|
|
||||||
export function Donate() {
|
export function Donate() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.donate}>
|
<div className={styles.donate}>
|
||||||
<div className={styles.iconContainer}>
|
<div className={styles.iconContainer}>
|
||||||
|
|
@ -15,14 +18,14 @@ export function Donate() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.title}>
|
<div className={styles.title}>
|
||||||
<span>Support Me</span>
|
<span>{t('supportMe')}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className={styles.desc}>Help me keep Moodist ad-free.</p>
|
<p className={styles.desc}>{t('helpKeepAdFree')}</p>
|
||||||
<SpecialButton
|
<SpecialButton
|
||||||
className={styles.button}
|
className={styles.button}
|
||||||
href="https://buymeacoffee.com/remvze"
|
href="https://buymeacoffee.com/remvze"
|
||||||
>
|
>
|
||||||
Donate Today
|
{t('donateToday')}
|
||||||
</SpecialButton>
|
</SpecialButton>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
---
|
|
||||||
import { Container } from './container';
|
|
||||||
---
|
|
||||||
|
|
||||||
<Container>
|
|
||||||
<section class="wrapper">
|
|
||||||
<p class="text">
|
|
||||||
Enjoy Moodist?{' '}
|
|
||||||
<a
|
|
||||||
href="https://buymeacoffee.com/remvze"
|
|
||||||
rel="noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
Support with a donation!
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</Container>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.wrapper {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
padding: 16px;
|
|
||||||
font-size: var(--font-xsm);
|
|
||||||
color: var(--color-foreground-subtle);
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 50%;
|
|
||||||
width: 80%;
|
|
||||||
height: 1px;
|
|
||||||
content: '';
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
transparent,
|
|
||||||
var(--color-neutral-400),
|
|
||||||
transparent
|
|
||||||
);
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
& .text {
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
& a {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-foreground);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
---
|
---
|
||||||
import { Container } from './container';
|
import { Container } from './container';
|
||||||
|
import { getTranslation } from '@/data/i18n';
|
||||||
|
|
||||||
|
// Get language from URL path
|
||||||
|
const url = Astro.url;
|
||||||
|
const pathname = url.pathname;
|
||||||
|
const isZhPage = pathname === '/zh' || pathname.startsWith('/zh/');
|
||||||
|
const lang = isZhPage ? 'zh-CN' : 'en';
|
||||||
|
const t = getTranslation(lang);
|
||||||
---
|
---
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<Container>
|
<Container>
|
||||||
<p>
|
<p>
|
||||||
Created by <a href="https://twitter.com/remvze">Maze ✦</a>
|
{t.createdBy} <a href="https://twitter.com/remvze">Maze ✦</a>
|
||||||
</p>
|
</p>
|
||||||
</Container>
|
</Container>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,15 @@
|
||||||
import { BsSoundwave } from 'react-icons/bs/index';
|
import { BsSoundwave } from 'react-icons/bs/index';
|
||||||
|
|
||||||
import { Container } from './container';
|
import { Container } from './container';
|
||||||
|
import { getTranslation } from '@/data/i18n';
|
||||||
import { count as soundCount } from '@/lib/sounds';
|
import { count as soundCount } from '@/lib/sounds';
|
||||||
|
|
||||||
|
// Get language from URL path
|
||||||
|
const url = Astro.url;
|
||||||
|
const pathname = url.pathname;
|
||||||
|
const isZhPage = pathname === '/zh' || pathname.startsWith('/zh/');
|
||||||
|
const lang = isZhPage ? 'zh-CN' : 'en';
|
||||||
|
const t = getTranslation(lang);
|
||||||
const count = soundCount();
|
const count = soundCount();
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -24,15 +30,15 @@ const count = soundCount();
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="title">
|
<h1 class="title">
|
||||||
Ambient Sounds<span class="line">For Focus and Calm</span>
|
{t.heroTitle}<span class="line">{t.heroSubtitle}</span>
|
||||||
</h1>
|
</h1>
|
||||||
<h2 class="desc">Free and Open-Source.</h2>
|
<h2 class="desc">{t.heroDescription}</h2>
|
||||||
|
|
||||||
<p class="sounds">
|
<p class="sounds">
|
||||||
<span aria-hidden="true" class="icon">
|
<span aria-hidden="true" class="icon">
|
||||||
<BsSoundwave />
|
<BsSoundwave />
|
||||||
</span>
|
</span>
|
||||||
<span>{count} Sounds</span>
|
<span>{count}{t.soundsCount}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
|
||||||
1
src/components/language-switcher/index.ts
Normal file
1
src/components/language-switcher/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './language-switcher';
|
||||||
492
src/components/language-switcher/language-switcher.module.css
Normal file
492
src/components/language-switcher/language-switcher.module.css
Normal file
|
|
@ -0,0 +1,492 @@
|
||||||
|
/* 头部控制容器 */
|
||||||
|
.headerControls {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 4px;
|
||||||
|
z-index: 1000;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 通用控制按钮样式 */
|
||||||
|
.controlButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
min-height: 40px;
|
||||||
|
position: relative;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlButton:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlButton:focus {
|
||||||
|
outline: 2px solid var(--color-foreground);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlButton:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 用户指示器 */
|
||||||
|
.userIndicator {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2px;
|
||||||
|
right: 2px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: #10b981;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 语言切换器样式 */
|
||||||
|
.languageSwitcher {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.languageSwitcher:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 70px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select:focus-visible {
|
||||||
|
outline: 2px solid var(--color-muted);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 认证表单样式 */
|
||||||
|
.authFormOverlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1001;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authForm {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--component-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authForm h3 {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
font-size: var(--font-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authForm form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authInput {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authInput::placeholder {
|
||||||
|
color: var(--color-foreground-subtler);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authInput:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-muted);
|
||||||
|
box-shadow: 0 0 0 2px var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authButtons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authSubmitButton {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--color-foreground);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authSubmitButton:hover:not(:disabled) {
|
||||||
|
background: var(--color-foreground-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authSubmitButton:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authCancelButton {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-foreground-subtler);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authCancelButton:hover {
|
||||||
|
background: var(--component-hover);
|
||||||
|
border-color: var(--color-foreground-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authToggle {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authToggleButton {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: var(--font-xsm);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authToggleButton:hover {
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 用户菜单样式 */
|
||||||
|
.userMenu {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 180px; /* 改为左侧展开,在headerControls的左边 */
|
||||||
|
z-index: 1001; /* 提高层级,确保在最上层 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.userInfo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--component-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userAvatar {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-foreground);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userName {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userActions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.userActionButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 14px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userActionButton:hover {
|
||||||
|
background: var(--component-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.userActionButton.logoutButton {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userActionButton.logoutButton:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.userActionButton .icon {
|
||||||
|
font-size: 14px;
|
||||||
|
width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗色主题下的特殊样式 */
|
||||||
|
:global(.dark-theme) .headerControls {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .controlButton {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .controlButton:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .userIndicator {
|
||||||
|
border-color: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .authForm {
|
||||||
|
background: var(--component-bg);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .authInput {
|
||||||
|
background: var(--input-bg);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .userInfo {
|
||||||
|
background: var(--component-bg);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 提示通知样式 */
|
||||||
|
.notification {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 1002;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
animation: notificationSlideIn 0.3s ease-out;
|
||||||
|
max-width: 400px;
|
||||||
|
width: calc(100vw - 40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notificationContent {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notificationMessage {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notificationClose {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notificationClose:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 成功通知样式 */
|
||||||
|
.notification.success {
|
||||||
|
background: linear-gradient(135deg, #10b981, #059669);
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 错误通知样式 */
|
||||||
|
.notification.error {
|
||||||
|
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 通知动画 */
|
||||||
|
@keyframes notificationSlideIn {
|
||||||
|
from {
|
||||||
|
transform: translateX(-50%) translateY(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.headerControls {
|
||||||
|
top: 15px;
|
||||||
|
right: 15px;
|
||||||
|
padding: 6px;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlButton {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
min-height: 36px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.languageSwitcher {
|
||||||
|
padding: 8px 10px;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userMenu {
|
||||||
|
top: 15px;
|
||||||
|
right: 155px; /* 移动端适配左侧展开 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.authFormOverlay {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authForm {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification {
|
||||||
|
top: 15px;
|
||||||
|
max-width: calc(100vw - 30px);
|
||||||
|
width: calc(100vw - 30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notificationContent {
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notificationMessage {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
394
src/components/language-switcher/language-switcher.tsx
Normal file
394
src/components/language-switcher/language-switcher.tsx
Normal file
|
|
@ -0,0 +1,394 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { FaGlobe, FaSun, FaMoon, FaUser, FaSignOutAlt, FaCog } from 'react-icons/fa/index';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
|
||||||
|
import styles from './language-switcher.module.css';
|
||||||
|
import { fade } from '@/lib/motion';
|
||||||
|
|
||||||
|
interface LanguageSwitcherProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
|
||||||
|
const { currentLang, changeLanguage, t } = useTranslation();
|
||||||
|
const { isAuthenticated, user, login, register, logout, isLoading, checkAuth, error, clearError } = useAuthStore();
|
||||||
|
const [isDarkTheme, setIsDarkTheme] = useState<boolean | null>(null); // 使用 null 表示未初始化
|
||||||
|
const [isClient, setIsClient] = useState(false); // 跟踪是否在客户端
|
||||||
|
const [showAuthForm, setShowAuthForm] = useState(false);
|
||||||
|
const [isLogin, setIsLogin] = useState(true);
|
||||||
|
const [showNotification, setShowNotification] = useState(false);
|
||||||
|
const [notificationMessage, setNotificationMessage] = useState('');
|
||||||
|
const [notificationType, setNotificationType] = useState<'success' | 'error'>('success');
|
||||||
|
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 客户端检测
|
||||||
|
useEffect(() => {
|
||||||
|
setIsClient(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 认证状态检查
|
||||||
|
useEffect(() => {
|
||||||
|
if (isClient) {
|
||||||
|
checkAuth();
|
||||||
|
}
|
||||||
|
}, [isClient]);
|
||||||
|
|
||||||
|
// 点击外部关闭用户菜单
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Element;
|
||||||
|
if (showUserMenu && !target.closest(`.${styles.headerControls}`) && !target.closest(`.${styles.userMenu}`)) {
|
||||||
|
setShowUserMenu(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showUserMenu) {
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [showUserMenu]);
|
||||||
|
|
||||||
|
// 监听显示登录表单的自定义事件
|
||||||
|
useEffect(() => {
|
||||||
|
const handleShowLoginForm = () => {
|
||||||
|
setShowAuthForm(true);
|
||||||
|
setIsLogin(true); // 默认显示登录表单
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('showLoginForm', handleShowLoginForm);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('showLoginForm', handleShowLoginForm);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 主题切换逻辑 - 确保只在客户端执行
|
||||||
|
useEffect(() => {
|
||||||
|
// 避免在 SSR 环境下执行
|
||||||
|
if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedTheme = localStorage.getItem('theme');
|
||||||
|
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
const initialDarkTheme = savedTheme ? savedTheme === 'dark' : systemPrefersDark;
|
||||||
|
setIsDarkTheme(initialDarkTheme);
|
||||||
|
applyTheme(initialDarkTheme);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyTheme = (isDark: boolean) => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const body = document.body;
|
||||||
|
|
||||||
|
if (isDark) {
|
||||||
|
root.classList.add('dark-theme');
|
||||||
|
root.style.setProperty('--bg-primary', '#0d1117');
|
||||||
|
root.style.setProperty('--bg-secondary', '#161b22');
|
||||||
|
root.style.setProperty('--bg-tertiary', '#21262d');
|
||||||
|
root.style.setProperty('--bg-quaternary', '#30363d');
|
||||||
|
root.style.setProperty('--color-foreground', '#f0f6fc');
|
||||||
|
root.style.setProperty('--color-foreground-subtle', '#8b949e');
|
||||||
|
root.style.setProperty('--color-foreground-subtler', '#6e7681');
|
||||||
|
root.style.setProperty('--color-muted', '#484f58');
|
||||||
|
root.style.setProperty('--color-border', '#30363d');
|
||||||
|
root.style.setProperty('--component-bg', '#161b22');
|
||||||
|
root.style.setProperty('--component-hover', '#21262d');
|
||||||
|
root.style.setProperty('--component-active', '#30363d');
|
||||||
|
root.style.setProperty('--modal-bg', '#0d1117');
|
||||||
|
root.style.setProperty('--input-bg', '#0d1117');
|
||||||
|
body.style.backgroundColor = '#0d1117';
|
||||||
|
} else {
|
||||||
|
root.classList.remove('dark-theme');
|
||||||
|
root.style.setProperty('--bg-primary', '#ffffff');
|
||||||
|
root.style.setProperty('--bg-secondary', '#f8fafc');
|
||||||
|
root.style.setProperty('--bg-tertiary', '#f1f5f9');
|
||||||
|
root.style.setProperty('--bg-quaternary', '#e2e8f0');
|
||||||
|
root.style.setProperty('--color-foreground', '#1e293b');
|
||||||
|
root.style.setProperty('--color-foreground-subtle', '#475569');
|
||||||
|
root.style.setProperty('--color-foreground-subtler', '#64748b');
|
||||||
|
root.style.setProperty('--color-muted', '#94a3b8');
|
||||||
|
root.style.setProperty('--color-border', '#cbd5e1');
|
||||||
|
root.style.setProperty('--component-bg', '#ffffff');
|
||||||
|
root.style.setProperty('--component-hover', '#f8fafc');
|
||||||
|
root.style.setProperty('--component-active', '#f1f5f9');
|
||||||
|
root.style.setProperty('--modal-bg', '#ffffff');
|
||||||
|
root.style.setProperty('--input-bg', '#ffffff');
|
||||||
|
body.style.backgroundColor = '#ffffff';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
// 确保主题已初始化且在客户端环境
|
||||||
|
if (isDarkTheme === null || typeof window === 'undefined' || typeof localStorage === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTheme = !isDarkTheme;
|
||||||
|
setIsDarkTheme(newTheme);
|
||||||
|
applyTheme(newTheme);
|
||||||
|
localStorage.setItem('theme', newTheme ? 'dark' : 'light');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 显示提示信息
|
||||||
|
const showNotificationMessage = (message: string, type: 'success' | 'error') => {
|
||||||
|
setNotificationMessage(message);
|
||||||
|
setNotificationType(type);
|
||||||
|
setShowNotification(true);
|
||||||
|
|
||||||
|
// 3秒后自动关闭
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowNotification(false);
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 认证逻辑
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearError(); // 清除之前的错误
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isLogin) {
|
||||||
|
await login(formData);
|
||||||
|
showNotificationMessage('登录成功!', 'success');
|
||||||
|
setShowAuthForm(false);
|
||||||
|
setFormData({ username: '', password: '' });
|
||||||
|
} else {
|
||||||
|
await register(formData);
|
||||||
|
showNotificationMessage('注册成功!', 'success');
|
||||||
|
setShowAuthForm(false);
|
||||||
|
setFormData({ username: '', password: '' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '认证失败';
|
||||||
|
showNotificationMessage(errorMessage, 'error');
|
||||||
|
console.error('认证失败:', error);
|
||||||
|
// 认证失败时不关闭弹窗,让用户重新尝试
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[e.target.name]: e.target.value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuthClick = () => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
setShowUserMenu(!showUserMenu);
|
||||||
|
} else {
|
||||||
|
setShowAuthForm(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLanguageChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
changeLanguage(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const variants = fade();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={`${styles.headerControls} ${className || ''}`}>
|
||||||
|
{/* 语言切换器 */}
|
||||||
|
<div className={styles.languageSwitcher}>
|
||||||
|
<FaGlobe className={styles.icon} />
|
||||||
|
<select
|
||||||
|
value={currentLang}
|
||||||
|
onChange={handleLanguageChange}
|
||||||
|
className={styles.select}
|
||||||
|
aria-label={t('app.language') || 'Select language'}
|
||||||
|
>
|
||||||
|
<option value="en">EN</option>
|
||||||
|
<option value="zh-CN">中文</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 主题切换按钮 */}
|
||||||
|
<button
|
||||||
|
className={styles.controlButton}
|
||||||
|
onClick={toggleTheme}
|
||||||
|
aria-label={isDarkTheme === true ? 'Switch to light mode' : isDarkTheme === false ? 'Switch to dark mode' : 'Loading theme'}
|
||||||
|
disabled={isDarkTheme === null}
|
||||||
|
>
|
||||||
|
{isDarkTheme !== null && (
|
||||||
|
<AnimatePresence initial={false} mode="wait">
|
||||||
|
<motion.span
|
||||||
|
animate="show"
|
||||||
|
aria-hidden="true"
|
||||||
|
exit="hidden"
|
||||||
|
initial="hidden"
|
||||||
|
key={isDarkTheme ? 'moon' : 'sun'}
|
||||||
|
variants={variants}
|
||||||
|
style={{ display: 'flex', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
{isDarkTheme ? <FaMoon /> : <FaSun />}
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
)}
|
||||||
|
<span style={{ marginLeft: '8px', fontSize: '14px' }}>
|
||||||
|
{isDarkTheme === null ? '...' : (isDarkTheme ? '黑暗' : '明亮')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 登录按钮 */}
|
||||||
|
<motion.button
|
||||||
|
className={styles.controlButton}
|
||||||
|
onClick={handleAuthClick}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
aria-label={isClient ? (isAuthenticated ? `用户: ${user?.username}` : '登录') : '登录'}
|
||||||
|
>
|
||||||
|
<FaUser />
|
||||||
|
{isClient && isAuthenticated && user && (
|
||||||
|
<span className={styles.userIndicator}></span>
|
||||||
|
)}
|
||||||
|
<span style={{
|
||||||
|
marginLeft: '8px',
|
||||||
|
fontSize: '14px',
|
||||||
|
maxWidth: '80px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}>
|
||||||
|
{isClient ? (isAuthenticated ? (user?.username || '用户') : '登录') : '登录'}
|
||||||
|
</span>
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 认证表单模态框 */}
|
||||||
|
{showAuthForm && (
|
||||||
|
<div className={styles.authFormOverlay} onClick={() => setShowAuthForm(false)}>
|
||||||
|
<div className={styles.authForm} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>{isLogin ? '登录' : '注册'}</h3>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
placeholder="用户名"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
minLength={3}
|
||||||
|
className={styles.authInput}
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
placeholder="密码"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
className={styles.authInput}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
<div className={styles.authButtons}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className={styles.authSubmitButton}
|
||||||
|
>
|
||||||
|
{isLoading ? '处理中...' : (isLogin ? '登录' : '注册')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAuthForm(false)}
|
||||||
|
className={styles.authCancelButton}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.authToggle}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsLogin(!isLogin)}
|
||||||
|
className={styles.authToggleButton}
|
||||||
|
>
|
||||||
|
{isLogin ? '没有账号?注册' : '已有账号?登录'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 用户菜单 - 左侧展开菜单 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isAuthenticated && showUserMenu && (
|
||||||
|
<motion.div
|
||||||
|
className={styles.userMenu}
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<div className={styles.userInfo}>
|
||||||
|
<div className={styles.userAvatar}>
|
||||||
|
{user?.username.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span className={styles.userName}>{user?.username}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.userActions}>
|
||||||
|
<button
|
||||||
|
className={`${styles.userActionButton}`}
|
||||||
|
onClick={() => {
|
||||||
|
// 这里可以添加个人设置功能
|
||||||
|
setShowUserMenu(false);
|
||||||
|
showNotificationMessage('个人设置功能开发中...', 'success');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaCog className={styles.icon} />
|
||||||
|
个人设置
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`${styles.userActionButton} ${styles.logoutButton}`}
|
||||||
|
onClick={() => {
|
||||||
|
handleLogout();
|
||||||
|
setShowUserMenu(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaSignOutAlt className={styles.icon} />
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* 提示通知 */}
|
||||||
|
{showNotification && (
|
||||||
|
<div className={`${styles.notification} ${styles[notificationType]}`}>
|
||||||
|
<div className={styles.notificationContent}>
|
||||||
|
<span className={styles.notificationMessage}>
|
||||||
|
{notificationMessage}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className={styles.notificationClose}
|
||||||
|
onClick={() => setShowNotification(false)}
|
||||||
|
aria-label="关闭通知"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -51,14 +51,16 @@ export function ShareLinkModal({ onClose, show }: ShareLinkModalProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal show={show} onClose={onClose}>
|
<Modal show={show} onClose={onClose}>
|
||||||
<h1 className={styles.heading}>Share your sound selection!</h1>
|
<h1 className={styles.heading} data-i18n="modals.share.title">
|
||||||
<p className={styles.desc}>
|
Share your sound selection!
|
||||||
|
</h1>
|
||||||
|
<p className={styles.desc} data-i18n="modals.share.description">
|
||||||
Copy and send the following link to the person you want to share your
|
Copy and send the following link to the person you want to share your
|
||||||
selection with.
|
selection with.
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.inputWrapper}>
|
<div className={styles.inputWrapper}>
|
||||||
<input readOnly type="text" value={url} />
|
<input readOnly type="text" value={url} />
|
||||||
<button onClick={() => copy(url)}>
|
<button onClick={() => copy(url)} aria-label="Copy link">
|
||||||
{copying ? <IoCheckmark /> : <IoCopyOutline />}
|
{copying ? <IoCheckmark /> : <IoCopyOutline />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
90
src/components/notification/notification.module.css
Normal file
90
src/components/notification/notification.module.css
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
.notification {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 1002;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
animation: notificationSlideIn 0.3s ease-out;
|
||||||
|
max-width: 400px;
|
||||||
|
width: calc(100vw - 40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notificationContent {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notificationMessage {
|
||||||
|
font-size: 14px;
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notificationClose {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notificationClose:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 成功通知样式 */
|
||||||
|
.notification.success {
|
||||||
|
background: linear-gradient(135deg, #10b981, #059669);
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 错误通知样式 */
|
||||||
|
.notification.error {
|
||||||
|
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 通知动画 */
|
||||||
|
@keyframes notificationSlideIn {
|
||||||
|
from {
|
||||||
|
transform: translateX(-50%) translateY(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.notification {
|
||||||
|
top: 15px;
|
||||||
|
max-width: calc(100vw - 30px);
|
||||||
|
width: calc(100vw - 30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notificationContent {
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notificationMessage {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/components/notification/notification.tsx
Normal file
32
src/components/notification/notification.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { AnimatePresence } from 'motion/react';
|
||||||
|
import styles from './notification.module.css';
|
||||||
|
|
||||||
|
interface NotificationProps {
|
||||||
|
show: boolean;
|
||||||
|
message: string;
|
||||||
|
type: 'success' | 'error';
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Notification({ show, message, type, onClose }: NotificationProps) {
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{show && (
|
||||||
|
<div className={`${styles.notification} ${styles[type]}`}>
|
||||||
|
<div className={styles.notificationContent}>
|
||||||
|
<span className={styles.notificationMessage}>
|
||||||
|
{message}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className={styles.notificationClose}
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="关闭通知"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/saved-music-list/index.ts
Normal file
1
src/components/saved-music-list/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { SavedMusicList } from './saved-music-list';
|
||||||
331
src/components/saved-music-list/saved-music-list.module.css
Normal file
331
src/components/saved-music-list/saved-music-list.module.css
Normal file
|
|
@ -0,0 +1,331 @@
|
||||||
|
.savedMusicList {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleIcon {
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: var(--bg-error, rgba(239, 68, 68, 0.1));
|
||||||
|
color: var(--color-error, #ef4444);
|
||||||
|
border: 1px solid var(--color-error, #ef4444);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorClose {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-error, #ef4444);
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 8px;
|
||||||
|
font-weight: bold;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorClose:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px 20px;
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyIcon {
|
||||||
|
font-size: 32px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty p {
|
||||||
|
margin: 8px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyHint {
|
||||||
|
font-size: 13px !important;
|
||||||
|
color: var(--color-muted) !important;
|
||||||
|
margin-top: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.musicItems {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.musicItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--component-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.musicItem:hover {
|
||||||
|
background: var(--component-hover);
|
||||||
|
border-color: var(--color-foreground-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.musicInfo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: var(--color-foreground);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playButton:hover {
|
||||||
|
background: var(--color-foreground-subtle);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playButton:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.musicName {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.musicName:hover {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.musicActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.musicItem:hover .musicActions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButton:hover {
|
||||||
|
background: var(--component-hover);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButton.deleteButton:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: var(--color-error, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editForm {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editInput {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
font-size: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editInput:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editButtons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveButton {
|
||||||
|
background: var(--color-foreground);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveButton:hover {
|
||||||
|
background: var(--color-foreground-subtle);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelButton {
|
||||||
|
background: var(--color-muted);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelButton:hover {
|
||||||
|
background: var(--color-foreground-subtle);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.title {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.musicItem {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playButton {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.musicName {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButton {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.musicActions {
|
||||||
|
opacity: 1; /* 移动端始终显示操作按钮 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyIcon {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 动画效果 */
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.musicItem {
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 焦点可访问性 */
|
||||||
|
.musicItem:focus-within {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editInput:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editButton:focus-visible,
|
||||||
|
.actionButton:focus-visible,
|
||||||
|
.playButton:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
309
src/components/saved-music-list/saved-music-list.tsx
Normal file
309
src/components/saved-music-list/saved-music-list.tsx
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { FaMusic, FaEdit, FaTrash, FaPlay } from 'react-icons/fa';
|
||||||
|
import { AnimatePresence } from 'motion/react';
|
||||||
|
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { useSoundStore } from '@/stores/sound';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
import { ApiClient } from '@/lib/api-client';
|
||||||
|
|
||||||
|
import type { SavedMusic } from '@/lib/database';
|
||||||
|
|
||||||
|
import styles from './saved-music-list.module.css';
|
||||||
|
|
||||||
|
interface SavedMusicListProps {
|
||||||
|
onMusicSelect?: (music: SavedMusic) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SavedMusicList({ onMusicSelect }: SavedMusicListProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isAuthenticated, user } = useAuthStore();
|
||||||
|
const [savedMusicList, setSavedMusicList] = useState<SavedMusic[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editingName, setEditingName] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 获取声音store的操作函数
|
||||||
|
const unselectAll = useSoundStore(state => state.unselectAll);
|
||||||
|
const select = useSoundStore(state => state.select);
|
||||||
|
const setVolume = useSoundStore(state => state.setVolume);
|
||||||
|
const setSpeed = useSoundStore(state => state.setSpeed);
|
||||||
|
const setRate = useSoundStore(state => state.setRate);
|
||||||
|
const toggleRandomSpeed = useSoundStore(state => state.toggleRandomSpeed);
|
||||||
|
const toggleRandomVolume = useSoundStore(state => state.toggleRandomVolume);
|
||||||
|
const toggleRandomRate = useSoundStore(state => state.toggleRandomRate);
|
||||||
|
const play = useSoundStore(state => state.play);
|
||||||
|
|
||||||
|
// 获取用户保存的音乐列表
|
||||||
|
const fetchSavedMusic = async () => {
|
||||||
|
if (!isAuthenticated || !user) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await ApiClient.post('/api/auth/music/list');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('获取音乐列表失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
setSavedMusicList(data.musicList || []);
|
||||||
|
} else {
|
||||||
|
setError(data.error || '获取音乐列表失败');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取音乐列表错误:', err);
|
||||||
|
setError('获取音乐列表失败,请稍后再试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重命名音乐
|
||||||
|
const renameMusic = async (musicId: string, newName: string) => {
|
||||||
|
if (!isAuthenticated || !user) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await ApiClient.post('/api/auth/music/rename', {
|
||||||
|
musicId,
|
||||||
|
name: newName
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('重命名失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
// 更新本地状态
|
||||||
|
setSavedMusicList(prev =>
|
||||||
|
prev.map(music =>
|
||||||
|
music.id === musicId ? { ...music, name: newName } : music
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setEditingId(null);
|
||||||
|
setEditingName('');
|
||||||
|
} else {
|
||||||
|
setError(data.error || '重命名失败');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('重命名音乐错误:', err);
|
||||||
|
setError('重命名失败,请稍后再试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除音乐
|
||||||
|
const deleteMusic = async (musicId: string) => {
|
||||||
|
if (!isAuthenticated || !user) return;
|
||||||
|
|
||||||
|
if (!confirm('确定要删除这首音乐吗?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await ApiClient.post('/api/auth/music/delete', {
|
||||||
|
musicId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('删除失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
// 从本地状态中移除
|
||||||
|
setSavedMusicList(prev => prev.filter(music => music.id !== musicId));
|
||||||
|
} else {
|
||||||
|
setError(data.error || '删除失败');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('删除音乐错误:', err);
|
||||||
|
setError('删除失败,请稍后再试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 播放保存的音乐
|
||||||
|
const playSavedMusic = async (music: SavedMusic) => {
|
||||||
|
// 清除当前所有声音选择
|
||||||
|
unselectAll(true);
|
||||||
|
|
||||||
|
// 延迟一下确保清除完成后再开始播放
|
||||||
|
setTimeout(() => {
|
||||||
|
// 选择音乐中的所有声音
|
||||||
|
music.sounds.forEach((soundId: string) => {
|
||||||
|
// 选择声音
|
||||||
|
select(soundId);
|
||||||
|
|
||||||
|
// 设置音量
|
||||||
|
const volume = music.volume[soundId] || 50;
|
||||||
|
setVolume(soundId, volume / 100); // store中存储的是0-1的范围
|
||||||
|
|
||||||
|
// 设置速度
|
||||||
|
const speed = music.speed[soundId] || 1;
|
||||||
|
setSpeed(soundId, speed);
|
||||||
|
|
||||||
|
// 设置速率
|
||||||
|
const rate = music.rate[soundId] || 1;
|
||||||
|
setRate(soundId, rate);
|
||||||
|
|
||||||
|
// 设置随机效果
|
||||||
|
const randomEffects = music.random_effects[soundId];
|
||||||
|
if (randomEffects) {
|
||||||
|
if (randomEffects.volume) {
|
||||||
|
toggleRandomVolume(soundId);
|
||||||
|
}
|
||||||
|
if (randomEffects.speed) {
|
||||||
|
toggleRandomSpeed(soundId);
|
||||||
|
}
|
||||||
|
if (randomEffects.rate) {
|
||||||
|
toggleRandomRate(soundId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 开始播放
|
||||||
|
play();
|
||||||
|
|
||||||
|
// 通知父组件音乐已被选中
|
||||||
|
if (onMusicSelect) {
|
||||||
|
onMusicSelect(music);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 开始编辑名称
|
||||||
|
const startEditing = (music: SavedMusic) => {
|
||||||
|
setEditingId(music.id);
|
||||||
|
setEditingName(music.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存编辑
|
||||||
|
const saveEdit = () => {
|
||||||
|
if (editingId && editingName.trim()) {
|
||||||
|
renameMusic(editingId, editingName.trim());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 取消编辑
|
||||||
|
const cancelEdit = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setEditingName('');
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 当用户认证状态改变时,获取音乐列表
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated && user) {
|
||||||
|
fetchSavedMusic();
|
||||||
|
} else {
|
||||||
|
setSavedMusicList([]);
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, user]);
|
||||||
|
|
||||||
|
// 如果用户未登录,不显示组件
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.savedMusicList}>
|
||||||
|
<h3 className={styles.title}>
|
||||||
|
<FaMusic className={styles.titleIcon} />
|
||||||
|
我的音乐
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className={styles.error}>
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError(null)} className={styles.errorClose}>×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.loading}>加载中...</div>
|
||||||
|
) : savedMusicList.length === 0 ? (
|
||||||
|
<div className={styles.empty}>
|
||||||
|
<FaMusic className={styles.emptyIcon} />
|
||||||
|
<p>还没有保存的音乐</p>
|
||||||
|
<p className={styles.emptyHint}>选择声音并点击保存按钮来创建你的第一首音乐</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.musicItems}>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{savedMusicList.map((music) => (
|
||||||
|
<div key={music.id} className={styles.musicItem}>
|
||||||
|
{editingId === music.id ? (
|
||||||
|
<div className={styles.editForm}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingName}
|
||||||
|
onChange={(e) => setEditingName(e.target.value)}
|
||||||
|
className={styles.editInput}
|
||||||
|
placeholder="输入音乐名称"
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
<div className={styles.editButtons}>
|
||||||
|
<button
|
||||||
|
onClick={saveEdit}
|
||||||
|
className={`${styles.editButton} ${styles.saveButton}`}
|
||||||
|
title="保存"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={cancelEdit}
|
||||||
|
className={`${styles.editButton} ${styles.cancelButton}`}
|
||||||
|
title="取消"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={styles.musicInfo}>
|
||||||
|
<button
|
||||||
|
onClick={() => playSavedMusic(music)}
|
||||||
|
className={styles.playButton}
|
||||||
|
title="播放这首音乐"
|
||||||
|
>
|
||||||
|
<FaPlay />
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
className={styles.musicName}
|
||||||
|
onClick={() => startEditing(music)}
|
||||||
|
title="点击编辑名称"
|
||||||
|
>
|
||||||
|
{music.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.musicActions}>
|
||||||
|
<button
|
||||||
|
onClick={() => startEditing(music)}
|
||||||
|
className={styles.actionButton}
|
||||||
|
title="编辑名称"
|
||||||
|
>
|
||||||
|
<FaEdit />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteMusic(music.id)}
|
||||||
|
className={`${styles.actionButton} ${styles.deleteButton}`}
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<FaTrash />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/selected-sounds-display/index.ts
Normal file
1
src/components/selected-sounds-display/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { SelectedSoundsDisplay } from './selected-sounds-display';
|
||||||
|
|
@ -0,0 +1,615 @@
|
||||||
|
import { useMemo, useState, useEffect, useRef } from 'react';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
import { FaSave, FaPlay, FaStop, FaTrash, FaEdit, FaCog, FaSignOutAlt, FaMusic, FaChevronDown, FaChevronRight } from 'react-icons/fa/index';
|
||||||
|
import { SaveMusicButton } from '@/components/buttons/save-music/save-music';
|
||||||
|
import { DeleteMusicButton } from '@/components/buttons/delete-music/delete-music';
|
||||||
|
|
||||||
|
import { useSoundStore } from '@/stores/sound';
|
||||||
|
import { useLocalizedSounds } from '@/hooks/useLocalizedSounds';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { ApiClient } from '@/lib/api-client';
|
||||||
|
import { Howl } from 'howler';
|
||||||
|
|
||||||
|
import { Sound } from '@/components/sounds/sound';
|
||||||
|
import styles from '../sounds/sounds.module.css';
|
||||||
|
|
||||||
|
interface SavedMusic {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
sounds: string[];
|
||||||
|
volume: Record<string, number>;
|
||||||
|
speed: Record<string, number>;
|
||||||
|
rate: Record<string, number>;
|
||||||
|
random_effects: Record<string, boolean>;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectedSoundsDisplay() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const localizedCategories = useLocalizedSounds();
|
||||||
|
const { isAuthenticated, user, login, sessionPassword } = useAuthStore();
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
|
||||||
|
const [showSaveSuccess, setShowSaveSuccess] = useState(false);
|
||||||
|
const [savedMusicList, setSavedMusicList] = useState<SavedMusic[]>([]);
|
||||||
|
const [isLoadingMusic, setIsLoadingMusic] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editingName, setEditingName] = useState('');
|
||||||
|
const [expandedMusic, setExpandedMusic] = useState<Set<number>>(new Set()); // 跟踪展开的音乐项
|
||||||
|
const [expandedCurrent, setExpandedCurrent] = useState(true); // 跟踪当前选中声音的展开状态,默认展开
|
||||||
|
const [expandedMyMusic, setExpandedMyMusic] = useState(true); // 跟踪音乐列表展开状态,默认展开
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [musicName, setMusicName] = useState('');
|
||||||
|
|
||||||
|
// 独立的音乐播放状态
|
||||||
|
const [currentlyPlayingMusic, setCurrentlyPlayingMusic] = useState<SavedMusic | null>(null);
|
||||||
|
const musicHowlInstances = useRef<Record<string, Howl>>({});
|
||||||
|
const [isPlayingMusic, setIsPlayingMusic] = useState(false);
|
||||||
|
|
||||||
|
// 获取声音store
|
||||||
|
const sounds = useSoundStore(state => state.sounds);
|
||||||
|
|
||||||
|
// 获取选中的声音
|
||||||
|
const selectedSoundIds = useSoundStore(state =>
|
||||||
|
Object.keys(state.sounds).filter(id => state.sounds[id].isSelected)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 独立展开逻辑:展开当前选中声音时收起所有展开的音乐
|
||||||
|
const toggleExpandedCurrent = () => {
|
||||||
|
setExpandedCurrent(!expandedCurrent);
|
||||||
|
if (!expandedCurrent) {
|
||||||
|
setExpandedMusic(new Set()); // 展开时收起所有展开的音乐项
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleExpandedMyMusic = () => {
|
||||||
|
setExpandedMyMusic(!expandedMyMusic);
|
||||||
|
if (!expandedMyMusic) {
|
||||||
|
setExpandedMusic(new Set()); // 展开时收起所有展开的音乐项
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取声音store的操作函数(仅用于控制主要播放状态)
|
||||||
|
const play = useSoundStore(state => state.play);
|
||||||
|
const pause = useSoundStore(state => state.pause);
|
||||||
|
|
||||||
|
// 停止音乐播放
|
||||||
|
const stopMusic = () => {
|
||||||
|
console.log('🛑 停止音乐播放');
|
||||||
|
|
||||||
|
// 停止所有音乐相关的 Howl 实例
|
||||||
|
Object.values(musicHowlInstances.current).forEach(howlInstance => {
|
||||||
|
if (howlInstance) {
|
||||||
|
howlInstance.stop();
|
||||||
|
howlInstance.unload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
musicHowlInstances.current = {};
|
||||||
|
setCurrentlyPlayingMusic(null);
|
||||||
|
setIsPlayingMusic(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 播放音乐记录 - 使用独立的音乐播放系统,不影响当前选中声音
|
||||||
|
const playMusicRecord = async (music: SavedMusic) => {
|
||||||
|
try {
|
||||||
|
console.log('🎵 开始播放音乐:', music.name);
|
||||||
|
console.log('🎵 音乐数据:', {
|
||||||
|
sounds: music.sounds,
|
||||||
|
volume: music.volume,
|
||||||
|
speed: music.speed,
|
||||||
|
rate: music.rate,
|
||||||
|
random_effects: music.random_effects
|
||||||
|
});
|
||||||
|
|
||||||
|
// 先停止当前播放的音乐
|
||||||
|
stopMusic();
|
||||||
|
|
||||||
|
// 停止主要的选中声音播放(但不改变选中状态)
|
||||||
|
pause();
|
||||||
|
|
||||||
|
// 获取所有声音数据
|
||||||
|
const allSounds = localizedCategories
|
||||||
|
.map(category => category.sounds)
|
||||||
|
.flat();
|
||||||
|
|
||||||
|
// 创建所有声音的 Howl 实例
|
||||||
|
const howlPromises: Promise<Howl>[] = [];
|
||||||
|
|
||||||
|
for (const soundId of music.sounds) {
|
||||||
|
const soundData = allSounds.find(s => s.id === soundId);
|
||||||
|
if (!soundData || !soundData.src) continue;
|
||||||
|
|
||||||
|
const volume = music.volume[soundId] || 0.5;
|
||||||
|
const rate = music.rate[soundId] || 1;
|
||||||
|
const speed = music.speed[soundId] || 1;
|
||||||
|
|
||||||
|
console.log(`🔊 创建音乐声音: ${soundId}`, { volume, rate, speed });
|
||||||
|
|
||||||
|
// 创建 Howl 实例的 Promise
|
||||||
|
const howlPromise = new Promise<Howl>((resolve, reject) => {
|
||||||
|
const howl = new Howl({
|
||||||
|
src: [soundData.src],
|
||||||
|
loop: true,
|
||||||
|
volume: volume,
|
||||||
|
rate: rate,
|
||||||
|
preload: true,
|
||||||
|
onload: () => {
|
||||||
|
console.log(`✅ 声音加载完成: ${soundId}`);
|
||||||
|
resolve(howl);
|
||||||
|
},
|
||||||
|
onloaderror: (id, error) => {
|
||||||
|
console.error(`❌ 声音加载失败: ${soundId}`, error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保存实例引用
|
||||||
|
musicHowlInstances.current[soundId] = howl;
|
||||||
|
});
|
||||||
|
|
||||||
|
howlPromises.push(howlPromise);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待所有声音加载完成
|
||||||
|
console.log('⏳ 等待所有声音加载...');
|
||||||
|
await Promise.all(howlPromises);
|
||||||
|
console.log('✅ 所有声音加载完成,开始播放');
|
||||||
|
|
||||||
|
// 播放所有声音
|
||||||
|
Object.values(musicHowlInstances.current).forEach(howlInstance => {
|
||||||
|
if (howlInstance && howlInstance.state() === 'loaded') {
|
||||||
|
howlInstance.play();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置播放状态
|
||||||
|
setCurrentlyPlayingMusic(music);
|
||||||
|
setIsPlayingMusic(true);
|
||||||
|
|
||||||
|
// 展开对应的音乐记录
|
||||||
|
setExpandedMusic(new Set([music.id]));
|
||||||
|
setExpandedCurrent(false); // 收起当前选中声音模块
|
||||||
|
|
||||||
|
console.log(`✅ 播放音乐记录完成: ${music.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 播放音乐记录失败:', error);
|
||||||
|
stopMusic();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换音乐项的展开/收起状态
|
||||||
|
const toggleMusicExpansion = (musicId: number) => {
|
||||||
|
setExpandedMusic(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(musicId)) {
|
||||||
|
// 如果点击已展开的音乐,直接收起
|
||||||
|
newSet.delete(musicId);
|
||||||
|
} else {
|
||||||
|
// 如果点击未展开的音乐,收起其他所有展开的项目,只展开当前这个
|
||||||
|
return new Set([musicId]);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 展开音乐时,同时收起当前选中声音模块
|
||||||
|
if (!expandedMusic.has(musicId)) {
|
||||||
|
setExpandedCurrent(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 根据选中的声音ID获取声音对象
|
||||||
|
const selectedSounds = useMemo(() => {
|
||||||
|
return selectedSoundIds.map(id => {
|
||||||
|
// 从 localizedCategories 中查找对应的声音数据
|
||||||
|
const allSounds = localizedCategories
|
||||||
|
.map(category => category.sounds)
|
||||||
|
.flat();
|
||||||
|
const soundData = allSounds.find(s => s.id === id);
|
||||||
|
|
||||||
|
if (!soundData) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
...soundData,
|
||||||
|
...sounds[id] // 合并状态信息(volume, speed 等)
|
||||||
|
};
|
||||||
|
}).filter(Boolean);
|
||||||
|
}, [selectedSoundIds, sounds, localizedCategories]);
|
||||||
|
|
||||||
|
// 获取音乐列表
|
||||||
|
const fetchMusicList = async () => {
|
||||||
|
if (!isAuthenticated || !user) return;
|
||||||
|
|
||||||
|
setIsLoadingMusic(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🎵 开始获取音乐列表...');
|
||||||
|
console.log('👤 用户信息:', { id: user.id, username: user.username });
|
||||||
|
|
||||||
|
const response = await ApiClient.post('/api/auth/music/list', {
|
||||||
|
userId: user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('📡 响应状态:', response.status);
|
||||||
|
console.log('📡 响应头:', response.headers);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('❌ API响应错误:', response.status, errorText);
|
||||||
|
throw new Error(`获取音乐列表失败 (${response.status}): ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('📋 音乐列表数据:', data);
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
console.log('✅ 设置音乐列表:', data.musicList || [], '数量:', (data.musicList || []).length);
|
||||||
|
setSavedMusicList(data.musicList || []);
|
||||||
|
console.log('✅ savedMusicList状态更新完成');
|
||||||
|
} else {
|
||||||
|
setError(data.error || '获取音乐列表失败');
|
||||||
|
console.error('❌ 音乐列表API返回错误:', data.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 获取音乐列表失败:', error);
|
||||||
|
setError('获取音乐列表失败,请稍后再试');
|
||||||
|
setSavedMusicList([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingMusic(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重命名音乐
|
||||||
|
const renameMusic = async (musicId: string, newName: string) => {
|
||||||
|
if (!isAuthenticated || !user) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await ApiClient.post('/api/auth/music/rename', {
|
||||||
|
musicId,
|
||||||
|
name: newName
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('重命名失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
setSavedMusicList(prev =>
|
||||||
|
prev.map(music =>
|
||||||
|
music.id === parseInt(musicId) ? { ...music, name: newName } : music
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setEditingId(null);
|
||||||
|
setEditingName('');
|
||||||
|
} else {
|
||||||
|
setError(data.error || '重命名失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 重命名失败:', error);
|
||||||
|
setError('重命名失败,请稍后再试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除音乐
|
||||||
|
const deleteMusic = async (musicId: string) => {
|
||||||
|
if (!isAuthenticated || !user) return;
|
||||||
|
|
||||||
|
if (!confirm('确定要删除这首音乐吗?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🗑️ 开始删除音乐:', musicId);
|
||||||
|
const response = await ApiClient.post('/api/auth/music/delete', {
|
||||||
|
musicId,
|
||||||
|
userId: user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('❌ 删除失败:', response.status, errorText);
|
||||||
|
throw new Error(`删除失败 (${response.status}): ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('📋 删除响应:', data);
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setSavedMusicList(prev => prev.filter(music => music.id !== parseInt(musicId)));
|
||||||
|
console.log('✅ 音乐删除成功');
|
||||||
|
} else {
|
||||||
|
setError(data.error || '删除失败');
|
||||||
|
console.error('❌ 删除API返回错误:', data.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 删除音乐失败:', error);
|
||||||
|
setError('删除失败,请稍后再试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始加载音乐列表
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated && user) {
|
||||||
|
fetchMusicList();
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, user]);
|
||||||
|
|
||||||
|
// 组件卸载时清理音乐播放
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
stopMusic();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 监听音乐列表数量,超过5个时默认收起
|
||||||
|
useEffect(() => {
|
||||||
|
if (savedMusicList.length > 5) {
|
||||||
|
setExpandedMyMusic(false);
|
||||||
|
} else {
|
||||||
|
setExpandedMyMusic(true);
|
||||||
|
}
|
||||||
|
}, [savedMusicList.length]);
|
||||||
|
|
||||||
|
// 如果既没有选中声音,也没有音乐列表,则不渲染组件
|
||||||
|
if (selectedSounds.length === 0 && (!isAuthenticated || savedMusicList.length === 0)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* 当前选中声音模块 - 只有选中声音时才显示 */}
|
||||||
|
{selectedSounds.length > 0 && (
|
||||||
|
<div className={styles.currentSoundsModule}>
|
||||||
|
<div className={styles.currentSoundsHeader}>
|
||||||
|
<h4 className={styles.currentSoundsTitle}>
|
||||||
|
<FaMusic className={styles.musicIcon} />
|
||||||
|
当前选中的声音
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
className={`${styles.expandButton} ${styles.expandButtonCurrent}`}
|
||||||
|
onClick={toggleExpandedCurrent}
|
||||||
|
title={expandedCurrent ? "收起" : "展开"}
|
||||||
|
>
|
||||||
|
{expandedCurrent ? <FaChevronDown /> : <FaChevronRight />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 音乐名称配置区域 */}
|
||||||
|
{expandedCurrent && (
|
||||||
|
<div className={styles.musicNameConfig}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={musicName}
|
||||||
|
onChange={(e) => setMusicName(e.target.value)}
|
||||||
|
placeholder="音乐名称"
|
||||||
|
className={styles.musicNameInput}
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
<SaveMusicButton />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 选中的声音展示 */}
|
||||||
|
{expandedCurrent && (
|
||||||
|
<div className={styles.sounds}>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{selectedSounds.map((sound) => (
|
||||||
|
<Sound
|
||||||
|
key={sound.id}
|
||||||
|
id={sound.id}
|
||||||
|
icon={sound.icon}
|
||||||
|
label={sound.label}
|
||||||
|
src={sound.src}
|
||||||
|
functional={false}
|
||||||
|
displayMode={true}
|
||||||
|
hidden={false}
|
||||||
|
selectHidden={() => {}}
|
||||||
|
unselectHidden={() => {}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 音乐列表模块 - 只有登录用户且有音乐时才显示 */}
|
||||||
|
{isAuthenticated && savedMusicList.length > 0 && (
|
||||||
|
<div className={`${styles.musicListModule} ${styles.musicSection}`}>
|
||||||
|
<div className={styles.musicHeader}>
|
||||||
|
<h4 className={styles.musicTitle}>
|
||||||
|
<FaCog className={styles.musicIcon} />
|
||||||
|
音乐列表
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{error && (
|
||||||
|
<div className={styles.error}>
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError(null)} className={styles.errorClose}>×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 保存成功提示 */}
|
||||||
|
{showSaveSuccess && (
|
||||||
|
<div className={styles.saveSuccess}>
|
||||||
|
<p>✓ 音乐保存成功!</p>
|
||||||
|
<button onClick={() => setShowSaveSuccess(false)}>
|
||||||
|
确定
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 音乐列表 - 展开时显示 */}
|
||||||
|
{expandedMyMusic && (
|
||||||
|
<div className={`${styles.musicList} ${expandedMusic.size > 0 ? styles.hasExpanded : ''}`}>
|
||||||
|
{console.log('🎵 渲染音乐列表:', { isLoadingMusic, listLength: savedMusicList.length })}
|
||||||
|
{isLoadingMusic ? (
|
||||||
|
<div className={styles.loading}>加载中...</div>
|
||||||
|
) : savedMusicList.length === 0 ? (
|
||||||
|
<div className={styles.empty}>
|
||||||
|
<FaMusic className={styles.emptyIcon} />
|
||||||
|
<p>还没有保存的音乐</p>
|
||||||
|
<p className={styles.emptyHint}>选择声音并点击保存按钮来创建你的第一首音乐</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{savedMusicList.map((music) => (
|
||||||
|
<div key={music.id} className={styles.musicItem}>
|
||||||
|
{editingId === music.id.toString() ? (
|
||||||
|
<div className={styles.editForm}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingName}
|
||||||
|
onChange={(e) => setEditingName(e.target.value)}
|
||||||
|
className={styles.editInput}
|
||||||
|
placeholder="输入音乐名称"
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
<div className={styles.editButtons}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (editingName.trim()) {
|
||||||
|
renameMusic(music.id.toString(), editingName.trim());
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`${styles.editButton} ${styles.saveButton}`}
|
||||||
|
title="保存"
|
||||||
|
>
|
||||||
|
<FaSave />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingId(null)}
|
||||||
|
className={`${styles.editButton} ${styles.cancelButton}`}
|
||||||
|
title="取消"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.musicContent}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (currentlyPlayingMusic?.id === music.id) {
|
||||||
|
stopMusic();
|
||||||
|
} else {
|
||||||
|
playMusicRecord(music);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`${styles.playButton} ${
|
||||||
|
currentlyPlayingMusic?.id === music.id ? styles.playing : ''
|
||||||
|
}`}
|
||||||
|
title={currentlyPlayingMusic?.id === music.id ? "停止播放" : "播放这首音乐"}
|
||||||
|
>
|
||||||
|
{currentlyPlayingMusic?.id === music.id ? <FaStop /> : <FaPlay />}
|
||||||
|
</button>
|
||||||
|
<div className={styles.musicInfo}>
|
||||||
|
<div className={styles.musicName}>{music.name}</div>
|
||||||
|
<div className={styles.soundNames}>
|
||||||
|
{music.sounds && music.sounds.length > 0 ? (
|
||||||
|
music.sounds.map((soundId, index) => {
|
||||||
|
// 从所有声音中查找对应的声音名称
|
||||||
|
const allSounds = localizedCategories
|
||||||
|
.map(category => category.sounds)
|
||||||
|
.flat();
|
||||||
|
const sound = allSounds.find(s => s.id === soundId);
|
||||||
|
return sound ? (
|
||||||
|
<span key={soundId} className={styles.soundName}>
|
||||||
|
{sound.label}{index < music.sounds.length - 1 ? ', ' : ''}
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<span className={styles.noSounds}>暂无声音</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.musicActions}>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteMusic(music.id.toString())}
|
||||||
|
className={styles.deleteButton}
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<FaTrash />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleMusicExpansion(music.id)}
|
||||||
|
className={styles.expandButton}
|
||||||
|
title="展开/收起声音详情"
|
||||||
|
>
|
||||||
|
{expandedMusic.has(music.id) ? '收起 ▲' : '展开 ▼'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 展开时显示的声音内容 */}
|
||||||
|
{expandedMusic.has(music.id) && (
|
||||||
|
<div className={styles.expandedMusicContent}>
|
||||||
|
{/* 声音组件展示 */}
|
||||||
|
<div className={styles.sounds}>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{music.sounds.map((soundId) => {
|
||||||
|
// 从所有声音中查找对应的声音
|
||||||
|
const allSounds = localizedCategories
|
||||||
|
.map(category => category.sounds)
|
||||||
|
.flat();
|
||||||
|
const sound = allSounds.find(s => s.id === soundId);
|
||||||
|
|
||||||
|
if (!sound) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sound
|
||||||
|
key={`${music.id}-${soundId}`}
|
||||||
|
id={soundId}
|
||||||
|
icon={sound.icon}
|
||||||
|
label={sound.label}
|
||||||
|
src={sound.src}
|
||||||
|
functional={false}
|
||||||
|
displayMode={true}
|
||||||
|
hidden={false}
|
||||||
|
selectHidden={() => {}}
|
||||||
|
unselectHidden={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 登录提示 */}
|
||||||
|
{showLoginPrompt && (
|
||||||
|
<div className={styles.loginPrompt}>
|
||||||
|
<p>请先登录后再保存音乐</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowLoginPrompt(false);
|
||||||
|
// 触发LanguageSwitcher的登录表单
|
||||||
|
const event = new CustomEvent('showLoginForm', { bubbles: true });
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
去登录
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowLoginPrompt(false)}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -11,14 +11,14 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
background: var(--color-neutral-200);
|
background: var(--color-control-bg);
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sliderRange {
|
.sliderRange {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--color-neutral-800);
|
background: var(--color-control-progress);
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,16 +27,18 @@
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: var(--color-neutral-950);
|
background: var(--color-control-progress);
|
||||||
|
border: 2px solid var(--color-control-progress);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-shadow: 0 0 3px var(--color-neutral-50);
|
box-shadow: 0 0 4px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sliderThumb:hover {
|
.sliderThumb:hover {
|
||||||
background: var(--color-neutral-800);
|
background: var(--color-control-bg-hover);
|
||||||
|
border-color: var(--color-control-bg-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sliderThumb:focus {
|
.sliderThumb:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: 0 0 0 3px var(--color-neutral-400);
|
box-shadow: 0 0 0 3px var(--color-foreground-subtler);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
.favoriteButton {
|
.favoriteButton {
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -10,23 +7,24 @@
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
color: var(--color-foreground-subtle);
|
color: var(--color-foreground-subtle);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-color: black;
|
background-color: var(--component-bg);
|
||||||
background-color: var(--color-neutral-100);
|
border: 1px solid var(--color-border);
|
||||||
border: 1px solid var(--color-neutral-200);
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
transition: 0.2s;
|
transition: 0.2s;
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
color: var(--color-foreground);
|
color: var(--color-foreground);
|
||||||
|
background-color: var(--component-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
outline: 2px solid var(--color-neutral-400);
|
outline: 2px solid var(--color-muted);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.isFavorite {
|
&.isFavorite {
|
||||||
color: var(--color-foreground);
|
color: var(--color-foreground);
|
||||||
|
background-color: var(--component-active);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
src/components/sounds/sound/random-speed/index.ts
Normal file
1
src/components/sounds/sound/random-speed/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { RandomSpeed } from './random-speed';
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
.randomSpeedButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 0;
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--component-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: 0.2s;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
color: var(--color-foreground);
|
||||||
|
background-color: var(--component-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-muted);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.isRandomSpeed {
|
||||||
|
color: var(--color-foreground);
|
||||||
|
background-color: var(--component-active);
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/components/sounds/sound/random-speed/random-speed.tsx
Normal file
89
src/components/sounds/sound/random-speed/random-speed.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { FiShuffle } from 'react-icons/fi/index';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
|
||||||
|
import { useSoundStore } from '@/stores/sound';
|
||||||
|
import { cn } from '@/helpers/styles';
|
||||||
|
import { fade } from '@/lib/motion';
|
||||||
|
|
||||||
|
import styles from './random-speed.module.css';
|
||||||
|
|
||||||
|
import { useKeyboardButton } from '@/hooks/use-keyboard-button';
|
||||||
|
import { random } from '@/helpers/random';
|
||||||
|
|
||||||
|
interface RandomSpeedProps {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
baseSpeed: number;
|
||||||
|
baseRate: number;
|
||||||
|
baseVolume: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RandomSpeed({ id, label, baseSpeed, baseRate, baseVolume }: RandomSpeedProps) {
|
||||||
|
const isRandomSpeed = useSoundStore(state => state.sounds[id].isRandomSpeed);
|
||||||
|
const isRandomVolume = useSoundStore(state => state.sounds[id].isRandomVolume);
|
||||||
|
const isRandomRate = useSoundStore(state => state.sounds[id].isRandomRate);
|
||||||
|
const toggleAllRandom = useSoundStore(state => state.toggleAllRandom);
|
||||||
|
const setSpeed = useSoundStore(state => state.setSpeed);
|
||||||
|
const setRate = useSoundStore(state => state.setRate);
|
||||||
|
const setVolume = useSoundStore(state => state.setVolume);
|
||||||
|
|
||||||
|
const hasAnyRandom = isRandomSpeed || isRandomVolume || isRandomRate;
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
toggleAllRandom(id);
|
||||||
|
|
||||||
|
if (!hasAnyRandom) {
|
||||||
|
// 启用随机时,立即设置随机值
|
||||||
|
if (isRandomSpeed) {
|
||||||
|
const randomSpeed = random(baseSpeed - 0.25, baseSpeed + 0.25);
|
||||||
|
setSpeed(id, Math.max(0.5, Math.min(2.0, randomSpeed)));
|
||||||
|
}
|
||||||
|
if (isRandomRate) {
|
||||||
|
const randomRate = random(baseRate - 0.25, baseRate + 0.25);
|
||||||
|
setRate(id, Math.max(0.5, Math.min(2.0, randomRate)));
|
||||||
|
}
|
||||||
|
if (isRandomVolume) {
|
||||||
|
const randomVolume = random(baseVolume * 0.3, baseVolume * 0.7);
|
||||||
|
setVolume(id, Math.max(0.0, Math.min(1.0, randomVolume)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 禁用随机时,恢复基础值
|
||||||
|
setSpeed(id, baseSpeed);
|
||||||
|
setRate(id, baseRate);
|
||||||
|
setVolume(id, baseVolume);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const variants = fade();
|
||||||
|
|
||||||
|
const handleKeyDown = useKeyboardButton(handleToggle);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence initial={false} mode="wait">
|
||||||
|
<button
|
||||||
|
className={cn(styles.randomSpeedButton, hasAnyRandom && styles.isRandomSpeed)}
|
||||||
|
aria-label={
|
||||||
|
hasAnyRandom
|
||||||
|
? `Disable Random Effects for ${label} Sound`
|
||||||
|
: `Enable Random Effects for ${label} Sound`
|
||||||
|
}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleToggle();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<motion.span
|
||||||
|
animate="show"
|
||||||
|
aria-hidden="true"
|
||||||
|
exit="hidden"
|
||||||
|
initial="hidden"
|
||||||
|
key={hasAnyRandom ? `${id}-is-random` : `${id}-not-random`}
|
||||||
|
variants={variants}
|
||||||
|
>
|
||||||
|
<FiShuffle />
|
||||||
|
</motion.span>
|
||||||
|
</button>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,72 +1,45 @@
|
||||||
.range {
|
.controlsContainer {
|
||||||
width: 100%;
|
display: flex;
|
||||||
max-width: 120px;
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
|
width: 100%;
|
||||||
/********** Range Input Styles **********/
|
max-width: 150px;
|
||||||
|
}
|
||||||
/* Range Reset */
|
|
||||||
appearance: none;
|
.volumeContainer,
|
||||||
cursor: pointer;
|
.speedContainer,
|
||||||
background: transparent;
|
.rateContainer {
|
||||||
|
display: flex;
|
||||||
/* Removes default focus */
|
align-items: center;
|
||||||
&:focus {
|
gap: 8px;
|
||||||
outline: none;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
.volumeIcon,
|
||||||
pointer-events: none;
|
.speedIcon,
|
||||||
cursor: default;
|
.rateIcon {
|
||||||
opacity: 0.5;
|
color: var(--color-foreground-subtle);
|
||||||
}
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
/***** Chrome, Safari, Opera and Edge Chromium styles *****/
|
opacity: 0.8;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
&::-webkit-slider-runnable-track {
|
}
|
||||||
height: 0.5rem;
|
|
||||||
background-color: #27272a;
|
.volumeContainer:hover .volumeIcon,
|
||||||
border-radius: 0.5rem;
|
.speedContainer:hover .speedIcon,
|
||||||
}
|
.rateContainer:hover .rateIcon {
|
||||||
|
opacity: 1;
|
||||||
&::-webkit-slider-thumb {
|
}
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
/* 当滑块禁用时,容器内的图标也要相应调整 */
|
||||||
margin-top: -3px;
|
.volumeContainer:has(.slider:disabled) .volumeIcon,
|
||||||
appearance: none;
|
.speedContainer:has(.slider:disabled) .speedIcon,
|
||||||
background-color: #3f3f46;
|
.rateContainer:has(.slider:disabled) .rateIcon {
|
||||||
border: 1px solid #52525b;
|
opacity: 0.4;
|
||||||
border-radius: 50%;
|
}
|
||||||
}
|
|
||||||
|
.slider {
|
||||||
&:not(:disabled):focus::-webkit-slider-thumb {
|
width: 100%;
|
||||||
border: 1px solid #053a5f;
|
flex: 1;
|
||||||
outline: 3px solid #053a5f;
|
|
||||||
outline-offset: 0.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/******** Firefox styles ********/
|
|
||||||
|
|
||||||
&::-moz-range-track {
|
|
||||||
height: 0.5rem;
|
|
||||||
background-color: #27272a;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-moz-range-thumb {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
margin-top: -3px;
|
|
||||||
background-color: #3f3f46;
|
|
||||||
border: none;
|
|
||||||
border: 1px solid #52525b;
|
|
||||||
border-radius: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(:disabled):focus::-moz-range-thumb {
|
|
||||||
border: 1px solid #053a5f;
|
|
||||||
outline: 3px solid #053a5f;
|
|
||||||
outline-offset: 0.125rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
|
import { FaVolumeUp, FaTachometerAlt, FaMusic } from 'react-icons/fa/index';
|
||||||
import { useSoundStore } from '@/stores/sound';
|
import { useSoundStore } from '@/stores/sound';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
import { Slider } from '@/components/slider';
|
||||||
|
|
||||||
import styles from './range.module.css';
|
import styles from './range.module.css';
|
||||||
|
|
||||||
|
|
@ -8,25 +11,54 @@ interface RangeProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Range({ id, label }: RangeProps) {
|
export function Range({ id, label }: RangeProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const setVolume = useSoundStore(state => state.setVolume);
|
const setVolume = useSoundStore(state => state.setVolume);
|
||||||
|
const setSpeed = useSoundStore(state => state.setSpeed);
|
||||||
|
const setRate = useSoundStore(state => state.setRate);
|
||||||
const volume = useSoundStore(state => state.sounds[id].volume);
|
const volume = useSoundStore(state => state.sounds[id].volume);
|
||||||
|
const speed = useSoundStore(state => state.sounds[id].speed);
|
||||||
|
const rate = useSoundStore(state => state.sounds[id].rate);
|
||||||
const isSelected = useSoundStore(state => state.sounds[id].isSelected);
|
const isSelected = useSoundStore(state => state.sounds[id].isSelected);
|
||||||
const locked = useSoundStore(state => state.locked);
|
const locked = useSoundStore(state => state.locked);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<div className={styles.controlsContainer}>
|
||||||
aria-label={`${label} sound volume`}
|
<div className={styles.volumeContainer}>
|
||||||
autoComplete="off"
|
<FaVolumeUp className={styles.volumeIcon} />
|
||||||
className={styles.range}
|
<Slider
|
||||||
disabled={!isSelected}
|
disabled={!isSelected}
|
||||||
max={100}
|
max={1}
|
||||||
min={0}
|
min={0}
|
||||||
type="range"
|
step={0.01}
|
||||||
value={volume * 100}
|
value={volume}
|
||||||
onClick={e => e.stopPropagation()}
|
onChange={value => !locked && isSelected && setVolume(id, value)}
|
||||||
onChange={e =>
|
className={styles.slider}
|
||||||
!locked && isSelected && setVolume(id, Number(e.target.value) / 100)
|
/>
|
||||||
}
|
</div>
|
||||||
/>
|
<div className={styles.speedContainer}>
|
||||||
|
<FaTachometerAlt className={styles.speedIcon} />
|
||||||
|
<Slider
|
||||||
|
disabled={!isSelected}
|
||||||
|
max={2}
|
||||||
|
min={0.5}
|
||||||
|
step={0.1}
|
||||||
|
value={speed}
|
||||||
|
onChange={value => !locked && isSelected && setSpeed(id, value)}
|
||||||
|
className={styles.slider}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.rateContainer}>
|
||||||
|
<FaMusic className={styles.rateIcon} />
|
||||||
|
<Slider
|
||||||
|
disabled={!isSelected}
|
||||||
|
max={2}
|
||||||
|
min={0.5}
|
||||||
|
step={0.1}
|
||||||
|
value={rate}
|
||||||
|
onChange={value => !locked && isSelected && setRate(id, value)}
|
||||||
|
className={styles.slider}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,13 @@
|
||||||
padding: 25px 20px;
|
padding: 25px 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: linear-gradient(rgb(24 24 27 / 50%), transparent);
|
background: var(--color-component-bg);
|
||||||
border: 1px solid var(--color-neutral-200);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
transition: 0.2s;
|
transition: 0.2s;
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
outline: 2px solid var(--color-neutral-400);
|
outline: 2px solid var(--color-muted);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
transparent,
|
transparent,
|
||||||
var(--color-neutral-400),
|
var(--color-muted),
|
||||||
transparent
|
transparent
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -60,7 +60,7 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
content: '';
|
content: '';
|
||||||
background-color: var(--color-neutral-50);
|
background-color: var(--color-control-bg);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,8 +73,8 @@
|
||||||
height: calc(100% + 2px);
|
height: calc(100% + 2px);
|
||||||
content: '';
|
content: '';
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
var(--color-neutral-300),
|
var(--bg-quaternary),
|
||||||
var(--color-neutral-100)
|
var(--bg-secondary)
|
||||||
);
|
);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
@ -95,7 +95,7 @@
|
||||||
|
|
||||||
&.selected {
|
&.selected {
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
box-shadow: 0 0 0 2px var(--color-neutral-800);
|
box-shadow: 0 0 0 2px var(--color-border);
|
||||||
|
|
||||||
& .icon {
|
& .icon {
|
||||||
color: var(--color-foreground);
|
color: var(--color-foreground);
|
||||||
|
|
@ -111,6 +111,17 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.controlsContainer {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
z-index: 10;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes spinner {
|
@keyframes spinner {
|
||||||
0% {
|
0% {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { ImSpinner9 } from 'react-icons/im/index';
|
||||||
|
|
||||||
import { Range } from './range';
|
import { Range } from './range';
|
||||||
import { Favorite } from './favorite';
|
import { Favorite } from './favorite';
|
||||||
|
import { RandomSpeed } from './random-speed';
|
||||||
|
|
||||||
import { useSound } from '@/hooks/use-sound';
|
import { useSound } from '@/hooks/use-sound';
|
||||||
import { useSoundStore } from '@/stores/sound';
|
import { useSoundStore } from '@/stores/sound';
|
||||||
|
|
@ -20,10 +21,11 @@ interface SoundProps extends SoundType {
|
||||||
hidden: boolean;
|
hidden: boolean;
|
||||||
selectHidden: (key: string) => void;
|
selectHidden: (key: string) => void;
|
||||||
unselectHidden: (key: string) => void;
|
unselectHidden: (key: string) => void;
|
||||||
|
displayMode?: boolean; // 新增:展示模式参数
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Sound = forwardRef<HTMLDivElement, SoundProps>(function Sound(
|
export const Sound = forwardRef<HTMLDivElement, SoundProps>(function Sound(
|
||||||
{ functional, hidden, icon, id, label, selectHidden, src, unselectHidden },
|
{ functional, hidden, icon, id, label, selectHidden, src, unselectHidden, displayMode = false },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const isPlaying = useSoundStore(state => state.isPlaying);
|
const isPlaying = useSoundStore(state => state.isPlaying);
|
||||||
|
|
@ -31,35 +33,96 @@ export const Sound = forwardRef<HTMLDivElement, SoundProps>(function Sound(
|
||||||
const selectSound = useSoundStore(state => state.select);
|
const selectSound = useSoundStore(state => state.select);
|
||||||
const unselectSound = useSoundStore(state => state.unselect);
|
const unselectSound = useSoundStore(state => state.unselect);
|
||||||
const setVolume = useSoundStore(state => state.setVolume);
|
const setVolume = useSoundStore(state => state.setVolume);
|
||||||
|
const setSpeed = useSoundStore(state => state.setSpeed);
|
||||||
|
const setRate = useSoundStore(state => state.setRate);
|
||||||
const isSelected = useSoundStore(state => state.sounds[id].isSelected);
|
const isSelected = useSoundStore(state => state.sounds[id].isSelected);
|
||||||
const locked = useSoundStore(state => state.locked);
|
const locked = useSoundStore(state => state.locked);
|
||||||
|
|
||||||
const volume = useSoundStore(state => state.sounds[id].volume);
|
const volume = useSoundStore(state => state.sounds[id].volume);
|
||||||
|
const speed = useSoundStore(state => state.sounds[id].speed);
|
||||||
|
const rate = useSoundStore(state => state.sounds[id].rate);
|
||||||
|
const isRandomSpeed = useSoundStore(state => state.sounds[id].isRandomSpeed);
|
||||||
|
const isRandomVolume = useSoundStore(state => state.sounds[id].isRandomVolume);
|
||||||
|
const isRandomRate = useSoundStore(state => state.sounds[id].isRandomRate);
|
||||||
const globalVolume = useSoundStore(state => state.globalVolume);
|
const globalVolume = useSoundStore(state => state.globalVolume);
|
||||||
const adjustedVolume = useMemo(
|
const adjustedVolume = useMemo(
|
||||||
() => volume * globalVolume,
|
() => volume * globalVolume,
|
||||||
[volume, globalVolume],
|
[volume, globalVolume],
|
||||||
);
|
);
|
||||||
|
const actualPlaybackRate = useMemo(
|
||||||
|
() => speed * rate,
|
||||||
|
[speed, rate],
|
||||||
|
);
|
||||||
|
|
||||||
const isLoading = useLoadingStore(state => state.loaders[src]);
|
const isLoading = src ? useLoadingStore(state => state.loaders[src]) : false;
|
||||||
|
|
||||||
const sound = useSound(src, { loop: true, volume: adjustedVolume });
|
// 确保 src 存在才创建声音实例
|
||||||
|
const sound = useSound(src || '', { loop: true, volume: adjustedVolume, speed: actualPlaybackRate });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (locked) return;
|
if (locked) return;
|
||||||
|
|
||||||
if (isSelected && isPlaying && functional) {
|
// 在展示模式下或者功能模式下,只要选中且在播放就应该播放
|
||||||
|
const shouldPlay = isSelected && isPlaying && (functional || displayMode);
|
||||||
|
|
||||||
|
if (shouldPlay) {
|
||||||
sound?.play();
|
sound?.play();
|
||||||
} else {
|
} else {
|
||||||
sound?.pause();
|
sound?.pause();
|
||||||
}
|
}
|
||||||
}, [isSelected, sound, isPlaying, functional, locked]);
|
}, [isSelected, sound, isPlaying, functional, displayMode, locked]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hidden && isSelected) selectHidden(label);
|
if (hidden && isSelected) selectHidden(label);
|
||||||
else if (hidden && !isSelected) unselectHidden(label);
|
else if (hidden && !isSelected) unselectHidden(label);
|
||||||
}, [label, isSelected, hidden, selectHidden, unselectHidden]);
|
}, [label, isSelected, hidden, selectHidden, unselectHidden]);
|
||||||
|
|
||||||
|
// 改进的随机逻辑 - 每次只随机调整一个参数,频率为1分钟
|
||||||
|
useEffect(() => {
|
||||||
|
const hasAnyRandom = isRandomSpeed || isRandomVolume || isRandomRate;
|
||||||
|
const isActiveMode = functional || displayMode;
|
||||||
|
if (!hasAnyRandom || !isSelected || !isPlaying || !isActiveMode) return;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
// 获取当前启用的随机选项列表
|
||||||
|
const randomOptions = [];
|
||||||
|
if (isRandomSpeed) randomOptions.push('speed');
|
||||||
|
if (isRandomRate) randomOptions.push('rate');
|
||||||
|
if (isRandomVolume) randomOptions.push('volume');
|
||||||
|
|
||||||
|
if (randomOptions.length === 0) return;
|
||||||
|
|
||||||
|
// 随机选择一个要调整的参数
|
||||||
|
const selectedOption = randomOptions[Math.floor(Math.random() * randomOptions.length)];
|
||||||
|
|
||||||
|
switch (selectedOption) {
|
||||||
|
case 'speed': {
|
||||||
|
const baseSpeed = 1.0;
|
||||||
|
const randomSpeed = Math.random() * 0.5 + baseSpeed - 0.25; // baseSpeed ± 0.25
|
||||||
|
const clampedSpeed = Math.max(0.5, Math.min(2.0, randomSpeed));
|
||||||
|
setSpeed(id, clampedSpeed);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'rate': {
|
||||||
|
const baseRate = 1.0;
|
||||||
|
const randomRate = Math.random() * 0.5 + baseRate - 0.25; // baseRate ± 0.25
|
||||||
|
const clampedRate = Math.max(0.5, Math.min(2.0, randomRate));
|
||||||
|
setRate(id, clampedRate);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'volume': {
|
||||||
|
const baseVolume = 0.5;
|
||||||
|
const randomVolume = Math.random() * 0.4 + baseVolume * 0.3; // 30% - 70% 范围
|
||||||
|
const clampedVolume = Math.max(0.0, Math.min(1.0, randomVolume));
|
||||||
|
setVolume(id, clampedVolume);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 60000 + Math.random() * 30000); // 每 60-90 秒更新一次,大约1分钟
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [isRandomSpeed, isRandomVolume, isRandomRate, isSelected, isPlaying, id, setSpeed, setRate, setVolume]);
|
||||||
|
|
||||||
const select = useCallback(() => {
|
const select = useCallback(() => {
|
||||||
if (locked) return;
|
if (locked) return;
|
||||||
selectSound(id);
|
selectSound(id);
|
||||||
|
|
@ -70,7 +133,17 @@ export const Sound = forwardRef<HTMLDivElement, SoundProps>(function Sound(
|
||||||
if (locked) return;
|
if (locked) return;
|
||||||
unselectSound(id);
|
unselectSound(id);
|
||||||
setVolume(id, 0.5);
|
setVolume(id, 0.5);
|
||||||
}, [unselectSound, setVolume, id, locked]);
|
setSpeed(id, 1.0);
|
||||||
|
setRate(id, 1.0);
|
||||||
|
|
||||||
|
// 确保所有随机模式都被重置
|
||||||
|
const { toggleRandomSpeed, toggleRandomVolume, toggleRandomRate } = useSoundStore.getState();
|
||||||
|
const sound = useSoundStore.getState().sounds[id];
|
||||||
|
|
||||||
|
if (sound?.isRandomSpeed) toggleRandomSpeed(id);
|
||||||
|
if (sound?.isRandomVolume) toggleRandomVolume(id);
|
||||||
|
if (sound?.isRandomRate) toggleRandomRate(id);
|
||||||
|
}, [unselectSound, setVolume, setSpeed, setRate, id, locked]);
|
||||||
|
|
||||||
const toggle = useCallback(() => {
|
const toggle = useCallback(() => {
|
||||||
if (locked) return;
|
if (locked) return;
|
||||||
|
|
@ -100,7 +173,10 @@ export const Sound = forwardRef<HTMLDivElement, SoundProps>(function Sound(
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
<Favorite id={id} label={label} />
|
<div className={styles.controlsContainer}>
|
||||||
|
<Favorite id={id} label={label} />
|
||||||
|
<RandomSpeed id={id} label={label} baseSpeed={speed} baseRate={rate} baseVolume={volume} />
|
||||||
|
</div>
|
||||||
<div className={styles.icon}>
|
<div className={styles.icon}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<span aria-hidden="true" className={styles.spinner}>
|
<span aria-hidden="true" className={styles.spinner}>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,639 @@
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 主容器 */
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px; /* 两个模块之间的间距 */
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 当前选中声音模块 */
|
||||||
|
.currentSoundsModule {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 音乐列表模块 */
|
||||||
|
.musicListModule {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.soundsContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: linear-gradient(135deg, #10b981, #059669);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 音乐列表区域基础样式 */
|
||||||
|
.musicSection {
|
||||||
|
/* 继承模块样式,不需要额外样式 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 当前选中声音标题样式 */
|
||||||
|
.currentSoundsHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.currentSoundsTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 音乐列表标题样式 */
|
||||||
|
.musicHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.musicTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.musicIcon {
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleMusicList {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleMusicList:hover {
|
||||||
|
background: var(--component-hover);
|
||||||
|
border-color: var(--color-foreground-subtle);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.musicList {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color-component-bg);
|
||||||
|
transition: max-height 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 当有展开的音乐项时,移除高度限制 */
|
||||||
|
.musicList.hasExpanded {
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.musicItem {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.musicItem:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.musicItem:hover {
|
||||||
|
background: var(--component-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 音乐内容容器 - 新的横向布局 */
|
||||||
|
.musicContent {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playButton {
|
||||||
|
background: var(--color-foreground);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playButton:hover {
|
||||||
|
background: var(--color-foreground-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playButton.playing {
|
||||||
|
background: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playButton.playing:hover {
|
||||||
|
background: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 音乐名称 */
|
||||||
|
.musicName {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
line-height: 1.2;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 音乐信息容器 - 左侧信息 */
|
||||||
|
.musicInfo {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 音乐操作按钮容器 - 右侧按钮 */
|
||||||
|
.musicActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 声音名字显示 */
|
||||||
|
.soundNames {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
line-height: 1.2;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 展开按钮 */
|
||||||
|
.expandButton {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: auto;
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandButton:hover {
|
||||||
|
background: var(--component-hover);
|
||||||
|
border-color: var(--color-foreground-subtle);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 当前选中音乐的展开按钮 */
|
||||||
|
.expandButtonCurrent {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 32px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandButtonCurrent:hover {
|
||||||
|
background: var(--component-hover);
|
||||||
|
border-color: var(--color-foreground-subtle);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.soundName {
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
background: rgba(var(--color-muted-rgb), 0.3);
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-right: 2px;
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
border: 1px solid rgba(var(--color-border-rgb), 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.noSounds {
|
||||||
|
color: var(--color-foreground-subtler);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteButton {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteButton:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 编辑表单样式 */
|
||||||
|
.editForm {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editInput {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editInput:focus {
|
||||||
|
border-color: var(--color-muted);
|
||||||
|
box-shadow: 0 0 0 2px var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editButtons {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editButton {
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editButton.saveButton {
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editButton.saveButton:hover {
|
||||||
|
background: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editButton.cancelButton {
|
||||||
|
background: var(--color-muted);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editButton.cancelButton:hover {
|
||||||
|
background: var(--color-foreground-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 音乐名称配置区域 */
|
||||||
|
.musicNameConfig {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
/* 移除边框和背景 */
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.musicNameInput {
|
||||||
|
width: 6.25em; /* 5em * 1.25 = 6.25em,增大1/4 */
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.musicNameInput:focus {
|
||||||
|
border-color: var(--color-muted);
|
||||||
|
box-shadow: 0 0 0 2px var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.musicNameInput::placeholder {
|
||||||
|
color: var(--color-foreground-subtler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态和加载状态 */
|
||||||
|
.empty {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyIcon {
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--color-foreground-subtler);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty p {
|
||||||
|
margin: 4px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyHint {
|
||||||
|
font-size: 12px !important;
|
||||||
|
color: var(--color-foreground-subtler) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 错误提示样式 */
|
||||||
|
.error {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid #ef4444;
|
||||||
|
color: #dc2626;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorClose {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorClose:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveButton:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #059669, #047857);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveButton:disabled {
|
||||||
|
background: var(--color-muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveButton.saving {
|
||||||
|
background: var(--color-muted);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveSuccess {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--component-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 1002;
|
||||||
|
min-width: 250px;
|
||||||
|
font-size: 14px;
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveSuccess p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveSuccess button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
margin: 0 4px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background: var(--color-foreground);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveSuccess button:hover {
|
||||||
|
background: var(--color-foreground-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginPrompt {
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--component-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginPrompt p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginPrompt button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
margin: 0 4px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginPrompt button:first-child {
|
||||||
|
background: var(--color-foreground);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginPrompt button:last-child {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginPrompt button:hover:first-child {
|
||||||
|
background: var(--color-foreground-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginPrompt button:hover:last-child {
|
||||||
|
background: var(--component-hover);
|
||||||
|
border-color: var(--color-foreground-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 展开的音乐内容 */
|
||||||
|
.expandedMusicContent {
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--color-component-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandedMusicActions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playMusicButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--color-foreground);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playMusicButton:hover {
|
||||||
|
background: var(--color-foreground-subtle);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 展开音乐内的声音网格 */
|
||||||
|
.expandedMusicContent .sounds {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -17,18 +650,18 @@
|
||||||
font-size: var(--font-xsm);
|
font-size: var(--font-xsm);
|
||||||
color: var(--color-neutral-subtle);
|
color: var(--color-neutral-subtle);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-color: var(--color-neutral-50);
|
background-color: var(--bg-secondary);
|
||||||
border: 1px solid var(--color-neutral-200);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 50px;
|
border-radius: 50px;
|
||||||
transition: 0.2s;
|
transition: 0.2s;
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
background-color: var(--color-neutral-100);
|
background-color: var(--bg-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
outline: 2px solid var(--color-neutral-400);
|
outline: 2px solid var(--color-muted);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,7 +675,7 @@
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
transparent,
|
transparent,
|
||||||
var(--color-neutral-300),
|
var(--color-muted),
|
||||||
transparent
|
transparent
|
||||||
);
|
);
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
|
|
@ -55,7 +688,7 @@
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
content: '';
|
content: '';
|
||||||
background-color: var(--color-neutral-950);
|
background-color: var(--color-foreground);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
|
||||||
import { Sound } from './sound';
|
import { Sound } from './sound';
|
||||||
import { useLocalStorage } from '@/hooks/use-local-storage';
|
import { useLocalStorage } from '@/hooks/use-local-storage';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import { cn } from '@/helpers/styles';
|
import { cn } from '@/helpers/styles';
|
||||||
import { fade, scale, mix } from '@/lib/motion';
|
import { fade, scale, mix } from '@/lib/motion';
|
||||||
|
|
||||||
|
|
@ -17,6 +18,7 @@ interface SoundsProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sounds({ functional, id, sounds }: SoundsProps) {
|
export function Sounds({ functional, id, sounds }: SoundsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [showAll, setShowAll] = useLocalStorage(`${id}-show-more`, false);
|
const [showAll, setShowAll] = useLocalStorage(`${id}-show-more`, false);
|
||||||
const [clickedMore, setClickedMore] = useState(false);
|
const [clickedMore, setClickedMore] = useState(false);
|
||||||
|
|
||||||
|
|
@ -106,7 +108,7 @@ export function Sounds({ functional, id, sounds }: SoundsProps) {
|
||||||
onAnimationComplete={() => setIsAnimating(false)}
|
onAnimationComplete={() => setIsAnimating(false)}
|
||||||
onAnimationStart={() => setIsAnimating(true)}
|
onAnimationStart={() => setIsAnimating(true)}
|
||||||
>
|
>
|
||||||
{showAll ? 'Show Less' : 'Show More'}
|
{showAll ? t('showLess') : t('showMore')}
|
||||||
</motion.span>
|
</motion.span>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
1
src/components/theme-toggle/index.ts
Normal file
1
src/components/theme-toggle/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { ThemeToggle } from './theme-toggle';
|
||||||
46
src/components/theme-toggle/theme-toggle.module.css
Normal file
46
src/components/theme-toggle/theme-toggle.module.css
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
.themeToggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
z-index: 1000;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeToggle:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeToggle:focus {
|
||||||
|
outline: 2px solid var(--color-foreground);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeToggle:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗色主题下的特殊样式 */
|
||||||
|
:global(.dark-theme) .themeToggle {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .themeToggle:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
107
src/components/theme-toggle/theme-toggle.tsx
Normal file
107
src/components/theme-toggle/theme-toggle.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { FaSun, FaMoon } from 'react-icons/fa/index';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
|
||||||
|
import styles from './theme-toggle.module.css';
|
||||||
|
import { fade } from '@/lib/motion';
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const [isDarkTheme, setIsDarkTheme] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 从 localStorage 读取保存的主题,或使用系统偏好
|
||||||
|
const savedTheme = localStorage.getItem('theme');
|
||||||
|
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
|
||||||
|
const initialDarkTheme = savedTheme ? savedTheme === 'dark' : systemPrefersDark;
|
||||||
|
setIsDarkTheme(initialDarkTheme);
|
||||||
|
applyTheme(initialDarkTheme);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyTheme = (isDark: boolean) => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const body = document.body;
|
||||||
|
|
||||||
|
if (isDark) {
|
||||||
|
root.classList.add('dark-theme');
|
||||||
|
|
||||||
|
// 暗色主题 - 参考GitHub、VSCode等现代应用的深色主题
|
||||||
|
root.style.setProperty('--bg-primary', '#0d1117'); // 主背景 - 深蓝灰色(类似GitHub)
|
||||||
|
root.style.setProperty('--bg-secondary', '#161b22'); // 次要背景 - 稍浅的深蓝灰
|
||||||
|
root.style.setProperty('--bg-tertiary', '#21262d'); // 第三背景 - 中深蓝灰
|
||||||
|
root.style.setProperty('--bg-quaternary', '#30363d'); // 第四背景 - 蓝灰色
|
||||||
|
|
||||||
|
// 前景色 - 暗色主题使用高对比度的浅色文字
|
||||||
|
root.style.setProperty('--color-foreground', '#f0f6fc'); // 主前景色 - 高亮白色
|
||||||
|
root.style.setProperty('--color-foreground-subtle', '#8b949e'); // 次要前景色 - 柔和灰色
|
||||||
|
root.style.setProperty('--color-foreground-subtler', '#6e7681'); // 更次要前景色 - 中灰色
|
||||||
|
root.style.setProperty('--color-muted', '#484f58'); // 静音色 - 深灰色
|
||||||
|
root.style.setProperty('--color-border', '#30363d'); // 边框色 - 蓝灰色
|
||||||
|
|
||||||
|
// 组件特定背景
|
||||||
|
root.style.setProperty('--component-bg', '#161b22'); // 组件背景
|
||||||
|
root.style.setProperty('--component-hover', '#21262d'); // 组件悬停背景
|
||||||
|
root.style.setProperty('--component-active', '#30363d'); // 组件激活背景
|
||||||
|
root.style.setProperty('--modal-bg', '#0d1117'); // 模态框背景
|
||||||
|
root.style.setProperty('--input-bg', '#0d1117'); // 输入框背景
|
||||||
|
|
||||||
|
// 直接设置body背景为深色
|
||||||
|
body.style.backgroundColor = '#0d1117';
|
||||||
|
} else {
|
||||||
|
root.classList.remove('dark-theme');
|
||||||
|
|
||||||
|
// 明亮主题 - 参考现代简洁设计,更干净的白色系
|
||||||
|
root.style.setProperty('--bg-primary', '#ffffff'); // 主背景 - 纯白色
|
||||||
|
root.style.setProperty('--bg-secondary', '#f8fafc'); // 次要背景 - 浅灰白(更深一点)
|
||||||
|
root.style.setProperty('--bg-tertiary', '#f1f5f9'); // 第三背景 - 中浅灰(更深一点)
|
||||||
|
root.style.setProperty('--bg-quaternary', '#e2e8f0'); // 第四背景 - 中灰色(更深一点)
|
||||||
|
|
||||||
|
// 前景色 - 明亮主题使用深色文字,确保良好对比度
|
||||||
|
root.style.setProperty('--color-foreground', '#1e293b'); // 主前景色 - 深灰蓝(更深一点)
|
||||||
|
root.style.setProperty('--color-foreground-subtle', '#475569'); // 次要前景色 - 中深灰(更深一点)
|
||||||
|
root.style.setProperty('--color-foreground-subtler', '#64748b'); // 更次要前景色 - 中灰色(更深一点)
|
||||||
|
root.style.setProperty('--color-muted', '#94a3b8'); // 静音色 - 中灰(更深一点)
|
||||||
|
root.style.setProperty('--color-border', '#cbd5e1'); // 边框色 - 中浅灰(更深一点)
|
||||||
|
|
||||||
|
// 组件特定背景 - 明亮模式下使用浅色系
|
||||||
|
root.style.setProperty('--component-bg', '#ffffff'); // 组件背景 - 白色
|
||||||
|
root.style.setProperty('--component-hover', '#f8fafc'); // 组件悬停背景 - 浅灰白
|
||||||
|
root.style.setProperty('--component-active', '#f1f5f9'); // 组件激活背景 - 中浅灰
|
||||||
|
root.style.setProperty('--modal-bg', '#ffffff'); // 模态框背景 - 白色
|
||||||
|
root.style.setProperty('--input-bg', '#ffffff'); // 输入框背景 - 白色
|
||||||
|
|
||||||
|
// 直接设置body背景为白色
|
||||||
|
body.style.backgroundColor = '#ffffff';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const newTheme = !isDarkTheme;
|
||||||
|
setIsDarkTheme(newTheme);
|
||||||
|
applyTheme(newTheme);
|
||||||
|
localStorage.setItem('theme', newTheme ? 'dark' : 'light');
|
||||||
|
};
|
||||||
|
|
||||||
|
const variants = fade();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={styles.themeToggle}
|
||||||
|
onClick={toggleTheme}
|
||||||
|
aria-label={isDarkTheme ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
|
>
|
||||||
|
<AnimatePresence initial={false} mode="wait">
|
||||||
|
<motion.span
|
||||||
|
animate="show"
|
||||||
|
aria-hidden="true"
|
||||||
|
exit="hidden"
|
||||||
|
initial="hidden"
|
||||||
|
key={isDarkTheme ? 'moon' : 'sun'}
|
||||||
|
variants={variants}
|
||||||
|
>
|
||||||
|
{isDarkTheme ? <FaMoon /> : <FaSun />}
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import styles from './item.module.css';
|
||||||
|
|
||||||
interface ItemProps {
|
interface ItemProps {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
'data-i18n'?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
href?: string;
|
href?: string;
|
||||||
icon: React.ReactElement;
|
icon: React.ReactElement;
|
||||||
|
|
@ -15,6 +16,7 @@ interface ItemProps {
|
||||||
|
|
||||||
export function Item({
|
export function Item({
|
||||||
active,
|
active,
|
||||||
|
'data-i18n': dataI18n,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
href,
|
href,
|
||||||
icon,
|
icon,
|
||||||
|
|
@ -30,10 +32,11 @@ export function Item({
|
||||||
className={styles.item}
|
className={styles.item}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
{...(href ? { href, target: '_blank' } : {})}
|
{...(href ? { href, target: '_blank' } : {})}
|
||||||
|
{...(dataI18n ? { 'data-i18n': dataI18n } : {})}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
>
|
>
|
||||||
<span className={styles.label}>
|
<span className={styles.label}>
|
||||||
<span className={styles.icon}>{icon}</span> {label}
|
<span className={styles.icon}>{icon}</span> <span data-i18n={dataI18n}>{label}</span>
|
||||||
{active && <div className={styles.active} />}
|
{active && <div className={styles.active} />}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|
|
||||||
138
src/components/toolbar/menu/items/auth-item.tsx
Normal file
138
src/components/toolbar/menu/items/auth-item.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import styles from './item.module.css';
|
||||||
|
|
||||||
|
interface AuthItemProps {
|
||||||
|
open: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthItem({ open }: AuthItemProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isAuthenticated, user, login, logout, isLoading } = useAuthStore();
|
||||||
|
const [showAuthForm, setShowAuthForm] = useState(false);
|
||||||
|
const [isLogin, setIsLogin] = useState(true);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
if (isLogin) {
|
||||||
|
await login(formData);
|
||||||
|
} else {
|
||||||
|
// 这里需要调用注册API
|
||||||
|
// 为了简单起见,我们先用登录代替注册
|
||||||
|
await login(formData);
|
||||||
|
}
|
||||||
|
setShowAuthForm(false);
|
||||||
|
setFormData({ username: '', password: '' });
|
||||||
|
} catch (error) {
|
||||||
|
// 错误处理
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[e.target.name]: e.target.value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isAuthenticated && user) {
|
||||||
|
return (
|
||||||
|
<div className={styles.userInfo}>
|
||||||
|
<span className={styles.userAvatar}>
|
||||||
|
{user.username.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className={styles.userName}>{user.username}</span>
|
||||||
|
<DropdownMenu.Item asChild>
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
onClick={handleLogout}
|
||||||
|
className={styles.logoutButton}
|
||||||
|
>
|
||||||
|
退出登录
|
||||||
|
</motion.button>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 隐藏的触发器按钮 */}
|
||||||
|
<button
|
||||||
|
id="auth-trigger"
|
||||||
|
onClick={() => setShowAuthForm(true)}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showAuthForm && (
|
||||||
|
<div className={styles.authFormOverlay}>
|
||||||
|
<div className={styles.authForm}>
|
||||||
|
<h3>{isLogin ? '登录' : '注册'}</h3>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
placeholder="用户名"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
minLength={3}
|
||||||
|
className={styles.authInput}
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
placeholder="密码"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
className={styles.authInput}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
<div className={styles.authButtons}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className={styles.authSubmitButton}
|
||||||
|
>
|
||||||
|
{isLoading ? '处理中...' : (isLogin ? '登录' : '注册')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAuthForm(false)}
|
||||||
|
className={styles.authCancelButton}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.authToggle}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsLogin(!isLogin)}
|
||||||
|
className={styles.authToggleButton}
|
||||||
|
>
|
||||||
|
{isLogin ? '没有账号?注册' : '已有账号?登录'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
import { FaHeadphonesAlt } from 'react-icons/fa/index';
|
import { FaHeadphonesAlt } from 'react-icons/fa/index';
|
||||||
|
|
||||||
import { Item } from '../item';
|
import { Item } from '../item';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
|
||||||
interface BinauralProps {
|
interface BinauralProps {
|
||||||
open: () => void;
|
open: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Binaural({ open }: BinauralProps) {
|
export function Binaural({ open }: BinauralProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Item icon={<FaHeadphonesAlt />} label="Binaural Beats" onClick={open} />
|
<Item icon={<FaHeadphonesAlt />} label={t('binauralBeats')} onClick={open} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,19 @@
|
||||||
import { IoMdFlower } from 'react-icons/io/index';
|
import { IoMdFlower } from 'react-icons/io/index';
|
||||||
|
|
||||||
import { Item } from '../item';
|
import { Item } from '../item';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
|
||||||
interface BreathingExerciseProps {
|
interface BreathingExerciseProps {
|
||||||
open: () => void;
|
open: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BreathingExercise({ open }: BreathingExerciseProps) {
|
export function BreathingExercise({ open }: BreathingExerciseProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Item
|
<Item
|
||||||
icon={<IoMdFlower />}
|
icon={<IoMdFlower />}
|
||||||
label="Breathing Exercise"
|
label={t('breathingExercise')}
|
||||||
shortcut="Shift + B"
|
shortcut="Shift + B"
|
||||||
onClick={open}
|
onClick={open}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,19 @@
|
||||||
import { MdOutlineTimer } from 'react-icons/md/index';
|
import { MdOutlineTimer } from 'react-icons/md/index';
|
||||||
|
|
||||||
import { Item } from '../item';
|
import { Item } from '../item';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
|
||||||
interface CountdownProps {
|
interface CountdownProps {
|
||||||
open: () => void;
|
open: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Countdown({ open }: CountdownProps) {
|
export function Countdown({ open }: CountdownProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Item
|
<Item
|
||||||
icon={<MdOutlineTimer />}
|
icon={<MdOutlineTimer />}
|
||||||
label="Countdown Timer"
|
label={t('countdownTimer')}
|
||||||
shortcut="Shift + C"
|
shortcut="Shift + C"
|
||||||
onClick={open}
|
onClick={open}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
import { SiBuymeacoffee } from 'react-icons/si/index';
|
import { SiBuymeacoffee } from 'react-icons/si/index';
|
||||||
|
|
||||||
import { Item } from '../item';
|
import { Item } from '../item';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
|
||||||
export function Donate() {
|
export function Donate() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Item
|
<Item
|
||||||
href="https://buymeacoffee.com/remvze"
|
href="https://buymeacoffee.com/remvze"
|
||||||
icon={<SiBuymeacoffee />}
|
icon={<SiBuymeacoffee />}
|
||||||
label="Buy Me a Coffee"
|
label={t('buyMeACoffee')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,4 @@ export { Countdown as CountdownItem } from './countdown';
|
||||||
export { Binaural as BinauralItem } from './binaural';
|
export { Binaural as BinauralItem } from './binaural';
|
||||||
export { Isochronic as IsochronicItem } from './isochronic';
|
export { Isochronic as IsochronicItem } from './isochronic';
|
||||||
export { Lofi as LofiItem } from './lofi';
|
export { Lofi as LofiItem } from './lofi';
|
||||||
|
export { AuthItem } from './auth-item';
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
import { TbWaveSine } from 'react-icons/tb/index';
|
import { TbWaveSine } from 'react-icons/tb/index';
|
||||||
|
|
||||||
import { Item } from '../item';
|
import { Item } from '../item';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
|
||||||
interface IsochronicProps {
|
interface IsochronicProps {
|
||||||
open: () => void;
|
open: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Isochronic({ open }: IsochronicProps) {
|
export function Isochronic({ open }: IsochronicProps) {
|
||||||
return <Item icon={<TbWaveSine />} label="Isochronic Tones" onClick={open} />;
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return <Item icon={<TbWaveSine />} label={t('isochronicTones')} onClick={open} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
201
src/components/toolbar/menu/items/item.module.css
Normal file
201
src/components/toolbar/menu/items/item.module.css
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
.item {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-neutral-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-neutral-400);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.authContainer {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userInfo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userAvatar {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-foreground);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userName {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoutButton {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: #ef4444;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-neutral-400);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.authForm {
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--component-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
font-size: var(--font-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.authInput {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--color-foreground-subtler);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-muted);
|
||||||
|
box-shadow: 0 0 0 2px var(--color-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.authButtons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authSubmitButton {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--color-foreground);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--color-foreground-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.authCancelButton {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-foreground-subtler);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s, border-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--component-hover);
|
||||||
|
border-color: var(--color-foreground-subtle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.authToggle {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authToggleButton {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-foreground-subtle);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: var(--font-xsm);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
transition: color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.authFormOverlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,19 @@
|
||||||
import { IoIosMusicalNote } from 'react-icons/io/index';
|
import { IoIosMusicalNote } from 'react-icons/io/index';
|
||||||
|
|
||||||
import { Item } from '../item';
|
import { Item } from '../item';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
|
||||||
interface LofiProps {
|
interface LofiProps {
|
||||||
open: () => void;
|
open: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Lofi({ open }: LofiProps) {
|
export function Lofi({ open }: LofiProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Item
|
<Item
|
||||||
icon={<IoIosMusicalNote />}
|
icon={<IoIosMusicalNote />}
|
||||||
label="Lofi Music Player"
|
label={t('lofiMusicPlayer')}
|
||||||
onClick={open}
|
onClick={open}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,21 @@ import { MdNotes } from 'react-icons/md/index';
|
||||||
import { Item } from '../item';
|
import { Item } from '../item';
|
||||||
|
|
||||||
import { useNoteStore } from '@/stores/note';
|
import { useNoteStore } from '@/stores/note';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
|
||||||
interface NotepadProps {
|
interface NotepadProps {
|
||||||
open: () => void;
|
open: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Notepad({ open }: NotepadProps) {
|
export function Notepad({ open }: NotepadProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const note = useNoteStore(state => state.note);
|
const note = useNoteStore(state => state.note);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Item
|
<Item
|
||||||
active={!!note.length}
|
active={!!note.length}
|
||||||
icon={<MdNotes />}
|
icon={<MdNotes />}
|
||||||
label="Notepad"
|
label={t('notepad')}
|
||||||
shortcut="Shift + N"
|
shortcut="Shift + N"
|
||||||
onClick={open}
|
onClick={open}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,21 @@ import { MdOutlineAvTimer } from 'react-icons/md/index';
|
||||||
import { Item } from '../item';
|
import { Item } from '../item';
|
||||||
|
|
||||||
import { usePomodoroStore } from '@/stores/pomodoro';
|
import { usePomodoroStore } from '@/stores/pomodoro';
|
||||||
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
|
||||||
interface PomodoroProps {
|
interface PomodoroProps {
|
||||||
open: () => void;
|
open: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Pomodoro({ open }: PomodoroProps) {
|
export function Pomodoro({ open }: PomodoroProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const running = usePomodoroStore(state => state.running);
|
const running = usePomodoroStore(state => state.running);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Item
|
<Item
|
||||||
active={running}
|
active={running}
|
||||||
icon={<MdOutlineAvTimer />}
|
icon={<MdOutlineAvTimer />}
|
||||||
label="Pomodoro"
|
label={t('pomodoro')}
|
||||||
shortcut="Shift + P"
|
shortcut="Shift + P"
|
||||||
onClick={open}
|
onClick={open}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue