feat: add VitePress docs site, music file picker modal, Dockerfile optimization, i18n keys for 14 locales
- Add docs/ with VitePress site (19 pages): guide, config, architecture, FAQ - Add GitHub Actions workflow for auto-deploy to GitHub Pages - Replace music 'Add Tracks' upload picker with in-app audio file browser modal - Add music picker CSS styles with dark theme support - Add missing i18n keys (search_audio, no_audio_files, etc.) to all 14 locales - Optimize Dockerfile: shared base stage, COPY --chmod, consolidated RUN, HEALTHCHECK - Improve README: docs links, updated stats (222+ tests, 14 languages), feature status
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
name: Deploy Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: docs
|
||||
run: npm install
|
||||
|
||||
- name: Build docs
|
||||
working-directory: docs
|
||||
run: npm run docs:build
|
||||
|
||||
- uses: actions/configure-pages@v5
|
||||
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: docs/.vitepress/dist
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
+20
-19
@@ -1,8 +1,11 @@
|
||||
# Stage 1: Cache dependencies
|
||||
FROM rust:1.94.0-alpine3.23 AS cacher
|
||||
WORKDIR /app
|
||||
# ─── Stage 1: Shared build base (avoids duplicate apk install) ────────────────
|
||||
FROM rust:1.94.1-alpine3.23 AS base
|
||||
RUN apk --no-cache upgrade && \
|
||||
apk add --no-cache musl-dev pkgconfig postgresql-dev gcc perl make
|
||||
|
||||
# ─── Stage 2: Cache dependencies ─────────────────────────────────────────────
|
||||
FROM base AS cacher
|
||||
WORKDIR /app
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
# build.rs + static/ are needed so the build script can run and set OUT_DIR
|
||||
COPY build.rs ./
|
||||
@@ -13,11 +16,10 @@ RUN mkdir -p src/bin && \
|
||||
echo 'fn main() {}' > src/bin/generate-openapi.rs && \
|
||||
cargo build --release && \
|
||||
rm -rf src static-dist target/release/deps/oxicloud* target/release/build/oxicloud-*
|
||||
# Stage 2: Build the application
|
||||
FROM rust:1.94.0-alpine3.23 AS builder
|
||||
|
||||
# ─── Stage 3: Build the application ──────────────────────────────────────────
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
RUN apk --no-cache upgrade && \
|
||||
apk add --no-cache musl-dev pkgconfig postgresql-dev gcc perl make
|
||||
# Copy cached dependencies (only target dir and cargo registry)
|
||||
COPY --from=cacher /app/target target
|
||||
COPY --from=cacher /usr/local/cargo/registry /usr/local/cargo/registry
|
||||
@@ -30,7 +32,7 @@ COPY migrations migrations
|
||||
ARG DATABASE_URL="postgres://postgres:postgres@localhost/oxicloud"
|
||||
RUN DATABASE_URL="${DATABASE_URL}" cargo build --release
|
||||
|
||||
# Stage 3: Create minimal final image
|
||||
# ─── Stage 4: Minimal runtime image ──────────────────────────────────────────
|
||||
FROM alpine:3.23.3
|
||||
|
||||
# OCI image metadata
|
||||
@@ -44,19 +46,13 @@ LABEL org.opencontainers.image.title="OxiCloud" \
|
||||
# Install only necessary runtime dependencies and update packages
|
||||
# su-exec is needed by the entrypoint to drop privileges after fixing volume permissions
|
||||
RUN apk --no-cache upgrade && \
|
||||
apk add --no-cache libgcc ca-certificates libpq tzdata su-exec
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S oxicloud && \
|
||||
apk add --no-cache libgcc ca-certificates libpq tzdata su-exec && \
|
||||
addgroup -g 1001 -S oxicloud && \
|
||||
adduser -u 1001 -S oxicloud -G oxicloud
|
||||
|
||||
# Copy only the compiled binary
|
||||
COPY --from=builder /app/target/release/oxicloud /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/oxicloud
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
# Copy the compiled binary and entrypoint (--chmod avoids extra RUN chmod layers)
|
||||
COPY --from=builder --chmod=755 /app/target/release/oxicloud /usr/local/bin/
|
||||
COPY --chmod=755 entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
|
||||
# Copy processed static files (bundled/minified by build.rs in release)
|
||||
COPY --from=builder --chown=oxicloud:oxicloud /app/static-dist /app/static
|
||||
@@ -69,6 +65,11 @@ WORKDIR /app
|
||||
# Expose application port
|
||||
EXPOSE 8086
|
||||
|
||||
# Basic health check — verifies the HTTP server responds on the main port.
|
||||
# Docker / Compose / Swarm will mark the container unhealthy after 3 failures.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -qO- http://localhost:8086/api/version || exit 1
|
||||
|
||||
# Entrypoint fixes volume permissions then drops to oxicloud user.
|
||||
# The container starts as root so it can chown mounted volumes,
|
||||
# then su-exec drops privileges before running the application.
|
||||
|
||||
@@ -15,9 +15,11 @@
|
||||
[](https://github.com/diocrafts/OxiCloud/issues)
|
||||
[](https://github.com/diocrafts/OxiCloud/commits/main)
|
||||
|
||||
</div>
|
||||
[**Documentation**](https://diocrafts.github.io/OxiCloud/) | [**Quick Start**](#-quick-start)
|
||||
<br>
|
||||
**English** | [Español](docs/es/)
|
||||
|
||||
<br/>
|
||||
</div>
|
||||
|
||||
NextCloud was too slow on my home server. So I built OxiCloud — a complete cloud platform written in Rust that runs on minimal hardware and stays out of your way.
|
||||
|
||||
@@ -86,8 +88,8 @@ NextCloud was too slow on my home server. So I built OxiCloud — a complete clo
|
||||
- **Dual DB pool** — dedicated maintenance pool so background tasks never starve user queries
|
||||
- **LTO-optimised release** — fat LTO, 1 codegen-unit, `opt-level = 3`, stripped
|
||||
- **Write-behind caching** (moka) — sub-millisecond hot reads
|
||||
- **112 automated tests** — `cargo test` on every push (CI)
|
||||
- **9 languages** — EN, ES, DE, FR, IT, PT, NL, ZH, FA
|
||||
- **222+ automated tests** — `cargo test` on every push (CI)
|
||||
- **14 languages** — EN, ES, DE, FR, IT, PT, NL, ZH, JA, KO, AR, HI, FA, RU
|
||||
|
||||
---
|
||||
|
||||
@@ -105,6 +107,8 @@ NextCloud was too slow on my home server. So I built OxiCloud — a complete clo
|
||||
| **Trash / recycle bin** | ✅ Working | Soft-delete with restore |
|
||||
| **Full-text search** | ✅ Working | Recursive subtree search |
|
||||
| **Shared links** | ✅ Working | Optional password protection |
|
||||
| **Music library & playlists** | ✅ Working | Streaming player, drag-and-drop reorder |
|
||||
| **Photo gallery** | ✅ Working | Day/month/year views, multi-select |
|
||||
| **Desktop sync client** | ❌ Planned | Not yet available |
|
||||
| **Android / iOS app** | ❌ Planned | Not yet available |
|
||||
| **End-to-end encryption** | ❌ Planned | Roadmap item |
|
||||
@@ -208,7 +212,7 @@ All config via environment variables (see [`example.env`](example.env)):
|
||||
| `OXICLOUD_ENABLE_AUTH` | `true` | Toggle authentication |
|
||||
| `OXICLOUD_ENABLE_TRASH` | `true` | Toggle trash / recycle bin |
|
||||
|
||||
Full reference: [`example.env`](example.env) · [Deployment guide](doc/deployment.md) · [OIDC examples](doc/oidc-config-examples.md)
|
||||
Full reference: [`example.env`](example.env) · [Deployment guide](https://diocrafts.github.io/OxiCloud/config/deployment) · [OIDC setup](https://diocrafts.github.io/OxiCloud/config/oidc)
|
||||
|
||||
---
|
||||
|
||||
@@ -241,30 +245,24 @@ CSS & JS linter is `biome` (can be installed via `cargo install biome-cli` or on
|
||||
|--------|-------|
|
||||
| Rust source files | 170 |
|
||||
| Lines of code | ~50 000 |
|
||||
| Automated tests | 112 |
|
||||
| Documentation pages | 35 |
|
||||
| Automated tests | 222+ |
|
||||
| Documentation pages | 35+ |
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
Extensive docs live in [`doc/`](doc/):
|
||||
📖 **Full documentation:** [**diocrafts.github.io/OxiCloud**](https://diocrafts.github.io/OxiCloud/)
|
||||
|
||||
| Topic | Link |
|
||||
|-------|------|
|
||||
| Deployment & Docker | [deployment.md](doc/deployment.md) |
|
||||
| WebDAV integration | [webdav-integration-guide.md](doc/webdav-integration-guide.md) |
|
||||
| CalDAV / CardDAV | [caldav-technical-spec.md](doc/caldav-technical-spec.md) · [carddav-technical-spec.md](doc/carddav-technical-spec.md) |
|
||||
| OIDC / SSO setup | [oidc-integration.md](doc/oidc-integration.md) · [oidc-config-examples.md](doc/oidc-config-examples.md) |
|
||||
| WOPI (Office editing) | [wopi-integration.md](doc/wopi-integration.md) |
|
||||
| Chunked uploads | [chunked-uploads.md](doc/chunked-uploads.md) |
|
||||
| Deduplication | [deduplication.md](doc/deduplication.md) |
|
||||
| Search | [search.md](doc/search.md) |
|
||||
| Caching architecture | [caching-architecture.md](doc/caching-architecture.md) |
|
||||
| Storage quotas | [storage-quotas.md](doc/storage-quotas.md) |
|
||||
| Trash / recycle bin | [trash-feature-summary.md](doc/trash-feature-summary.md) |
|
||||
| Internationalisation | [i18n.md](doc/i18n.md) |
|
||||
| Internal architecture | [internal-architecture.md](doc/internal-architecture.md) |
|
||||
| Topic | Online | Source |
|
||||
|-------|--------|--------|
|
||||
| Quick Start | [Guide](https://diocrafts.github.io/OxiCloud/guide/installation) | [doc/deployment.md](doc/deployment.md) |
|
||||
| WebDAV | [Guide](https://diocrafts.github.io/OxiCloud/guide/webdav) | [doc/webdav-integration-guide.md](doc/webdav-integration-guide.md) |
|
||||
| CalDAV / CardDAV | [Guide](https://diocrafts.github.io/OxiCloud/guide/caldav-carddav) | [doc/caldav-technical-spec.md](doc/caldav-technical-spec.md) |
|
||||
| OIDC / SSO | [Guide](https://diocrafts.github.io/OxiCloud/config/oidc) | [doc/oidc-integration.md](doc/oidc-integration.md) |
|
||||
| WOPI (Office) | [Guide](https://diocrafts.github.io/OxiCloud/config/wopi) | [doc/wopi-integration.md](doc/wopi-integration.md) |
|
||||
| Architecture | [Guide](https://diocrafts.github.io/OxiCloud/architecture/) | [doc/internal-architecture.md](doc/internal-architecture.md) |
|
||||
| All env variables | [Reference](https://diocrafts.github.io/OxiCloud/config/env) | [`example.env`](example.env) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.vitepress/dist/
|
||||
.vitepress/cache/
|
||||
@@ -0,0 +1,165 @@
|
||||
import { defineConfig } from "vitepress";
|
||||
|
||||
export default defineConfig({
|
||||
title: "OxiCloud",
|
||||
description: "Self-hosted cloud storage, calendar & contacts — blazingly fast",
|
||||
|
||||
base: "/OxiCloud/",
|
||||
|
||||
sitemap: {
|
||||
hostname: "https://diocrafts.github.io/OxiCloud",
|
||||
lastmodDateOnly: false,
|
||||
},
|
||||
|
||||
markdown: {
|
||||
image: {
|
||||
lazyLoading: true,
|
||||
},
|
||||
},
|
||||
|
||||
lastUpdated: true,
|
||||
|
||||
ignoreDeadLinks: [
|
||||
/^https?:\/\/localhost/,
|
||||
],
|
||||
|
||||
locales: {
|
||||
root: {
|
||||
label: "English",
|
||||
lang: "en",
|
||||
},
|
||||
es: {
|
||||
label: "Español",
|
||||
lang: "es",
|
||||
link: "/es/",
|
||||
title: "OxiCloud",
|
||||
description: "Almacenamiento en la nube autoalojado, calendario y contactos — increíblemente rápido",
|
||||
themeConfig: {
|
||||
nav: [
|
||||
{ text: "Inicio", link: "/es/" },
|
||||
{ text: "Guía", link: "/es/guide/" },
|
||||
{ text: "Configuración", link: "/es/config/" },
|
||||
{ text: "FAQ", link: "/es/faq" },
|
||||
],
|
||||
editLink: {
|
||||
pattern: "https://github.com/DioCrafts/OxiCloud/tree/main/docs/:path",
|
||||
text: "Editar esta página en GitHub",
|
||||
},
|
||||
sidebar: {
|
||||
"/es/": [
|
||||
{
|
||||
text: "Introducción",
|
||||
items: [
|
||||
{ text: "¿Qué es OxiCloud?", link: "/es/guide/" },
|
||||
{ text: "Inicio Rápido", link: "/es/guide/installation" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Configuración",
|
||||
items: [
|
||||
{ text: "Despliegue & Docker", link: "/es/config/deployment" },
|
||||
{ text: "Variables de Entorno", link: "/es/config/env" },
|
||||
{ text: "OIDC / SSO", link: "/es/config/oidc" },
|
||||
{ text: "WOPI (Office)", link: "/es/config/wopi" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Características",
|
||||
items: [
|
||||
{ text: "WebDAV", link: "/es/guide/webdav" },
|
||||
{ text: "CalDAV & CardDAV", link: "/es/guide/caldav-carddav" },
|
||||
{ text: "Subida Chunked", link: "/es/guide/chunked-uploads" },
|
||||
{ text: "Deduplicación", link: "/es/guide/deduplication" },
|
||||
{ text: "Búsqueda", link: "/es/guide/search" },
|
||||
{ text: "Papelera", link: "/es/guide/trash" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Arquitectura",
|
||||
items: [
|
||||
{ text: "Arquitectura Interna", link: "/es/architecture/" },
|
||||
{ text: "Caché", link: "/es/architecture/caching" },
|
||||
{ text: "Cuotas de Almacenamiento", link: "/es/architecture/storage-quotas" },
|
||||
],
|
||||
},
|
||||
{ text: "FAQ", link: "/es/faq" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
head: [
|
||||
["link", { rel: "icon", href: "/OxiCloud/logo.svg" }],
|
||||
],
|
||||
|
||||
themeConfig: {
|
||||
logo: "/logo.svg",
|
||||
|
||||
search: {
|
||||
provider: "local",
|
||||
},
|
||||
|
||||
editLink: {
|
||||
pattern: "https://github.com/DioCrafts/OxiCloud/tree/main/docs/:path",
|
||||
text: "Edit this page on GitHub",
|
||||
},
|
||||
|
||||
nav: [
|
||||
{ text: "Home", link: "/" },
|
||||
{ text: "Guide", link: "/guide/" },
|
||||
{ text: "Configuration", link: "/config/" },
|
||||
{ text: "FAQ", link: "/faq" },
|
||||
],
|
||||
|
||||
sidebar: {
|
||||
"/": [
|
||||
{
|
||||
text: "Introduction",
|
||||
items: [
|
||||
{ text: "What is OxiCloud?", link: "/guide/" },
|
||||
{ text: "Quick Start", link: "/guide/installation" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Configuration",
|
||||
items: [
|
||||
{ text: "Deployment & Docker", link: "/config/deployment" },
|
||||
{ text: "Environment Variables", link: "/config/env" },
|
||||
{ text: "OIDC / SSO", link: "/config/oidc" },
|
||||
{ text: "WOPI (Office Editing)", link: "/config/wopi" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Features",
|
||||
items: [
|
||||
{ text: "WebDAV", link: "/guide/webdav" },
|
||||
{ text: "CalDAV & CardDAV", link: "/guide/caldav-carddav" },
|
||||
{ text: "Chunked Uploads", link: "/guide/chunked-uploads" },
|
||||
{ text: "Deduplication", link: "/guide/deduplication" },
|
||||
{ text: "Search", link: "/guide/search" },
|
||||
{ text: "Trash & Recycle Bin", link: "/guide/trash" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Architecture",
|
||||
items: [
|
||||
{ text: "Internal Architecture", link: "/architecture/" },
|
||||
{ text: "Caching", link: "/architecture/caching" },
|
||||
{ text: "Storage Quotas", link: "/architecture/storage-quotas" },
|
||||
],
|
||||
},
|
||||
{ text: "FAQ", link: "/faq" },
|
||||
],
|
||||
},
|
||||
|
||||
socialLinks: [
|
||||
{ icon: "github", link: "https://github.com/DioCrafts/OxiCloud" },
|
||||
],
|
||||
|
||||
footer: {
|
||||
message: "Released under the MIT License.",
|
||||
copyright: "Copyright © 2025-present DioCrafts",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
# Caching Architecture
|
||||
|
||||
OxiCloud uses **moka** (a lock-free, concurrent cache) for write-behind caching that delivers sub-millisecond hot reads.
|
||||
|
||||
## Cache Layers
|
||||
|
||||
| Cache | TTL | Max Entries | Purpose |
|
||||
|---|---|---|---|
|
||||
| File metadata | 60 s | 10 000 | Avoid re-querying PostgreSQL for file info |
|
||||
| Directory listings | 120 s | 10 000 | Frequently accessed folder contents |
|
||||
| Thumbnail cache | configurable | 1 000 | Generated WebP/AVIF thumbnails |
|
||||
| Image transcode | configurable | 500 | On-the-fly image transcoding results |
|
||||
| Blob hash | 30 s TTI | 5 000 | SHA-256 hashes for dedup lookups |
|
||||
| Audio metadata | — | 2 000 | ID3 tags and duration |
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Read path:** check cache → if hit, return immediately (sub-ms); if miss, query PostgreSQL, populate cache, return
|
||||
2. **Write path:** update PostgreSQL → invalidate relevant cache entries
|
||||
3. **TTL expiry:** entries are evicted after their time-to-live, ensuring eventual consistency
|
||||
|
||||
## Why moka?
|
||||
|
||||
- **Lock-free** — no mutex contention under concurrent access
|
||||
- **Bounded memory** — max entries prevent unbounded growth
|
||||
- **TTL + TTI** — supports both time-to-live and time-to-idle eviction
|
||||
- **Async-ready** — works natively with Tokio
|
||||
|
||||
## Configuration
|
||||
|
||||
Cache parameters are currently hardcoded in `src/common/config.rs`. Key defaults:
|
||||
|
||||
```rust
|
||||
file_cache_ttl_ms: 60_000, // 1 minute
|
||||
directory_cache_ttl_ms: 120_000, // 2 minutes
|
||||
max_cache_entries: 10_000,
|
||||
```
|
||||
@@ -0,0 +1,63 @@
|
||||
# Internal Architecture
|
||||
|
||||
OxiCloud follows a **hexagonal (ports & adapters) architecture** with four layers:
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ Interfaces │ REST API, WebDAV, CalDAV, CardDAV, WOPI │
|
||||
├───────────────────────────────────────────────────────────────┤
|
||||
│ Application │ Use cases, DTOs, port definitions │
|
||||
├───────────────────────────────────────────────────────────────┤
|
||||
│ Domain │ Entities, business rules, repository traits │
|
||||
├───────────────────────────────────────────────────────────────┤
|
||||
│ Infrastructure│ PostgreSQL, filesystem, caching, auth │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
All cross-layer dependencies point **inward** via trait-based ports. The DI container (`AppServiceFactory`) wires concrete implementations at startup.
|
||||
|
||||
## Storage Model: 100% Blob Storage
|
||||
|
||||
- **File metadata** (name, folder, size, user, timestamps, trash status) → PostgreSQL (`storage.files`)
|
||||
- **File content** → content-addressed blobs via DedupService at `.blobs/{prefix}/{hash}.blob`
|
||||
- **Folder structure** → purely virtual, rows in `storage.folders` (no filesystem directories per user)
|
||||
- **Trash** → soft-delete flags on files/folders, exposed via `storage.trash_items` VIEW
|
||||
|
||||
## Dependency Injection
|
||||
|
||||
`AppServiceFactory` in `src/common/di.rs` builds all services in a defined order:
|
||||
|
||||
1. **Core services** — paths, content cache, thumbnails, chunked upload, transcode, dedup, compression
|
||||
2. **Repositories** — `FolderDbRepository`, `FileBlobReadRepository`, `FileBlobWriteRepository`, `TrashDbRepository`
|
||||
3. **Trash service** (if enabled)
|
||||
4. **Application services** — folder, file upload/retrieval/management, search, i18n
|
||||
5. **Share service** (if enabled)
|
||||
6. **DB services** — favorites, recent, storage usage, auth
|
||||
7. **CalDAV/CardDAV services**
|
||||
8. **ZIP service** (last, depends on file & folder services)
|
||||
9. **Assemble `AppState`**
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── common/ # Config, DI container, errors
|
||||
├── domain/ # Entities, repository traits
|
||||
├── application/ # Use cases, DTOs, port traits
|
||||
├── infrastructure/ # PostgreSQL repos, filesystem, caching
|
||||
└── interfaces/ # HTTP handlers, WebDAV, CalDAV, CardDAV
|
||||
```
|
||||
|
||||
## Key Metrics
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Rust source files | ~170 |
|
||||
| Lines of code | ~50 000 |
|
||||
| Automated tests | 222+ |
|
||||
| Docker image | ~40 MB |
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Caching Architecture →](/architecture/caching)
|
||||
- [Storage Quotas →](/architecture/storage-quotas)
|
||||
@@ -0,0 +1,36 @@
|
||||
# Storage Quotas
|
||||
|
||||
OxiCloud supports per-user storage quotas to limit disk usage.
|
||||
|
||||
## Enabling Quotas
|
||||
|
||||
```bash
|
||||
OXICLOUD_ENABLE_USER_STORAGE_QUOTAS=true
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Each user has a `storage_quota` field (in bytes, `0` = unlimited)
|
||||
2. On every file upload, the current usage is checked against the quota
|
||||
3. If the upload would exceed the quota, it's rejected with a `413 Payload Too Large` error
|
||||
4. Admins can view and set quotas via the admin panel or API
|
||||
|
||||
## API
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/admin/users/{id}/quota` | Get user's quota and current usage |
|
||||
| PUT | `/api/admin/users/{id}/quota` | Set user's quota |
|
||||
|
||||
## Admin Panel
|
||||
|
||||
The admin panel (`/admin.html`) shows each user's current usage vs. quota with a visual progress bar.
|
||||
|
||||
## Deduplication Interaction
|
||||
|
||||
Storage usage is calculated based on **logical file size** (what the user uploaded), not physical blob size. This means:
|
||||
|
||||
- If two users upload the same 100 MB file, each user's quota is charged 100 MB
|
||||
- But on disk, only one 100 MB blob exists
|
||||
|
||||
This ensures fair quota accounting while maintaining dedup benefits.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Deployment & Docker
|
||||
|
||||
## Docker Image
|
||||
|
||||
OxiCloud uses a multi-stage Alpine build producing a **~40 MB** image:
|
||||
|
||||
1. **Base** — shared build dependencies (`musl-dev`, `pkgconfig`, `openssl-dev`, `libpq-dev`)
|
||||
2. **Cacher** — pre-builds the dependency layer for fast rebuilds
|
||||
3. **Builder** — compiles OxiCloud (`rust:1.94.0-alpine3.23`)
|
||||
4. **Runtime** — minimal Alpine (`alpine:3.23.3`) with `libgcc`, `ca-certificates`, `libpq`, `tzdata`, `su-exec`
|
||||
|
||||
The final image runs as non-root user `oxicloud` (UID/GID 1001). Exposed port: **8086**.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17.4-alpine
|
||||
environment:
|
||||
POSTGRES_DB: oxicloud
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres # change in production!
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
- ./db/schema.sql:/docker-entrypoint-initdb.d/schema.sql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
oxicloud:
|
||||
image: ghcr.io/diocrafts/oxicloud:latest
|
||||
ports:
|
||||
- "8086:8086"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- storage_data:/app/storage
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
storage_data:
|
||||
```
|
||||
|
||||
## Kubernetes (Helm)
|
||||
|
||||
### Prerequisites
|
||||
- Kubernetes cluster
|
||||
- Default StorageClass
|
||||
- Ingress Controller
|
||||
- Helm 3+
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
helm upgrade --install oxicloud charts/oxicloud \
|
||||
-f charts/oxicloud/values.yaml
|
||||
```
|
||||
|
||||
### Verify
|
||||
|
||||
```bash
|
||||
kubectl get pods -n oxicloud
|
||||
kubectl logs statefulset/oxicloud -n oxicloud
|
||||
```
|
||||
|
||||
### WOPI Verification
|
||||
|
||||
If Collabora/OnlyOffice is enabled:
|
||||
|
||||
```bash
|
||||
kubectl logs statefulset/oxicloud -n oxicloud | grep "WOPI discovery loaded"
|
||||
```
|
||||
|
||||
## Feature Dependency Matrix
|
||||
|
||||
| Feature | Requires DB | Requires Auth | Feature Flag |
|
||||
|---|---|---|---|
|
||||
| File storage | Yes | No | Always on |
|
||||
| Authentication | Yes | — | `OXICLOUD_ENABLE_AUTH` |
|
||||
| OIDC / SSO | Yes | Yes | `OXICLOUD_OIDC_ENABLED` |
|
||||
| File sharing | Yes | Yes | `OXICLOUD_ENABLE_FILE_SHARING` |
|
||||
| Trash | Yes | No | `OXICLOUD_ENABLE_TRASH` |
|
||||
| Search | Yes | No | `OXICLOUD_ENABLE_SEARCH` |
|
||||
| Favorites | Yes | Yes | Always on |
|
||||
| Storage quotas | Yes | Yes | `OXICLOUD_ENABLE_USER_STORAGE_QUOTAS` |
|
||||
| WebDAV | Yes | Optional | Always on |
|
||||
| CalDAV / CardDAV | Yes | Yes | Always on |
|
||||
| Deduplication | No | No | Always on |
|
||||
| Thumbnails | No | No | Always on |
|
||||
| Chunked uploads | No | No | Always on |
|
||||
@@ -0,0 +1,83 @@
|
||||
# Environment Variables
|
||||
|
||||
All variables use the `OXICLOUD_` prefix.
|
||||
|
||||
## Server
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `OXICLOUD_STORAGE_PATH` | `./storage` | Root storage directory |
|
||||
| `OXICLOUD_STATIC_PATH` | `./static` | Static files directory |
|
||||
| `OXICLOUD_SERVER_PORT` | `8086` | Server port |
|
||||
| `OXICLOUD_SERVER_HOST` | `127.0.0.1` | Server bind address |
|
||||
| `OXICLOUD_BASE_URL` | (auto) | Public base URL for share links |
|
||||
|
||||
## Database
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `OXICLOUD_DB_CONNECTION_STRING` | `postgres://postgres:postgres@localhost:5432/oxicloud` | PostgreSQL connection string |
|
||||
| `OXICLOUD_DB_MAX_CONNECTIONS` | `20` | Max pool connections |
|
||||
| `OXICLOUD_DB_MIN_CONNECTIONS` | `5` | Min pool connections |
|
||||
|
||||
## Authentication
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `OXICLOUD_JWT_SECRET` | (random) | JWT signing secret |
|
||||
| `OXICLOUD_ACCESS_TOKEN_EXPIRY_SECS` | `3600` | Access token lifetime (seconds) |
|
||||
| `OXICLOUD_REFRESH_TOKEN_EXPIRY_SECS` | `2592000` | Refresh token lifetime (seconds) |
|
||||
|
||||
## Feature Flags
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `OXICLOUD_ENABLE_AUTH` | `true` | Enable authentication |
|
||||
| `OXICLOUD_ENABLE_USER_STORAGE_QUOTAS` | `false` | Per-user storage quotas |
|
||||
| `OXICLOUD_ENABLE_FILE_SHARING` | `true` | File/folder sharing |
|
||||
| `OXICLOUD_ENABLE_TRASH` | `true` | Trash / recycle bin |
|
||||
| `OXICLOUD_ENABLE_SEARCH` | `true` | Search |
|
||||
|
||||
## OIDC / SSO
|
||||
|
||||
See the [OIDC configuration guide](/config/oidc) for details.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `OXICLOUD_OIDC_ENABLED` | `false` | Enable OIDC |
|
||||
| `OXICLOUD_OIDC_ISSUER_URL` | — | OIDC issuer URL |
|
||||
| `OXICLOUD_OIDC_CLIENT_ID` | — | Client ID |
|
||||
| `OXICLOUD_OIDC_CLIENT_SECRET` | — | Client secret |
|
||||
| `OXICLOUD_OIDC_REDIRECT_URI` | `http://localhost:8086/api/auth/oidc/callback` | Callback URL |
|
||||
| `OXICLOUD_OIDC_SCOPES` | `openid profile email` | Requested scopes |
|
||||
| `OXICLOUD_OIDC_FRONTEND_URL` | `http://localhost:8086` | Frontend URL |
|
||||
| `OXICLOUD_OIDC_AUTO_PROVISION` | `true` | Auto-create users on first SSO login |
|
||||
| `OXICLOUD_OIDC_ADMIN_GROUPS` | — | Groups that grant admin role |
|
||||
| `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN` | `false` | Hide password form when OIDC enabled |
|
||||
| `OXICLOUD_OIDC_PROVIDER_NAME` | `SSO` | Display name for the provider |
|
||||
|
||||
## WOPI (Office Editing)
|
||||
|
||||
See the [WOPI configuration guide](/config/wopi) for details.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `OXICLOUD_WOPI_ENABLED` | `false` | Enable WOPI |
|
||||
| `OXICLOUD_WOPI_DISCOVERY_URL` | — | Collabora/OnlyOffice discovery URL |
|
||||
| `OXICLOUD_WOPI_SECRET` | (JWT secret) | WOPI token signing key |
|
||||
| `OXICLOUD_WOPI_TOKEN_TTL_SECS` | `86400` | Token lifetime |
|
||||
| `OXICLOUD_WOPI_LOCK_TTL_SECS` | `1800` | Lock expiration |
|
||||
|
||||
## Internal Defaults (not configurable via env)
|
||||
|
||||
| Parameter | Default |
|
||||
|---|---|
|
||||
| File cache TTL | 60 s |
|
||||
| Directory cache TTL | 120 s |
|
||||
| Max cache entries | 10 000 |
|
||||
| Large file threshold | 100 MB |
|
||||
| Streaming chunk size | 1 MB |
|
||||
| Max parallel chunks | 8 |
|
||||
| Trash retention | 30 days |
|
||||
| Argon2id memory cost | 64 MB |
|
||||
| Argon2id time cost | 3 iterations |
|
||||
@@ -0,0 +1,21 @@
|
||||
# Configuration
|
||||
|
||||
OxiCloud is configured entirely via **environment variables** (no config files needed).
|
||||
|
||||
## Sections
|
||||
|
||||
- [Deployment & Docker](/config/deployment) — Docker Compose, Kubernetes Helm chart, image details
|
||||
- [Environment Variables](/config/env) — complete reference of all `OXICLOUD_*` variables
|
||||
- [OIDC / SSO](/config/oidc) — single sign-on with Keycloak, Authentik, Authelia, Google, Azure AD
|
||||
- [WOPI (Office Editing)](/config/wopi) — Collabora Online / OnlyOffice integration
|
||||
|
||||
## Minimal `.env`
|
||||
|
||||
```bash
|
||||
OXICLOUD_DB_CONNECTION_STRING=postgres://postgres:postgres@postgres:5432/oxicloud
|
||||
OXICLOUD_STORAGE_PATH=/app/storage
|
||||
OXICLOUD_SERVER_HOST=0.0.0.0
|
||||
OXICLOUD_SERVER_PORT=8086
|
||||
```
|
||||
|
||||
That's enough to get started. All other settings have sensible defaults.
|
||||
@@ -0,0 +1,95 @@
|
||||
# OIDC / SSO
|
||||
|
||||
OxiCloud supports OpenID Connect for single sign-on with providers like **Keycloak**, **Authentik**, **Authelia**, **Google**, and **Azure AD**.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. User clicks "Sign in with SSO" on the login page
|
||||
2. Browser redirects to the identity provider (IdP)
|
||||
3. User authenticates with their existing credentials
|
||||
4. IdP redirects back to OxiCloud with an auth code
|
||||
5. OxiCloud exchanges the code for user info and issues its own JWT tokens
|
||||
|
||||
## Configuration
|
||||
|
||||
```bash
|
||||
OXICLOUD_OIDC_ENABLED=true
|
||||
OXICLOUD_OIDC_ISSUER_URL="https://authentik.example.com/application/o/oxicloud/"
|
||||
OXICLOUD_OIDC_CLIENT_ID="your-client-id"
|
||||
OXICLOUD_OIDC_CLIENT_SECRET="your-client-secret"
|
||||
OXICLOUD_OIDC_REDIRECT_URI="https://oxicloud.example.com/api/auth/oidc/callback"
|
||||
OXICLOUD_OIDC_SCOPES="openid profile email"
|
||||
OXICLOUD_OIDC_FRONTEND_URL="https://oxicloud.example.com"
|
||||
OXICLOUD_OIDC_AUTO_PROVISION=true
|
||||
OXICLOUD_OIDC_ADMIN_GROUPS="oxicloud-admins"
|
||||
OXICLOUD_OIDC_PROVIDER_NAME="Authentik"
|
||||
```
|
||||
|
||||
### Variable Reference
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `OXICLOUD_OIDC_ENABLED` | `false` | Master switch |
|
||||
| `OXICLOUD_OIDC_ISSUER_URL` | — | Provider's OIDC issuer URL |
|
||||
| `OXICLOUD_OIDC_CLIENT_ID` | — | OAuth client ID |
|
||||
| `OXICLOUD_OIDC_CLIENT_SECRET` | — | OAuth client secret |
|
||||
| `OXICLOUD_OIDC_REDIRECT_URI` | `http://localhost:8086/api/auth/oidc/callback` | Callback URL registered with the IdP |
|
||||
| `OXICLOUD_OIDC_SCOPES` | `openid profile email` | Requested scopes |
|
||||
| `OXICLOUD_OIDC_FRONTEND_URL` | `http://localhost:8086` | Where to redirect the browser after auth |
|
||||
| `OXICLOUD_OIDC_AUTO_PROVISION` | `true` | Auto-create users on first login |
|
||||
| `OXICLOUD_OIDC_ADMIN_GROUPS` | — | OIDC groups that grant admin role |
|
||||
| `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN` | `false` | Hide password login when OIDC is active |
|
||||
| `OXICLOUD_OIDC_PROVIDER_NAME` | `SSO` | Label shown on the login button |
|
||||
|
||||
::: warning
|
||||
If `OXICLOUD_OIDC_ENABLED=true` but `issuer_url`, `client_id`, or `client_secret` are empty, OIDC is automatically disabled with an error log.
|
||||
:::
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/auth/oidc/providers` | Returns OIDC provider info |
|
||||
| GET | `/api/auth/oidc/authorize` | Authorization URL for redirect to IdP |
|
||||
| GET | `/api/auth/oidc/callback` | Callback from IdP with auth code |
|
||||
| POST | `/api/auth/oidc/exchange` | Exchange auth code for JWT tokens |
|
||||
|
||||
## Provider Examples
|
||||
|
||||
### Keycloak
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
oxicloud:
|
||||
environment:
|
||||
OXICLOUD_OIDC_ENABLED: "true"
|
||||
OXICLOUD_OIDC_ISSUER_URL: "https://keycloak.example.com/realms/your-realm"
|
||||
OXICLOUD_OIDC_CLIENT_ID: "oxicloud"
|
||||
OXICLOUD_OIDC_CLIENT_SECRET: "your-client-secret"
|
||||
OXICLOUD_OIDC_REDIRECT_URI: "https://oxicloud.example.com/api/auth/oidc/callback"
|
||||
OXICLOUD_OIDC_FRONTEND_URL: "https://oxicloud.example.com"
|
||||
OXICLOUD_OIDC_PROVIDER_NAME: "Keycloak"
|
||||
```
|
||||
|
||||
### Authentik
|
||||
|
||||
```bash
|
||||
OXICLOUD_OIDC_ISSUER_URL="https://authentik.example.com/application/o/oxicloud/"
|
||||
OXICLOUD_OIDC_PROVIDER_NAME="Authentik"
|
||||
```
|
||||
|
||||
### Google
|
||||
|
||||
```bash
|
||||
OXICLOUD_OIDC_ISSUER_URL="https://accounts.google.com"
|
||||
OXICLOUD_OIDC_PROVIDER_NAME="Google"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Always use **HTTPS** for OIDC connections
|
||||
- One OIDC provider per instance (single-provider model)
|
||||
- OIDC users share the same permissions model as local users
|
||||
- After OIDC auth, the backend issues its own JWT tokens (no IdP token dependency)
|
||||
- Use the admin settings UI (`/admin.html`) to configure and test OIDC at runtime
|
||||
@@ -0,0 +1,78 @@
|
||||
# WOPI (Office Document Editing)
|
||||
|
||||
OxiCloud integrates with **Collabora Online** and **OnlyOffice** via the WOPI protocol, letting users edit documents, spreadsheets, and presentations directly in the browser.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. User opens a document (`.docx`, `.xlsx`, `.pptx`, `.odt`, etc.)
|
||||
2. OxiCloud generates a WOPI access token and redirects to the editor
|
||||
3. The editor fetches the file from OxiCloud via WOPI endpoints
|
||||
4. Edits are saved back via `PutFile`
|
||||
|
||||
## Configuration
|
||||
|
||||
```bash
|
||||
OXICLOUD_WOPI_ENABLED=true
|
||||
OXICLOUD_WOPI_DISCOVERY_URL="http://collabora:9980/hosting/discovery"
|
||||
```
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `OXICLOUD_WOPI_ENABLED` | `false` | Enable WOPI integration |
|
||||
| `OXICLOUD_WOPI_DISCOVERY_URL` | — | Editor's WOPI discovery URL |
|
||||
| `OXICLOUD_WOPI_SECRET` | (JWT secret) | Token signing key |
|
||||
| `OXICLOUD_WOPI_TOKEN_TTL_SECS` | `86400` | Access token lifetime |
|
||||
| `OXICLOUD_WOPI_LOCK_TTL_SECS` | `1800` | Lock expiration |
|
||||
|
||||
## Docker Compose with Collabora
|
||||
|
||||
```yaml
|
||||
services:
|
||||
collabora:
|
||||
image: collabora/code:latest
|
||||
environment:
|
||||
- domain=oxicloud\\.example\\.com
|
||||
- extra_params=--o:ssl.enable=false
|
||||
ports:
|
||||
- "9980:9980"
|
||||
cap_add:
|
||||
- MKNOD
|
||||
|
||||
oxicloud:
|
||||
environment:
|
||||
OXICLOUD_WOPI_ENABLED: "true"
|
||||
OXICLOUD_WOPI_DISCOVERY_URL: "http://collabora:9980/hosting/discovery"
|
||||
```
|
||||
|
||||
## Docker Compose with OnlyOffice
|
||||
|
||||
```yaml
|
||||
services:
|
||||
onlyoffice:
|
||||
image: onlyoffice/documentserver:latest
|
||||
environment:
|
||||
- JWT_ENABLED=false
|
||||
ports:
|
||||
- "8443:443"
|
||||
|
||||
oxicloud:
|
||||
environment:
|
||||
OXICLOUD_WOPI_ENABLED: "true"
|
||||
OXICLOUD_WOPI_DISCOVERY_URL: "http://onlyoffice/hosting/discovery"
|
||||
```
|
||||
|
||||
## WOPI Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/wopi/files/{id}` | CheckFileInfo — file metadata |
|
||||
| GET | `/wopi/files/{id}/contents` | GetFile — download file content |
|
||||
| POST | `/wopi/files/{id}/contents` | PutFile — save edited content |
|
||||
| POST | `/wopi/files/{id}` | Lock / Unlock / RefreshLock |
|
||||
|
||||
## Supported Formats
|
||||
|
||||
Any format supported by your Collabora or OnlyOffice installation, typically:
|
||||
- Documents: `.docx`, `.odt`, `.doc`, `.rtf`
|
||||
- Spreadsheets: `.xlsx`, `.ods`, `.xls`, `.csv`
|
||||
- Presentations: `.pptx`, `.odp`, `.ppt`
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
# FAQ
|
||||
|
||||
## General
|
||||
|
||||
### What is OxiCloud?
|
||||
|
||||
OxiCloud is a self-hosted cloud platform written in Rust. It provides file storage, calendar sync, contacts sync, and office editing from a single binary.
|
||||
|
||||
### How does it compare to NextCloud?
|
||||
|
||||
OxiCloud uses ~20× less RAM, produces a ~25× smaller Docker image, and starts in under 1 second. The trade-off is fewer plugins — OxiCloud builds everything into the core. See the [comparison table](/guide/#oxicloud-vs-nextcloud).
|
||||
|
||||
### What hardware do I need?
|
||||
|
||||
Minimum: 1 vCPU, 512 MB RAM, and a few GB of disk for PostgreSQL + file storage. OxiCloud runs comfortably on a Raspberry Pi 4.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Which database is supported?
|
||||
|
||||
PostgreSQL 13 or later. SQLite is not supported — PostgreSQL's ltree, array operations, and concurrent access are essential.
|
||||
|
||||
### Can I use MySQL / MariaDB?
|
||||
|
||||
Not currently. PostgreSQL is the only supported database.
|
||||
|
||||
### How do I update?
|
||||
|
||||
Pull the latest image and restart:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Can I sync files from my desktop?
|
||||
|
||||
WebDAV is built-in. You can mount OxiCloud in Windows Explorer, macOS Finder, or Linux file managers. A dedicated desktop sync client is on the roadmap.
|
||||
|
||||
### Does CalDAV/CardDAV work with my phone?
|
||||
|
||||
On Android, use [DAVx⁵](https://www.davx5.com/). On iOS, CalDAV and CardDAV work via the built-in Accounts settings.
|
||||
|
||||
### Can I edit Office documents?
|
||||
|
||||
Yes, via WOPI integration with Collabora Online or OnlyOffice. See [WOPI configuration](/config/wopi).
|
||||
|
||||
### Is there end-to-end encryption?
|
||||
|
||||
Not yet — it's on the roadmap. Files are stored unencrypted on the server (standard for self-hosted solutions that support server-side processing like thumbnails and search).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### I can't connect via WebDAV
|
||||
|
||||
1. Check that the URL is `http(s)://host:8086/webdav/` (note the trailing slash)
|
||||
2. Ensure authentication is correct (HTTP Basic)
|
||||
3. Check server logs: `docker compose logs oxicloud`
|
||||
|
||||
### Uploads fail for large files
|
||||
|
||||
OxiCloud automatically switches to chunked upload for files over 200 MB. If you're behind a reverse proxy, ensure it allows large request bodies:
|
||||
|
||||
```nginx
|
||||
client_max_body_size 0; # unlimited
|
||||
proxy_read_timeout 600s;
|
||||
```
|
||||
|
||||
### OIDC login doesn't redirect back
|
||||
|
||||
Verify that `OXICLOUD_OIDC_REDIRECT_URI` matches exactly what's configured in your identity provider, and that `OXICLOUD_OIDC_FRONTEND_URL` points to your actual frontend URL.
|
||||
@@ -0,0 +1,70 @@
|
||||
# CalDAV & CardDAV
|
||||
|
||||
OxiCloud provides built-in CalDAV (calendar) and CardDAV (contacts) servers — no extra apps or plugins needed.
|
||||
|
||||
## CalDAV (Calendars)
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
https://your-server:8086/caldav/
|
||||
```
|
||||
|
||||
### Protocol Compliance
|
||||
|
||||
- RFC 4791 (Calendar Access)
|
||||
- RFC 5545 (iCalendar format)
|
||||
- DAV capabilities: `1, 2, calendar-access`
|
||||
|
||||
### Client Setup
|
||||
|
||||
| Client | URL |
|
||||
|--------|-----|
|
||||
| Thunderbird | `https://your-server:8086/caldav/` |
|
||||
| GNOME Calendar | `https://your-server:8086/caldav/` |
|
||||
| Apple Calendar (macOS/iOS) | `https://your-server:8086/caldav/` |
|
||||
| DAVx⁵ (Android) | `https://your-server:8086/` (auto-discovery) |
|
||||
|
||||
### Thunderbird Setup
|
||||
|
||||
1. Open Thunderbird → **Calendar** tab
|
||||
2. Right-click → **New Calendar** → **On the Network**
|
||||
3. Format: **CalDAV**
|
||||
4. URL: `https://your-server:8086/caldav/`
|
||||
5. Enter your OxiCloud credentials
|
||||
|
||||
---
|
||||
|
||||
## CardDAV (Contacts)
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
https://your-server:8086/carddav/
|
||||
```
|
||||
|
||||
### Protocol Compliance
|
||||
|
||||
- RFC 6352 (CardDAV)
|
||||
- RFC 6350 (vCard 4.0)
|
||||
|
||||
### Client Setup
|
||||
|
||||
| Client | URL |
|
||||
|--------|-----|
|
||||
| Thunderbird | `https://your-server:8086/carddav/` |
|
||||
| GNOME Contacts | `https://your-server:8086/carddav/` |
|
||||
| Apple Contacts (macOS/iOS) | `https://your-server:8086/carddav/` |
|
||||
| DAVx⁵ (Android) | `https://your-server:8086/` (auto-discovery) |
|
||||
|
||||
### DAVx⁵ (Android) Setup
|
||||
|
||||
1. Install [DAVx⁵](https://www.davx5.com/) from F-Droid or Play Store
|
||||
2. Add account → **Login with URL and user name**
|
||||
3. Base URL: `https://your-server:8086/`
|
||||
4. Enter your OxiCloud credentials
|
||||
5. DAVx⁵ auto-discovers both CalDAV and CardDAV endpoints
|
||||
|
||||
::: info
|
||||
DAVx⁵ file sync works. CalDAV/CardDAV support on DAVx⁵ is still being refined.
|
||||
:::
|
||||
@@ -0,0 +1,63 @@
|
||||
# Chunked Uploads
|
||||
|
||||
OxiCloud supports TUS-like chunked uploads for large files. Uploads are parallel, resumable, and have MD5 integrity checks.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Client sends `POST /api/files/upload/init` with file metadata → receives an `upload_id`
|
||||
2. Client splits the file into chunks and uploads them in parallel via `POST /api/files/upload/chunk`
|
||||
3. Each chunk includes its index, MD5 hash, and the `upload_id`
|
||||
4. When all chunks are uploaded, client calls `POST /api/files/upload/complete`
|
||||
5. Server reassembles the file, verifies integrity, and runs deduplication
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Initialize Upload
|
||||
|
||||
```http
|
||||
POST /api/files/upload/init
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"file_name": "large-video.mp4",
|
||||
"folder_id": "folder-uuid",
|
||||
"total_size": 524288000,
|
||||
"chunk_size": 8388608,
|
||||
"total_chunks": 63
|
||||
}
|
||||
```
|
||||
|
||||
### Upload Chunk
|
||||
|
||||
```http
|
||||
POST /api/files/upload/chunk
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
upload_id: "uuid"
|
||||
chunk_index: 0
|
||||
chunk_hash: "md5-hex"
|
||||
file: <binary>
|
||||
```
|
||||
|
||||
### Complete Upload
|
||||
|
||||
```http
|
||||
POST /api/files/upload/complete
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"upload_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|---|---|---|
|
||||
| Max parallel chunks | 8 | Concurrent chunk uploads |
|
||||
| Min size for chunking | 200 MB | Below this, single-shot upload is used |
|
||||
| Chunk size | 8 MB | Default chunk size |
|
||||
|
||||
## Frontend Behaviour
|
||||
|
||||
The OxiCloud web UI automatically selects chunked upload for large files. A progress bar shows overall completion and current chunk status.
|
||||
@@ -0,0 +1,46 @@
|
||||
# File Deduplication
|
||||
|
||||
OxiCloud uses **SHA-256 content-addressable storage** to avoid storing duplicate files. If two users upload the same file, only one copy is stored on disk.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. When a file is uploaded, its SHA-256 hash is computed
|
||||
2. The hash is checked against the blob store (`.blobs/{prefix}/{hash}.blob`)
|
||||
3. If a blob with that hash already exists, the file metadata points to the existing blob (no extra disk usage)
|
||||
4. If not, the content is saved as a new blob
|
||||
5. A reference counter tracks how many files point to each blob
|
||||
|
||||
## Automatic Cleanup
|
||||
|
||||
When a file is permanently deleted:
|
||||
|
||||
1. The blob's reference count is decremented
|
||||
2. If the reference count reaches zero, the blob is removed from disk
|
||||
|
||||
This means disk space is only freed when the **last** reference to a blob is removed.
|
||||
|
||||
## Storage Layout
|
||||
|
||||
```
|
||||
storage/
|
||||
├── .blobs/
|
||||
│ ├── a1/
|
||||
│ │ └── a1b2c3d4...sha256.blob
|
||||
│ ├── f8/
|
||||
│ │ └── f8e7d6c5...sha256.blob
|
||||
│ └── ...
|
||||
```
|
||||
|
||||
The first two hex characters of the hash are used as a directory prefix to avoid having millions of files in a single directory.
|
||||
|
||||
## Benefits
|
||||
|
||||
- **Disk savings** — identical files across users consume storage only once
|
||||
- **Instant uploads** — if the blob already exists, the upload completes immediately
|
||||
- **Integrity** — SHA-256 ensures bit-for-bit correctness
|
||||
|
||||
## Limitations
|
||||
|
||||
- Deduplication is based on exact content match (byte-identical files)
|
||||
- Near-duplicate files (e.g., a JPEG re-saved at slightly different quality) are stored separately
|
||||
- Encryption at rest would require per-user keys, which breaks deduplication (planned as opt-in)
|
||||
@@ -0,0 +1,81 @@
|
||||
# What is OxiCloud?
|
||||
|
||||
OxiCloud is a self-hosted cloud platform written in Rust. It provides file storage, calendar sync (CalDAV), contacts sync (CardDAV), and office document editing (WOPI) — all from a single binary.
|
||||
|
||||
NextCloud was too slow on a home server. So OxiCloud was built to run on minimal hardware and stay out of the way.
|
||||
|
||||
## OxiCloud vs NextCloud
|
||||
|
||||
| Metric | OxiCloud | NextCloud |
|
||||
|--------|----------|-----------|
|
||||
| **Language** | Rust (compiled, zero-cost abstractions) | PHP (interpreted) |
|
||||
| **Docker image** | ~40 MB (Alpine, static binary) | ~1 GB+ (Apache + PHP + modules) |
|
||||
| **Idle RAM** | ~30–50 MB | ~250–512 MB |
|
||||
| **Cold start** | < 1 s | 5–15 s |
|
||||
| **CPU at idle** | ~0 % | 1–5 % (cron, background jobs) |
|
||||
| **Min. hardware** | 1 vCPU / 512 MB RAM | 2 vCPU / 2 GB RAM |
|
||||
| **File dedup** | SHA-256 content-addressable | None |
|
||||
| **Dependencies** | Single binary + PostgreSQL | PHP, Apache/Nginx, Redis, Cron, … |
|
||||
| **WebDAV** | Built-in (RFC 4918) | Built-in |
|
||||
| **CalDAV / CardDAV** | Built-in | Via apps |
|
||||
| **WOPI** | Built-in | Via apps |
|
||||
| **OIDC / SSO** | Built-in | Via apps |
|
||||
|
||||
> NextCloud is a mature, feature-rich ecosystem. OxiCloud targets users who prioritise raw performance, simplicity, and low resource usage over plugin breadth.
|
||||
|
||||
## Key Features
|
||||
|
||||
### Storage & Files
|
||||
- Drag-and-drop upload, multi-file, grid & list views
|
||||
- Chunked uploads (TUS-like, parallel, resumable, MD5 integrity)
|
||||
- SHA-256 content-addressable file deduplication with ref-counting
|
||||
- Adaptive compression (zstd / gzip per MIME type)
|
||||
- Trash bin with soft-delete and auto-purge
|
||||
- Favourites, recent files, full-text search
|
||||
- Inline preview for images, PDF, text, audio & video
|
||||
- On-the-fly thumbnails & transcoding (WebP / AVIF)
|
||||
|
||||
### Protocols
|
||||
- **WebDAV** — RFC 4918, streaming PROPFIND, locking
|
||||
- **CalDAV** — calendar sync (Thunderbird, GNOME Calendar, iOS, DAVx⁵)
|
||||
- **CardDAV** — contacts sync with vCard support
|
||||
- **WOPI** — Collabora Online / OnlyOffice
|
||||
- **REST API** — complete JSON API
|
||||
|
||||
### Security
|
||||
- JWT + Argon2id password hashing
|
||||
- OIDC / SSO (Keycloak, Authentik, Authelia, Google, Azure AD)
|
||||
- Role-based access, per-folder permissions, storage quotas
|
||||
- Shared links with optional password protection
|
||||
|
||||
### Infrastructure
|
||||
- Single binary, ~40 MB Docker image
|
||||
- Dual DB pool (user queries never starved by background tasks)
|
||||
- Write-behind caching (moka) for sub-millisecond reads
|
||||
- LTO-optimised release builds
|
||||
- 222+ automated tests
|
||||
|
||||
## Feature Status
|
||||
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| File storage & upload | ✅ Working |
|
||||
| WebDAV | ✅ Working |
|
||||
| CalDAV | ✅ Working |
|
||||
| CardDAV | ✅ Working |
|
||||
| WOPI / Office editing | ✅ Working |
|
||||
| OIDC / SSO | ✅ Working |
|
||||
| Trash / recycle bin | ✅ Working |
|
||||
| Full-text search | ✅ Working |
|
||||
| Shared links | ✅ Working |
|
||||
| Music library & playlists | ✅ Working |
|
||||
| Photo gallery | ✅ Working |
|
||||
| Desktop sync client | ❌ Planned |
|
||||
| Android / iOS app | ❌ Planned |
|
||||
| E2E encryption | ❌ Planned |
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Quick Start →](/guide/installation)
|
||||
- [Deployment & Docker →](/config/deployment)
|
||||
- [Architecture →](/architecture/)
|
||||
@@ -0,0 +1,94 @@
|
||||
# Quick Start
|
||||
|
||||
## Docker (recommended)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/DioCrafts/oxicloud.git
|
||||
cd oxicloud
|
||||
cp example.env .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open **http://localhost:8086**. That's it.
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17.4-alpine
|
||||
environment:
|
||||
POSTGRES_DB: oxicloud
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
- ./db/schema.sql:/docker-entrypoint-initdb.d/schema.sql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
oxicloud:
|
||||
image: oxicloud:latest
|
||||
ports:
|
||||
- "8086:8086"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- storage_data:/app/storage
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
storage_data:
|
||||
```
|
||||
|
||||
## From Source
|
||||
|
||||
Requires **Rust 1.93+** and **PostgreSQL 13+**.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/DioCrafts/oxicloud.git
|
||||
cd oxicloud
|
||||
echo "DATABASE_URL=postgres://user:pass@localhost/oxicloud" > .env
|
||||
cargo build --release
|
||||
cargo run --release
|
||||
```
|
||||
|
||||
## Kubernetes (Helm)
|
||||
|
||||
```bash
|
||||
helm upgrade --install oxicloud charts/oxicloud \
|
||||
-f charts/oxicloud/values.yaml
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
kubectl get pods -n oxicloud
|
||||
kubectl logs statefulset/oxicloud -n oxicloud
|
||||
```
|
||||
|
||||
## Client Setup
|
||||
|
||||
| Client | Protocol | URL |
|
||||
|--------|----------|-----|
|
||||
| Windows Explorer | WebDAV | `http://host:8086/webdav/` |
|
||||
| macOS Finder | WebDAV | `http://host:8086/webdav/` |
|
||||
| Nautilus / Dolphin | WebDAV | `dav://host:8086/webdav/` |
|
||||
| Thunderbird (calendar) | CalDAV | `http://host:8086/caldav/` |
|
||||
| Thunderbird (contacts) | CardDAV | `http://host:8086/carddav/` |
|
||||
| DAVx⁵ (Android) | CalDAV + CardDAV | `http://host:8086/` |
|
||||
| GNOME Calendar | CalDAV | `http://host:8086/caldav/` |
|
||||
| GNOME Contacts | CardDAV | `http://host:8086/carddav/` |
|
||||
| Collabora / OnlyOffice | WOPI | See [WOPI configuration](/config/wopi) |
|
||||
|
||||
## What's Next?
|
||||
|
||||
- [Environment Variables →](/config/env)
|
||||
- [OIDC / SSO Setup →](/config/oidc)
|
||||
- [WebDAV Guide →](/guide/webdav)
|
||||
@@ -0,0 +1,43 @@
|
||||
# Search
|
||||
|
||||
OxiCloud provides full-text search across your files with multiple filter options.
|
||||
|
||||
## Endpoint
|
||||
|
||||
```http
|
||||
GET /api/search?q=report&type_filter=pdf,docx&recursive=true
|
||||
```
|
||||
|
||||
## Query Parameters
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `q` | Search query (matches file name) |
|
||||
| `type_filter` | Comma-separated file extensions to filter by |
|
||||
| `folder_id` | Restrict search to a specific folder |
|
||||
| `recursive` | `true` to search subfolders |
|
||||
| `date_from` / `date_to` | Filter by modification date |
|
||||
| `size_min` / `size_max` | Filter by file size (bytes) |
|
||||
| `limit` | Maximum results to return |
|
||||
| `offset` | Pagination offset |
|
||||
|
||||
## How It Works
|
||||
|
||||
OxiCloud stores file metadata in PostgreSQL with an `ltree` path column, enabling efficient recursive subtree queries:
|
||||
|
||||
```sql
|
||||
SELECT * FROM storage.files
|
||||
WHERE path <@ 'root.folder_id'
|
||||
AND LOWER(name) LIKE '%query%'
|
||||
AND LOWER(extension) = ANY('{pdf,docx}')
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 50;
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
The web UI includes a search bar in the toolbar. Results appear instantly with file name, path, size, and type. Clicking a result navigates to the file's location.
|
||||
|
||||
## Feature Flag
|
||||
|
||||
Search can be disabled via `OXICLOUD_ENABLE_SEARCH=false`.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Trash & Recycle Bin
|
||||
|
||||
OxiCloud provides a trash system that soft-deletes files and folders, allowing users to restore or permanently remove them.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. When a file or folder is deleted, it's **soft-deleted** — a flag (`is_trashed`) is set and a `trashed_at` timestamp is recorded
|
||||
2. Trashed items are hidden from normal file listings but remain on disk and in the database
|
||||
3. Users can browse the trash, restore items, or permanently delete them
|
||||
4. Items older than the retention period (default: **30 days**) are automatically purged
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/trash` | List trashed items |
|
||||
| POST | `/api/trash/restore/{id}` | Restore a trashed item |
|
||||
| DELETE | `/api/trash/{id}` | Permanently delete |
|
||||
| DELETE | `/api/trash/empty` | Empty the entire trash |
|
||||
|
||||
## Deduplication Interaction
|
||||
|
||||
Permanent deletion decrements the blob reference count. If no other file points to the same blob, the blob is removed from disk.
|
||||
|
||||
## Feature Flag
|
||||
|
||||
Trash can be disabled via `OXICLOUD_ENABLE_TRASH=false`. When disabled, deletions are permanent.
|
||||
@@ -0,0 +1,80 @@
|
||||
# WebDAV
|
||||
|
||||
OxiCloud exposes a fully RFC 4918 compliant WebDAV interface at `/webdav/`. It works with all major file managers and sync clients.
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
https://your-server:8086/webdav/
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
HTTP Basic Authentication:
|
||||
|
||||
```
|
||||
Authorization: Basic base64(username:password)
|
||||
```
|
||||
|
||||
::: tip
|
||||
Always use HTTPS in production — Basic auth sends credentials in every request.
|
||||
:::
|
||||
|
||||
## Supported Methods
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `PROPFIND` | List directory contents / get file properties |
|
||||
| `GET` | Download a file |
|
||||
| `PUT` | Upload a file |
|
||||
| `MKCOL` | Create a folder |
|
||||
| `MOVE` | Move or rename a file/folder |
|
||||
| `COPY` | Copy a file/folder |
|
||||
| `DELETE` | Delete a file/folder |
|
||||
| `LOCK` / `UNLOCK` | File locking |
|
||||
|
||||
## Client Setup
|
||||
|
||||
### Windows Explorer
|
||||
|
||||
1. Open **This PC** → **Map network drive**
|
||||
2. Enter: `https://your-server:8086/webdav/`
|
||||
3. Check **Connect using different credentials**
|
||||
4. Enter your OxiCloud username and password
|
||||
|
||||
### macOS Finder
|
||||
|
||||
1. **Go** → **Connect to Server** (⌘K)
|
||||
2. Enter: `https://your-server:8086/webdav/`
|
||||
3. Enter credentials when prompted
|
||||
|
||||
### Linux (Nautilus / Files)
|
||||
|
||||
1. Open Files → **Other Locations**
|
||||
2. In the address bar, type: `davs://your-server:8086/webdav/`
|
||||
3. Enter credentials
|
||||
|
||||
### Linux (Dolphin / KDE)
|
||||
|
||||
1. In the address bar, type: `webdavs://your-server:8086/webdav/`
|
||||
|
||||
### Command Line (curl)
|
||||
|
||||
```bash
|
||||
# List root directory
|
||||
curl -u user:pass -X PROPFIND https://your-server:8086/webdav/ \
|
||||
-H "Depth: 1"
|
||||
|
||||
# Download a file
|
||||
curl -u user:pass https://your-server:8086/webdav/document.pdf -o document.pdf
|
||||
|
||||
# Upload a file
|
||||
curl -u user:pass -T localfile.txt https://your-server:8086/webdav/remotefile.txt
|
||||
|
||||
# Create a folder
|
||||
curl -u user:pass -X MKCOL https://your-server:8086/webdav/new-folder/
|
||||
```
|
||||
|
||||
## Streaming PROPFIND
|
||||
|
||||
OxiCloud streams PROPFIND responses, so listing directories with thousands of files doesn't consume excessive memory.
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
layout: home
|
||||
|
||||
hero:
|
||||
name: "OxiCloud"
|
||||
text: "Self-hosted cloud storage"
|
||||
tagline: "Files, calendar & contacts — blazingly fast, written in Rust"
|
||||
image:
|
||||
src: /logo.svg
|
||||
alt: OxiCloud Logo
|
||||
actions:
|
||||
- theme: brand
|
||||
text: Get Started
|
||||
link: /guide/installation
|
||||
- theme: alt
|
||||
text: View on GitHub
|
||||
link: https://github.com/DioCrafts/OxiCloud
|
||||
- theme: alt
|
||||
text: Why OxiCloud?
|
||||
link: /guide/
|
||||
|
||||
features:
|
||||
- icon: 🚀
|
||||
title: Blazingly Fast
|
||||
details: Single Rust binary, ~40 MB Docker image, <1s cold start, 30–50 MB idle RAM.
|
||||
- icon: 📁
|
||||
title: Full File Management
|
||||
details: Chunked uploads, SHA-256 deduplication, trash, favourites, full-text search, thumbnails.
|
||||
- icon: 🔗
|
||||
title: WebDAV / CalDAV / CardDAV
|
||||
details: RFC-compliant protocols for files, calendars, and contacts. Works with all major clients.
|
||||
- icon: 📝
|
||||
title: Office Editing (WOPI)
|
||||
details: Edit documents in Collabora Online or OnlyOffice directly in the browser.
|
||||
- icon: 🔐
|
||||
title: Security First
|
||||
details: JWT + Argon2id, OIDC/SSO (Keycloak, Authentik, Azure AD), role-based access, shared links.
|
||||
- icon: 🌍
|
||||
title: 14 Languages
|
||||
details: EN, ES, DE, FR, IT, PT, NL, ZH, JA, KO, AR, HI, FA, RU — and growing.
|
||||
---
|
||||
Generated
+2510
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"scripts": {
|
||||
"docs:dev": "vitepress dev .",
|
||||
"docs:build": "vitepress build .",
|
||||
"docs:preview": "vitepress preview ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitepress": "^1.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500">
|
||||
<!-- Fondo circular -->
|
||||
<circle cx="250" cy="250" r="200" fill="#f8f9fa" />
|
||||
|
||||
<!-- Forma de nube principal - inspirada en Nextcloud pero con estilo propio -->
|
||||
<path d="M330 280c27.6 0 50-22.4 50-50s-22.4-50-50-50c-5.3 0-10.3 0.8-15.1 2.3C307.4 154.5 282.6 135 253 135c-29.6 0-54.4 19.5-62.9 46.3C185.3 180.8 180.3 180 175 180c-27.6 0-50 22.4-50 50s22.4 50 50 50h155z" fill="#ff5e3a" />
|
||||
|
||||
<!-- Texto del logotipo - solo el nombre -->
|
||||
<text x="250" y="350" font-family="Arial, sans-serif" font-size="55" font-weight="bold" text-anchor="middle" fill="#2b3a4a">OxiCloud</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 657 B |
@@ -1288,3 +1288,186 @@
|
||||
[data-theme="dark"] .music-empty-state-desc {
|
||||
color: var(--color-text-muted, #888);
|
||||
}
|
||||
|
||||
/* ═══════════════ Audio file picker modal ═══════════════ */
|
||||
|
||||
.music-picker-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.music-picker-overlay.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.music-picker-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(560px, 94vw);
|
||||
max-height: min(620px, 85vh);
|
||||
background: var(--bg-primary, #fff);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
transform: translateY(12px);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.music-picker-overlay.active .music-picker-modal {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* ── header ── */
|
||||
.music-picker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-color, #e2e8f0);
|
||||
}
|
||||
.music-picker-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #2d3748);
|
||||
}
|
||||
.music-picker-header h3 i { margin-right: 8px; color: var(--color-primary, #667eea); }
|
||||
.music-picker-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted, #718096);
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
.music-picker-close:hover { color: var(--color-text, #2d3748); }
|
||||
|
||||
/* ── search bar ── */
|
||||
.music-picker-search {
|
||||
position: relative;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--border-color, #e2e8f0);
|
||||
}
|
||||
.music-picker-search i {
|
||||
position: absolute;
|
||||
left: 32px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--color-text-muted, #a0aec0);
|
||||
font-size: 13px;
|
||||
}
|
||||
.music-picker-search input {
|
||||
width: 100%;
|
||||
padding: 8px 12px 8px 32px;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
background: var(--bg-secondary, #f7fafc);
|
||||
color: var(--color-text, #2d3748);
|
||||
outline: none;
|
||||
}
|
||||
.music-picker-search input:focus {
|
||||
border-color: var(--color-primary, #667eea);
|
||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.15);
|
||||
}
|
||||
|
||||
/* ── file list ── */
|
||||
.music-picker-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
min-height: 200px;
|
||||
}
|
||||
.music-picker-loading,
|
||||
.music-picker-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 40px 20px;
|
||||
color: var(--color-text-muted, #718096);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.music-picker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 20px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
font-size: 13px;
|
||||
color: var(--color-text, #2d3748);
|
||||
}
|
||||
.music-picker-item:hover { background: var(--bg-hover, #edf2f7); }
|
||||
.music-picker-item.selected { background: rgba(102, 126, 234, 0.08); }
|
||||
|
||||
.music-picker-item input[type="checkbox"] {
|
||||
accent-color: var(--color-primary, #667eea);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.music-picker-item i.fa-file-audio {
|
||||
color: var(--color-primary, #667eea);
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.music-picker-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.music-picker-size {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted, #a0aec0);
|
||||
}
|
||||
|
||||
/* ── footer ── */
|
||||
.music-picker-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--border-color, #e2e8f0);
|
||||
}
|
||||
.music-picker-selected-count {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted, #718096);
|
||||
font-weight: 500;
|
||||
}
|
||||
.music-picker-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ── dark theme ── */
|
||||
[data-theme="dark"] .music-picker-modal {
|
||||
background: var(--bg-primary, #1a202c);
|
||||
}
|
||||
[data-theme="dark"] .music-picker-header h3 {
|
||||
color: var(--color-text, #e0e0e0);
|
||||
}
|
||||
[data-theme="dark"] .music-picker-search input {
|
||||
background: var(--bg-secondary, #2d3748);
|
||||
border-color: var(--border-color, #4a5568);
|
||||
color: var(--color-text, #e0e0e0);
|
||||
}
|
||||
[data-theme="dark"] .music-picker-item {
|
||||
color: var(--color-text, #e0e0e0);
|
||||
}
|
||||
[data-theme="dark"] .music-picker-item:hover {
|
||||
background: var(--bg-hover, #2d3748);
|
||||
}
|
||||
[data-theme="dark"] .music-picker-item.selected {
|
||||
background: rgba(102, 126, 234, 0.12);
|
||||
}
|
||||
|
||||
@@ -738,53 +738,117 @@ const musicView = {
|
||||
return typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
|
||||
};
|
||||
|
||||
// Create a file picker input for audio files
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'audio/*';
|
||||
input.multiple = true;
|
||||
input.style.display = 'none';
|
||||
document.body.appendChild(input);
|
||||
// ── Build modal overlay ──
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'music-picker-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="music-picker-modal">
|
||||
<div class="music-picker-header">
|
||||
<h3><i class="fas fa-music"></i> ${t('music.add_tracks', 'Add Tracks')}</h3>
|
||||
<button class="music-picker-close" title="${t('common.close', 'Close')}">×</button>
|
||||
</div>
|
||||
<div class="music-picker-search">
|
||||
<i class="fas fa-search"></i>
|
||||
<input type="text" id="music-picker-query"
|
||||
placeholder="${t('music.search_audio', 'Search audio files…')}" autocomplete="off">
|
||||
</div>
|
||||
<div class="music-picker-list" id="music-picker-list">
|
||||
<div class="music-picker-loading"><i class="fas fa-spinner fa-spin"></i> ${t('music.loading', 'Loading…')}</div>
|
||||
</div>
|
||||
<div class="music-picker-footer">
|
||||
<span class="music-picker-selected-count" id="music-picker-count">0 ${t('music.selected', 'selected')}</span>
|
||||
<div class="music-picker-actions">
|
||||
<button class="btn btn-secondary music-picker-cancel">${t('common.cancel', 'Cancel')}</button>
|
||||
<button class="btn btn-primary music-picker-add" id="music-picker-add-btn" disabled>
|
||||
<i class="fas fa-plus"></i> ${t('music.add', 'Add')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
requestAnimationFrame(() => overlay.classList.add('active'));
|
||||
|
||||
input.addEventListener('change', async () => {
|
||||
const files = Array.from(input.files);
|
||||
input.remove();
|
||||
if (files.length === 0) return;
|
||||
const listEl = document.getElementById('music-picker-list');
|
||||
const queryInput = document.getElementById('music-picker-query');
|
||||
const addBtn = document.getElementById('music-picker-add-btn');
|
||||
const countEl = document.getElementById('music-picker-count');
|
||||
const selectedIds = new Set();
|
||||
|
||||
// Upload each file first, then add to playlist
|
||||
const fileIds = [];
|
||||
for (const file of files) {
|
||||
// ── Close helpers ──
|
||||
const close = () => {
|
||||
overlay.classList.remove('active');
|
||||
setTimeout(() => overlay.remove(), 200);
|
||||
};
|
||||
overlay.querySelector('.music-picker-close').addEventListener('click', close);
|
||||
overlay.querySelector('.music-picker-cancel').addEventListener('click', close);
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
|
||||
// ── Fetch & render audio files ──
|
||||
const AUDIO_EXTENSIONS = 'mp3,ogg,flac,wav,aac,m4a,wma,opus,webm';
|
||||
|
||||
const fetchAudioFiles = async (query = '') => {
|
||||
listEl.innerHTML = `<div class="music-picker-loading"><i class="fas fa-spinner fa-spin"></i> ${t('music.loading', 'Loading…')}</div>`;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const folderId = window.app?.currentPath || window.app?.userHomeFolderId || '';
|
||||
formData.append('folder_id', folderId);
|
||||
const params = new URLSearchParams({ type_filter: AUDIO_EXTENSIONS, limit: '200', recursive: 'true' });
|
||||
if (query.trim()) params.set('query', query.trim());
|
||||
const resp = await fetch(`/api/search?${params}`, { credentials: 'include' });
|
||||
if (!resp.ok) throw new Error('Search failed');
|
||||
const data = await resp.json();
|
||||
renderFiles(data.files || []);
|
||||
} catch (err) {
|
||||
console.error('Audio search error:', err);
|
||||
listEl.innerHTML = `<div class="music-picker-empty"><i class="fas fa-exclamation-triangle"></i> ${t('music.search_error', 'Could not load audio files')}</div>`;
|
||||
}
|
||||
};
|
||||
|
||||
const uploadResp = await fetch('/api/files/upload', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: typeof getCsrfHeaders === 'function' ? getCsrfHeaders() : {},
|
||||
body: formData
|
||||
const renderFiles = (files) => {
|
||||
if (files.length === 0) {
|
||||
listEl.innerHTML = `<div class="music-picker-empty"><i class="fas fa-folder-open"></i> ${t('music.no_audio_files', 'No audio files found')}</div>`;
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = '';
|
||||
for (const file of files) {
|
||||
const row = document.createElement('label');
|
||||
row.className = 'music-picker-item' + (selectedIds.has(file.id) ? ' selected' : '');
|
||||
const sizeStr = file.size != null && window.formatFileSize ? window.formatFileSize(file.size) : '';
|
||||
row.innerHTML = `
|
||||
<input type="checkbox" value="${file.id}" ${selectedIds.has(file.id) ? 'checked' : ''}>
|
||||
<i class="fas fa-file-audio"></i>
|
||||
<span class="music-picker-name" title="${this._escapeHtml(file.name)}">${this._escapeHtml(file.name)}</span>
|
||||
<span class="music-picker-size">${sizeStr}</span>
|
||||
`;
|
||||
const cb = row.querySelector('input');
|
||||
cb.addEventListener('change', () => {
|
||||
if (cb.checked) { selectedIds.add(file.id); row.classList.add('selected'); }
|
||||
else { selectedIds.delete(file.id); row.classList.remove('selected'); }
|
||||
countEl.textContent = `${selectedIds.size} ${t('music.selected', 'selected')}`;
|
||||
addBtn.disabled = selectedIds.size === 0;
|
||||
});
|
||||
listEl.appendChild(row);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Debounced search ──
|
||||
let searchTimer = null;
|
||||
queryInput.addEventListener('input', () => {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => fetchAudioFiles(queryInput.value), 300);
|
||||
});
|
||||
|
||||
if (!uploadResp.ok) throw new Error(`Upload failed: ${file.name}`);
|
||||
const uploaded = await uploadResp.json();
|
||||
if (uploaded.id) fileIds.push(uploaded.id);
|
||||
} catch (err) {
|
||||
console.error('Upload error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (fileIds.length === 0) return;
|
||||
// ── Add button ──
|
||||
addBtn.addEventListener('click', async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
addBtn.disabled = true;
|
||||
addBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${t('music.adding', 'Adding…')}`;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/playlists/${this.currentPlaylist.id}/tracks`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: this._headers(true),
|
||||
body: JSON.stringify({ file_ids: fileIds })
|
||||
body: JSON.stringify({ file_ids: [...selectedIds] })
|
||||
});
|
||||
|
||||
if (!resp.ok) throw new Error('Failed to add tracks');
|
||||
|
||||
if (window.notifications) {
|
||||
@@ -792,15 +856,14 @@ const musicView = {
|
||||
icon: 'fa-check-circle',
|
||||
iconClass: 'upload',
|
||||
title: t('music.add_tracks', 'Add Tracks'),
|
||||
text: `${fileIds.length} ${t('music.added_to_playlist', 'added to playlist')}`
|
||||
text: `${selectedIds.size} ${t('music.added_to_playlist', 'added to playlist')}`
|
||||
});
|
||||
}
|
||||
|
||||
close();
|
||||
await this._loadPlaylistTracks(this.currentPlaylist.id);
|
||||
// Update track count
|
||||
const playlist = this.playlists.find((p) => p.id === this.currentPlaylist.id);
|
||||
if (playlist) {
|
||||
playlist.track_count = (playlist.track_count || 0) + fileIds.length;
|
||||
playlist.track_count = (playlist.track_count || 0) + selectedIds.size;
|
||||
this.currentPlaylist.track_count = playlist.track_count;
|
||||
this._renderPlaylistList();
|
||||
const metaEl = document.getElementById('music-playlist-meta');
|
||||
@@ -816,10 +879,14 @@ const musicView = {
|
||||
text: t('music.add_error', 'Could not add tracks to playlist')
|
||||
});
|
||||
}
|
||||
addBtn.disabled = false;
|
||||
addBtn.innerHTML = `<i class="fas fa-plus"></i> ${t('music.add', 'Add')}`;
|
||||
}
|
||||
});
|
||||
|
||||
input.click();
|
||||
// ── Initial load (all audio files) ──
|
||||
queryInput.focus();
|
||||
fetchAudioFiles();
|
||||
},
|
||||
|
||||
async _removeTrackFromPlaylist(trackId, fileId) {
|
||||
|
||||
@@ -62,7 +62,13 @@
|
||||
"add_error": "تعذر إضافة المقاطع",
|
||||
"no_playlists_yet": "لا توجد قوائم بعد. أنشئ واحدة أولاً!",
|
||||
"selected_files": "محدد:",
|
||||
"error": "خطأ"
|
||||
"error": "خطأ",
|
||||
"search_audio": "البحث عن ملفات صوتية…",
|
||||
"no_audio_files": "لم يتم العثور على ملفات صوتية",
|
||||
"selected": "محدد",
|
||||
"loading": "جارٍ التحميل…",
|
||||
"search_error": "تعذر تحميل الملفات الصوتية",
|
||||
"adding": "جارٍ الإضافة…"
|
||||
},
|
||||
"actions": {
|
||||
"search": "البحث في الملفات...",
|
||||
|
||||
@@ -62,7 +62,13 @@
|
||||
"add_error": "Tracks konnten nicht hinzugefügt werden",
|
||||
"no_playlists_yet": "Noch keine Playlists. Erstellen Sie zuerst eine!",
|
||||
"selected_files": "Ausgewählt:",
|
||||
"error": "Fehler"
|
||||
"error": "Fehler",
|
||||
"search_audio": "Audiodateien suchen…",
|
||||
"no_audio_files": "Keine Audiodateien gefunden",
|
||||
"selected": "ausgewählt",
|
||||
"loading": "Wird geladen…",
|
||||
"search_error": "Audiodateien konnten nicht geladen werden",
|
||||
"adding": "Wird hinzugefügt…"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Dateien suchen...",
|
||||
|
||||
@@ -79,7 +79,13 @@
|
||||
"make_public": "Make public",
|
||||
"make_private": "Make private",
|
||||
"set_cover": "Set cover",
|
||||
"cover_updated": "Cover updated"
|
||||
"cover_updated": "Cover updated",
|
||||
"search_audio": "Search audio files…",
|
||||
"no_audio_files": "No audio files found",
|
||||
"selected": "selected",
|
||||
"loading": "Loading…",
|
||||
"search_error": "Could not load audio files",
|
||||
"adding": "Adding…"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Search files...",
|
||||
|
||||
@@ -79,7 +79,13 @@
|
||||
"make_public": "Hacer pública",
|
||||
"make_private": "Hacer privada",
|
||||
"set_cover": "Establecer portada",
|
||||
"cover_updated": "Portada actualizada"
|
||||
"cover_updated": "Portada actualizada",
|
||||
"search_audio": "Buscar archivos de audio…",
|
||||
"no_audio_files": "No se encontraron archivos de audio",
|
||||
"selected": "seleccionados",
|
||||
"loading": "Cargando…",
|
||||
"search_error": "No se pudieron cargar los archivos de audio",
|
||||
"adding": "Añadiendo…"
|
||||
},
|
||||
"share": {
|
||||
"dialogTitle": "Compartir Enlace",
|
||||
|
||||
@@ -62,7 +62,13 @@
|
||||
"add_error": "امکان افزودن آهنگها به فهرست پخش نیست",
|
||||
"no_playlists_yet": "فهرست پخشی وجود ندارد. اول یکی بسازید!",
|
||||
"selected_files": "انتخاب شده:",
|
||||
"error": "خطا"
|
||||
"error": "خطا",
|
||||
"search_audio": "جستجوی فایلهای صوتی…",
|
||||
"no_audio_files": "فایل صوتی یافت نشد",
|
||||
"selected": "انتخاب شده",
|
||||
"loading": "در حال بارگذاری…",
|
||||
"search_error": "بارگذاری فایلهای صوتی ممکن نشد",
|
||||
"adding": "در حال افزودن…"
|
||||
},
|
||||
"actions": {
|
||||
"search": "جستوجوی پروندهها..",
|
||||
|
||||
@@ -62,7 +62,13 @@
|
||||
"add_error": "Impossible d'ajouter les pistes",
|
||||
"no_playlists_yet": "Pas encore de playlists. Créez-en une d'abord !",
|
||||
"selected_files": "Sélectionnés :",
|
||||
"error": "Erreur"
|
||||
"error": "Erreur",
|
||||
"search_audio": "Rechercher des fichiers audio…",
|
||||
"no_audio_files": "Aucun fichier audio trouvé",
|
||||
"selected": "sélectionnés",
|
||||
"loading": "Chargement…",
|
||||
"search_error": "Impossible de charger les fichiers audio",
|
||||
"adding": "Ajout en cours…"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Rechercher des fichiers...",
|
||||
|
||||
@@ -62,7 +62,13 @@
|
||||
"add_error": "प्लेलिस्ट में ट्रैक नहीं जोड़े जा सके",
|
||||
"no_playlists_yet": "अभी तक कोई प्लेलिस्ट नहीं। पहले एक बनाएं!",
|
||||
"selected_files": "चयनित:",
|
||||
"error": "त्रुटि"
|
||||
"error": "त्रुटि",
|
||||
"search_audio": "ऑडियो फ़ाइलें खोजें…",
|
||||
"no_audio_files": "कोई ऑडियो फ़ाइल नहीं मिली",
|
||||
"selected": "चयनित",
|
||||
"loading": "लोड हो रहा है…",
|
||||
"search_error": "ऑडियो फ़ाइलें लोड नहीं हो सकीं",
|
||||
"adding": "जोड़ा जा रहा है…"
|
||||
},
|
||||
"actions": {
|
||||
"search": "फ़ाइलें खोजें...",
|
||||
|
||||
@@ -62,7 +62,13 @@
|
||||
"add_error": "Impossibile aggiungere le tracce",
|
||||
"no_playlists_yet": "Nessuna playlist ancora. Creane una prima!",
|
||||
"selected_files": "Selezionati:",
|
||||
"error": "Errore"
|
||||
"error": "Errore",
|
||||
"search_audio": "Cerca file audio…",
|
||||
"no_audio_files": "Nessun file audio trovato",
|
||||
"selected": "selezionati",
|
||||
"loading": "Caricamento…",
|
||||
"search_error": "Impossibile caricare i file audio",
|
||||
"adding": "Aggiunta in corso…"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Cerca file...",
|
||||
|
||||
@@ -62,7 +62,13 @@
|
||||
"add_error": "曲を追加できませんでした",
|
||||
"no_playlists_yet": "プレイリストがありません。最初に作成してください!",
|
||||
"selected_files": "選択中:",
|
||||
"error": "エラー"
|
||||
"error": "エラー",
|
||||
"search_audio": "オーディオファイルを検索…",
|
||||
"no_audio_files": "オーディオファイルが見つかりません",
|
||||
"selected": "件選択中",
|
||||
"loading": "読み込み中…",
|
||||
"search_error": "オーディオファイルを読み込めませんでした",
|
||||
"adding": "追加中…"
|
||||
},
|
||||
"actions": {
|
||||
"search": "ファイルを検索...",
|
||||
|
||||
@@ -62,7 +62,13 @@
|
||||
"add_error": "트랙을 플레이리스트에 추가할 수 없습니다",
|
||||
"no_playlists_yet": "플레이리스트가 없습니다. 먼저 하나를 만드세요!",
|
||||
"selected_files": "선택됨:",
|
||||
"error": "오류"
|
||||
"error": "오류",
|
||||
"search_audio": "오디오 파일 검색…",
|
||||
"no_audio_files": "오디오 파일을 찾을 수 없습니다",
|
||||
"selected": "선택됨",
|
||||
"loading": "로딩 중…",
|
||||
"search_error": "오디오 파일을 불러올 수 없습니다",
|
||||
"adding": "추가 중…"
|
||||
},
|
||||
"actions": {
|
||||
"search": "파일 검색...",
|
||||
|
||||
@@ -62,7 +62,13 @@
|
||||
"add_error": "Kon tracks niet toevoegen aan playlist",
|
||||
"no_playlists_yet": "Nog geen playlists. Maak er eerst een!",
|
||||
"selected_files": "Geselecteerd:",
|
||||
"error": "Fout"
|
||||
"error": "Fout",
|
||||
"search_audio": "Audiobestanden zoeken…",
|
||||
"no_audio_files": "Geen audiobestanden gevonden",
|
||||
"selected": "geselecteerd",
|
||||
"loading": "Laden…",
|
||||
"search_error": "Kan audiobestanden niet laden",
|
||||
"adding": "Toevoegen…"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Zoek bestanden...",
|
||||
|
||||
@@ -62,7 +62,13 @@
|
||||
"add_error": "Não foi possível adicionar as faixas",
|
||||
"no_playlists_yet": "Nenhuma playlist ainda. Crie uma primeiro!",
|
||||
"selected_files": "Selecionados:",
|
||||
"error": "Erro"
|
||||
"error": "Erro",
|
||||
"search_audio": "Pesquisar ficheiros de áudio…",
|
||||
"no_audio_files": "Nenhum ficheiro de áudio encontrado",
|
||||
"selected": "selecionados",
|
||||
"loading": "A carregar…",
|
||||
"search_error": "Não foi possível carregar os ficheiros de áudio",
|
||||
"adding": "A adicionar…"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Pesquisar arquivos...",
|
||||
|
||||
@@ -62,7 +62,13 @@
|
||||
"add_error": "Не удалось добавить треки в плейлист",
|
||||
"no_playlists_yet": "Плейлистов пока нет. Создайте сначала!",
|
||||
"selected_files": "Выбрано:",
|
||||
"error": "Ошибка"
|
||||
"error": "Ошибка",
|
||||
"search_audio": "Поиск аудиофайлов…",
|
||||
"no_audio_files": "Аудиофайлы не найдены",
|
||||
"selected": "выбрано",
|
||||
"loading": "Загрузка…",
|
||||
"search_error": "Не удалось загрузить аудиофайлы",
|
||||
"adding": "Добавление…"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Поиск файлов...",
|
||||
|
||||
@@ -62,7 +62,13 @@
|
||||
"add_error": "无法将曲目添加到播放列表",
|
||||
"no_playlists_yet": "暂无播放列表。请先创建一个!",
|
||||
"selected_files": "已选择:",
|
||||
"error": "错误"
|
||||
"error": "错误",
|
||||
"search_audio": "搜索音频文件…",
|
||||
"no_audio_files": "未找到音频文件",
|
||||
"selected": "已选择",
|
||||
"loading": "加载中…",
|
||||
"search_error": "无法加载音频文件",
|
||||
"adding": "添加中…"
|
||||
},
|
||||
"actions": {
|
||||
"search": "搜索文件...",
|
||||
|
||||
Reference in New Issue
Block a user