Compare commits
5 Commits
a120838cbc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 880468282a | |||
| 58ba8954c5 | |||
| ce835da9a4 | |||
| 0aa87d61e5 | |||
| e6b5cb75ba |
1988
backend/Cargo.lock
generated
1988
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -5,15 +5,20 @@ edition = "2024"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.102"
|
anyhow = "1.0.102"
|
||||||
axum = { version = "0.8.9", features = [] }
|
axum = { version = "0.8.9", features = ["tracing", "multipart", "macros"] }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
tokio = { version = "1.51.0", features = ["full"] }
|
tokio = { version = "1.52.3", features = ["full"] }
|
||||||
tower-http = { version = "0.6.8", features = ["fs"] }
|
tower-http = { version = "0.6.10", features = ["fs", "trace", "cors"] }
|
||||||
tower-cookies = "0.11.0"
|
tower-cookies = "0.11.0"
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||||
lazy-regex = "3.6.0"
|
lazy-regex = "3.6.0"
|
||||||
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono"] }
|
||||||
|
chrono = { version = "0.4.44", features = ["serde"] }
|
||||||
|
dotenvy = "0.15.7"
|
||||||
|
opendal = { version = "0.56.0", features = ["tests", "services-fs"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
axum-test = "20.0.0"
|
axum-test = "20.0.0"
|
||||||
|
serial_test = "3.4.0"
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE file_records (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
file_type TEXT NOT NULL,
|
||||||
|
size BIGINT NOT NULL,
|
||||||
|
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE file_records ADD COLUMN storage_path TEXT NOT NULL DEFAULT '';
|
||||||
@@ -10,6 +10,7 @@ pub enum LoftError {
|
|||||||
AuthFailTokenWrongFormat,
|
AuthFailTokenWrongFormat,
|
||||||
AuthFailCtxNotInRequestExt,
|
AuthFailCtxNotInRequestExt,
|
||||||
FileIdNotFound,
|
FileIdNotFound,
|
||||||
|
UndefinedErrorType,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for LoftError {
|
impl fmt::Display for LoftError {
|
||||||
@@ -34,6 +35,10 @@ impl IntoResponse for LoftError {
|
|||||||
info!("NOT_FOUND");
|
info!("NOT_FOUND");
|
||||||
StatusCode::NOT_FOUND.into_response()
|
StatusCode::NOT_FOUND.into_response()
|
||||||
}
|
}
|
||||||
|
Self::UndefinedErrorType => {
|
||||||
|
info!("INTERNAL_SERVER_ERROR");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,20 @@ mod model;
|
|||||||
mod web;
|
mod web;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use axum::{Router, middleware, response::Response};
|
use axum::{
|
||||||
|
Router,
|
||||||
|
extract::DefaultBodyLimit,
|
||||||
|
http::{HeaderValue, Method, header},
|
||||||
|
middleware,
|
||||||
|
response::Response,
|
||||||
|
};
|
||||||
use tower_cookies::CookieManagerLayer;
|
use tower_cookies::CookieManagerLayer;
|
||||||
use tower_http::services::ServeDir;
|
use tower_http::{cors::CorsLayer, services::ServeDir, trace::TraceLayer};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
model::FileController,
|
model::FileRepository,
|
||||||
web::{
|
web::{
|
||||||
mw_auth::{mw_ctx_resolver, mw_require_auth},
|
mw_auth::{mw_ctx_resolver, mw_require_auth},
|
||||||
routes_file::routes_file,
|
routes_file::routes_file,
|
||||||
@@ -25,27 +31,36 @@ async fn main() -> Result<()> {
|
|||||||
tracing_subscriber::registry()
|
tracing_subscriber::registry()
|
||||||
.with(
|
.with(
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||||
format!("{}=info,tower_http=info", env!("CARGO_CRATE_NAME")).into()
|
format!("{}=info,tower_http=debug", env!("CARGO_CRATE_NAME")).into()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.with(tracing_subscriber::fmt::layer())
|
.with(tracing_subscriber::fmt::layer())
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let file_controller = FileController::new().await?;
|
let file_repository = FileRepository::new().await?;
|
||||||
|
|
||||||
let routes_file =
|
let routes_file = routes_file(file_repository.clone())
|
||||||
routes_file(file_controller.clone()).route_layer(middleware::from_fn(mw_require_auth));
|
.route_layer(middleware::from_fn(mw_require_auth))
|
||||||
|
.layer(DefaultBodyLimit::disable());
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.nest("/api", routes_file)
|
.nest("/api", routes_file)
|
||||||
.merge(routes_health())
|
.merge(routes_health())
|
||||||
.merge(routes_login())
|
.merge(routes_login())
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
.layer(middleware::map_response(main_response_mapper))
|
.layer(middleware::map_response(main_response_mapper))
|
||||||
.layer(middleware::from_fn_with_state(
|
.layer(middleware::from_fn_with_state(
|
||||||
file_controller,
|
file_repository,
|
||||||
mw_ctx_resolver,
|
mw_ctx_resolver,
|
||||||
))
|
))
|
||||||
.layer(CookieManagerLayer::new())
|
.layer(CookieManagerLayer::new())
|
||||||
|
.layer(
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin("http://localhost:5173".parse::<HeaderValue>().unwrap())
|
||||||
|
.allow_methods([Method::GET, Method::POST, Method::DELETE])
|
||||||
|
.allow_credentials(true)
|
||||||
|
.allow_headers([header::CONTENT_TYPE]),
|
||||||
|
)
|
||||||
.fallback_service(ServeDir::new("./"));
|
.fallback_service(ServeDir::new("./"));
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
|
||||||
|
|||||||
@@ -1,67 +1,165 @@
|
|||||||
use std::sync::{Arc, Mutex};
|
use opendal::{Operator, layers::LoggingLayer, services};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{PgPool, prelude::FromRow, types::uuid};
|
||||||
|
use std::fmt::Display;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
use crate::error::LoftError;
|
use crate::error::LoftError;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize, FromRow)]
|
||||||
pub struct File {
|
#[serde(rename_all = "camelCase")]
|
||||||
pub id: u64, // make uuid
|
pub struct FileRecord {
|
||||||
|
pub id: i64,
|
||||||
|
// pub user_id: i64,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub file_type: String, // make enum
|
pub file_type: String,
|
||||||
|
pub size: i64,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub storage_path: String,
|
||||||
|
pub uploaded_at: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct FileToCreate {
|
pub enum FileType {
|
||||||
pub name: String,
|
Image,
|
||||||
pub file_type: String, // make enum
|
Video,
|
||||||
|
Document,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for FileType {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
FileType::Image => write!(f, "Image"),
|
||||||
|
FileType::Video => write!(f, "Video"),
|
||||||
|
FileType::Document => write!(f, "Document"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct FileController {
|
pub struct FileRepository {
|
||||||
file_storage: Arc<Mutex<Vec<Option<File>>>>,
|
pub pool: PgPool,
|
||||||
|
pub op: Operator,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FileController {
|
impl FileRepository {
|
||||||
pub async fn new() -> Result<Self, LoftError> {
|
pub async fn new() -> Result<Self, LoftError> {
|
||||||
Ok(Self {
|
dotenvy::dotenv().ok();
|
||||||
file_storage: Arc::default(),
|
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
})
|
let pool = PgPool::connect(&database_url).await.unwrap();
|
||||||
|
|
||||||
|
let storage_path = std::env::var("STORAGE_PATH").expect("STORAGE_PATH must be set");
|
||||||
|
let op = Operator::new(services::Fs::default().root(&storage_path))
|
||||||
|
.unwrap()
|
||||||
|
.layer(LoggingLayer::default())
|
||||||
|
.finish();
|
||||||
|
//.map_err(|x| LoftError::customerror)?;
|
||||||
|
|
||||||
|
Ok(Self { pool, op })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn upload_file(&self, file_to_create: FileToCreate) -> Result<File, LoftError> {
|
pub async fn upload_file(
|
||||||
let mut file_storage = self.file_storage.lock().unwrap();
|
&self,
|
||||||
let id = file_storage.len() as u64;
|
bytes: Vec<u8>,
|
||||||
let file = File {
|
file_name: String,
|
||||||
id,
|
) -> Result<FileRecord, LoftError> {
|
||||||
name: file_to_create.name,
|
let storage_path_name = format!("{}-{}", file_name, uuid::Uuid::new_v4());
|
||||||
file_type: file_to_create.file_type,
|
let bytes_length = bytes.len();
|
||||||
};
|
|
||||||
file_storage.push(Some(file.clone()));
|
info!("Uploading file \"{}\"", file_name);
|
||||||
|
self.op.write(&storage_path_name, bytes).await.unwrap();
|
||||||
|
|
||||||
|
info!("Saving metadata of file \"{}\" in file_records", file_name);
|
||||||
|
let file = sqlx::query_as!(
|
||||||
|
FileRecord,
|
||||||
|
r#"
|
||||||
|
INSERT INTO file_records (name, file_type, size, storage_path)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING *
|
||||||
|
"#,
|
||||||
|
file_name,
|
||||||
|
"TODO-file_type".to_string(),
|
||||||
|
bytes_length as i64,
|
||||||
|
storage_path_name
|
||||||
|
)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
Ok(file)
|
Ok(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn download_file(&self, file_id: u64) -> Result<File, LoftError> {
|
pub async fn download_file(&self, file_id: i64) -> Result<Vec<u8>, LoftError> {
|
||||||
let file_storage = self.file_storage.lock().unwrap();
|
info!(
|
||||||
file_storage
|
"Fetching metadata of file \"{}\" from file_records",
|
||||||
.iter()
|
file_id
|
||||||
.flatten()
|
);
|
||||||
.find(|f| f.id == file_id)
|
let record = self.get_file(file_id).await?;
|
||||||
|
info!("Downloading file \"{}\"", file_id);
|
||||||
|
let bytes = self.op.read(&record.storage_path).await.unwrap();
|
||||||
|
|
||||||
|
Ok(bytes.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_file(&self, file_id: i64) -> Result<FileRecord, LoftError> {
|
||||||
|
info!(
|
||||||
|
"Fetching metadata of file \"{}\" from file_records",
|
||||||
|
file_id
|
||||||
|
);
|
||||||
|
let record = sqlx::query_as!(
|
||||||
|
FileRecord,
|
||||||
|
r#"
|
||||||
|
SELECT *
|
||||||
|
FROM file_records fr
|
||||||
|
WHERE fr.id = $1
|
||||||
|
"#,
|
||||||
|
file_id
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.ok_or(LoftError::FileIdNotFound)?;
|
||||||
|
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_file(&self, file_id: i64) -> Result<FileRecord, LoftError> {
|
||||||
|
info!(
|
||||||
|
"Fetching metadata of file \"{}\" from file_records",
|
||||||
|
file_id
|
||||||
|
);
|
||||||
|
let record = self.get_file(file_id).await?;
|
||||||
|
info!("Downloading file bytes \"{}\"", file_id);
|
||||||
|
self.op.delete(&record.storage_path).await.unwrap();
|
||||||
|
|
||||||
|
info!("Downloading file record \"{}\"", file_id);
|
||||||
|
sqlx::query_as!(
|
||||||
|
FileRecord,
|
||||||
|
r#"
|
||||||
|
DELETE FROM file_records
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *
|
||||||
|
"#,
|
||||||
|
file_id
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
.ok_or(LoftError::FileIdNotFound)
|
.ok_or(LoftError::FileIdNotFound)
|
||||||
.cloned()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_file(&self, file_id: u64) -> Result<File, LoftError> {
|
pub async fn list_files(&self) -> Result<Vec<FileRecord>, LoftError> {
|
||||||
let mut file_storage = self.file_storage.lock().unwrap();
|
let files = sqlx::query_as!(
|
||||||
let file = file_storage
|
FileRecord,
|
||||||
.get_mut(file_id as usize)
|
r#"
|
||||||
.and_then(|f| f.take());
|
SELECT *
|
||||||
file.ok_or(LoftError::FileIdNotFound)
|
FROM file_records fr
|
||||||
}
|
"#
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
pub async fn list_files(&self) -> Result<Vec<File>, LoftError> {
|
|
||||||
let file_storage = self.file_storage.lock().unwrap();
|
|
||||||
let files = file_storage.iter().flatten().cloned().collect();
|
|
||||||
Ok(files)
|
Ok(files)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,59 +168,91 @@ impl FileController {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
async fn fc() -> Result<FileController, LoftError> {
|
async fn truncate(pool: &PgPool) {
|
||||||
Ok(FileController::new().await.unwrap())
|
sqlx::query!("TRUNCATE TABLE file_records")
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new_file(name: &str) -> FileToCreate {
|
async fn file_repository() -> Result<FileRepository, LoftError> {
|
||||||
FileToCreate {
|
Ok(FileRepository::new().await.unwrap())
|
||||||
name: name.to_string(),
|
|
||||||
file_type: "text".to_string(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
async fn test_upload_and_list() {
|
async fn test_upload_and_list() {
|
||||||
let fc = fc().await.unwrap();
|
let file_repository = file_repository().await.unwrap();
|
||||||
fc.upload_file(new_file("a.txt")).await.unwrap();
|
truncate(&file_repository.pool).await;
|
||||||
fc.upload_file(new_file("b.txt")).await.unwrap();
|
|
||||||
let files = fc.list_files().await.unwrap();
|
file_repository
|
||||||
|
.upload_file(vec![0u8; 10], "a.jpg".to_string())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
file_repository
|
||||||
|
.upload_file(vec![0u8; 10], "b.jpg".to_string())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let files = file_repository.list_files().await.unwrap();
|
||||||
|
|
||||||
assert_eq!(files.len(), 2);
|
assert_eq!(files.len(), 2);
|
||||||
|
truncate(&file_repository.pool).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
async fn test_download() {
|
async fn test_download() {
|
||||||
let fc = fc().await.unwrap();
|
let file_repository = file_repository().await.unwrap();
|
||||||
let uploaded = fc.upload_file(new_file("a.txt")).await.unwrap();
|
truncate(&file_repository.pool).await;
|
||||||
let downloaded = fc.download_file(uploaded.id).await.unwrap();
|
|
||||||
assert_eq!(downloaded.name, "a.txt");
|
let uploaded = file_repository
|
||||||
|
.upload_file(vec![0u8; 10], "a.jpg".to_string())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let downloaded = file_repository.download_file(uploaded.id).await.unwrap();
|
||||||
|
|
||||||
|
assert!(!downloaded.is_empty());
|
||||||
|
truncate(&file_repository.pool).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
async fn test_download_not_found() {
|
async fn test_download_not_found() {
|
||||||
let fc = fc().await.unwrap();
|
let file_repository = file_repository().await.unwrap();
|
||||||
|
truncate(&file_repository.pool).await;
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
fc.download_file(99).await,
|
file_repository.download_file(i64::MAX).await,
|
||||||
Err(LoftError::FileIdNotFound)
|
Err(LoftError::FileIdNotFound)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
async fn test_delete() {
|
async fn test_delete() {
|
||||||
let fc = fc().await.unwrap();
|
let file_repository = file_repository().await.unwrap();
|
||||||
let uploaded = fc.upload_file(new_file("a.txt")).await.unwrap();
|
truncate(&file_repository.pool).await;
|
||||||
fc.delete_file(uploaded.id).await.unwrap();
|
|
||||||
|
let uploaded = file_repository
|
||||||
|
.upload_file(vec![0u8; 10], "a.jpg".to_string())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
file_repository.delete_file(uploaded.id).await.unwrap();
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
fc.download_file(uploaded.id).await,
|
file_repository.download_file(uploaded.id).await,
|
||||||
Err(LoftError::FileIdNotFound)
|
Err(LoftError::FileIdNotFound)
|
||||||
));
|
));
|
||||||
|
truncate(&file_repository.pool).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
async fn test_delete_not_found() {
|
async fn test_delete_not_found() {
|
||||||
let fc = fc().await.unwrap();
|
let file_repository = file_repository().await.unwrap();
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
fc.delete_file(99).await,
|
file_repository.delete_file(99).await,
|
||||||
Err(LoftError::FileIdNotFound)
|
Err(LoftError::FileIdNotFound)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +1,104 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
extract::{Path, State},
|
extract::{Multipart, Path, State},
|
||||||
|
response::IntoResponse,
|
||||||
routing::get,
|
routing::get,
|
||||||
};
|
};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::LoftError,
|
error::LoftError,
|
||||||
model::{File, FileController, FileToCreate},
|
model::{FileRecord, FileRepository},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes_file(file_controller: FileController) -> Router {
|
pub fn routes_file(file_repository: FileRepository) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/files", get(list_files).post(upload_file))
|
.route("/files", get(list_files).post(upload_file))
|
||||||
.route("/files/{id}", get(download_file).delete(delete_file))
|
.route("/files/{id}", get(get_file).delete(delete_file))
|
||||||
.with_state(file_controller)
|
.route("/files/{id}/download", get(download_file))
|
||||||
|
.with_state(file_repository)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn upload_file(
|
async fn upload_file(
|
||||||
State(file_controller): State<FileController>,
|
State(file_repository): State<FileRepository>,
|
||||||
Json(file_to_create): Json<FileToCreate>,
|
mut multipart: Multipart,
|
||||||
) -> Result<Json<File>, LoftError> {
|
) -> Result<Json<FileRecord>, LoftError> {
|
||||||
info!("handler: upload_file");
|
info!("handler: upload_file");
|
||||||
|
|
||||||
let file = file_controller.upload_file(file_to_create).await?;
|
let mut file_name = None;
|
||||||
Ok(Json(file))
|
let mut file_type: Option<String> = None;
|
||||||
|
let mut bytes = None;
|
||||||
|
|
||||||
|
while let Some(field) = multipart.next_field().await.unwrap() {
|
||||||
|
match field.name().unwrap() {
|
||||||
|
// "file_type" => file_type = Some(field.text().await.unwrap().parse().unwrap()),
|
||||||
|
"file" => {
|
||||||
|
file_name = field.file_name().map(|s| s.to_string());
|
||||||
|
bytes = Some(field.bytes().await.unwrap().to_vec())
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let (Some(bytes), Some(file_name)) = (bytes, file_name) {
|
||||||
|
let file = file_repository.upload_file(bytes, file_name).await?;
|
||||||
|
return Ok(Json(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(LoftError::UndefinedErrorType)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn download_file(
|
#[axum::debug_handler]
|
||||||
State(file_controller): State<FileController>,
|
async fn get_file(
|
||||||
|
State(file_repository): State<FileRepository>,
|
||||||
Path(file_id): Path<u64>,
|
Path(file_id): Path<u64>,
|
||||||
) -> Result<Json<File>, LoftError> {
|
) -> Result<Json<FileRecord>, LoftError> {
|
||||||
info!("handler: download_file");
|
let record = file_repository.get_file(file_id as i64).await?;
|
||||||
|
Ok(Json(record))
|
||||||
|
}
|
||||||
|
|
||||||
let file = file_controller.download_file(file_id).await?;
|
#[axum::debug_handler]
|
||||||
Ok(Json(file))
|
async fn download_file(
|
||||||
|
State(file_repository): State<FileRepository>,
|
||||||
|
Path(file_id): Path<u64>,
|
||||||
|
) -> Result<impl IntoResponse, LoftError> {
|
||||||
|
let bytes = file_repository.download_file(file_id as i64).await?;
|
||||||
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_file(
|
async fn delete_file(
|
||||||
State(file_controller): State<FileController>,
|
State(file_repository): State<FileRepository>,
|
||||||
Path(file_id): Path<u64>,
|
Path(file_id): Path<u64>,
|
||||||
) -> Result<Json<File>, LoftError> {
|
) -> Result<Json<FileRecord>, LoftError> {
|
||||||
info!("handler: delete_file");
|
info!("handler: delete_file");
|
||||||
|
|
||||||
let file = file_controller.delete_file(file_id).await?;
|
let file = file_repository.delete_file(file_id as i64).await?;
|
||||||
Ok(Json(file))
|
Ok(Json(file))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_files(
|
async fn list_files(
|
||||||
State(file_controller): State<FileController>,
|
State(file_repository): State<FileRepository>,
|
||||||
// can add a filters param here
|
// can add a filters param here
|
||||||
) -> Result<Json<Vec<File>>, LoftError> {
|
) -> Result<Json<Vec<FileRecord>>, LoftError> {
|
||||||
info!("handler: list_files");
|
info!("handler: list_files");
|
||||||
|
|
||||||
let files = file_controller.list_files().await?;
|
let files = file_repository.list_files().await?;
|
||||||
Ok(Json(files))
|
Ok(Json(files))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use axum::{Router, middleware};
|
use axum::{Router, middleware};
|
||||||
use axum_test::TestServer;
|
use axum_test::{
|
||||||
|
TestServer,
|
||||||
|
multipart::{MultipartForm, Part},
|
||||||
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use sqlx::PgPool;
|
||||||
use tower_cookies::CookieManagerLayer;
|
use tower_cookies::CookieManagerLayer;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
model::FileController,
|
model::FileRepository,
|
||||||
web::{
|
web::{
|
||||||
mw_auth::{mw_ctx_resolver, mw_require_auth},
|
mw_auth::{mw_ctx_resolver, mw_require_auth},
|
||||||
routes_file::routes_file,
|
routes_file::routes_file,
|
||||||
@@ -77,19 +110,26 @@ mod tests {
|
|||||||
const BAD_AUTH_COOKIE: &str = "auth-token=user-1.0123456789";
|
const BAD_AUTH_COOKIE: &str = "auth-token=user-1.0123456789";
|
||||||
|
|
||||||
async fn test_server() -> TestServer {
|
async fn test_server() -> TestServer {
|
||||||
let file_controller = FileController::new().await.unwrap();
|
let file_repository = FileRepository::new().await.unwrap();
|
||||||
let routes_file =
|
let routes_file =
|
||||||
routes_file(file_controller.clone()).route_layer(middleware::from_fn(mw_require_auth));
|
routes_file(file_repository.clone()).route_layer(middleware::from_fn(mw_require_auth));
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.nest("/api", routes_file)
|
.nest("/api", routes_file)
|
||||||
.layer(middleware::from_fn_with_state(
|
.layer(middleware::from_fn_with_state(
|
||||||
file_controller,
|
file_repository,
|
||||||
mw_ctx_resolver,
|
mw_ctx_resolver,
|
||||||
))
|
))
|
||||||
.layer(CookieManagerLayer::new());
|
.layer(CookieManagerLayer::new());
|
||||||
TestServer::new(app)
|
TestServer::new(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn truncate(pool: &PgPool) {
|
||||||
|
sqlx::query!("TRUNCATE TABLE file_records")
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_requires_auth() {
|
async fn test_requires_auth() {
|
||||||
let server = test_server().await;
|
let server = test_server().await;
|
||||||
@@ -107,17 +147,29 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
async fn test_requires_auth_post() {
|
async fn test_requires_auth_post() {
|
||||||
|
let file_repository = FileRepository::new().await.unwrap();
|
||||||
|
truncate(&file_repository.pool).await;
|
||||||
|
|
||||||
let server = test_server().await;
|
let server = test_server().await;
|
||||||
server
|
server
|
||||||
.post("/api/files")
|
.post("/api/files")
|
||||||
.json(&json!({"name": "a.txt", "file_type": "text"}))
|
.multipart(MultipartForm::new().add_part(
|
||||||
|
"file",
|
||||||
|
Part::bytes(b"fake_bytes".to_vec()).file_name("a.jpg"),
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
.assert_status_unauthorized();
|
.assert_status_unauthorized();
|
||||||
|
truncate(&file_repository.pool).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
async fn test_list_files_empty() {
|
async fn test_list_files_empty() {
|
||||||
|
let file_repository = FileRepository::new().await.unwrap();
|
||||||
|
truncate(&file_repository.pool).await;
|
||||||
|
|
||||||
let server = test_server().await;
|
let server = test_server().await;
|
||||||
server
|
server
|
||||||
.get("/api/files")
|
.get("/api/files")
|
||||||
@@ -128,73 +180,106 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
async fn test_upload_and_list_files() {
|
async fn test_upload_and_list_files() {
|
||||||
|
let file_repository = FileRepository::new().await.unwrap();
|
||||||
|
truncate(&file_repository.pool).await;
|
||||||
|
|
||||||
let server = test_server().await;
|
let server = test_server().await;
|
||||||
let res = server
|
let res = server
|
||||||
.post("/api/files")
|
.post("/api/files")
|
||||||
.add_header(axum::http::header::COOKIE, AUTH_COOKIE)
|
.add_header(axum::http::header::COOKIE, AUTH_COOKIE)
|
||||||
.json(&json!({"name": "a.txt", "file_type": "text"}))
|
.multipart(MultipartForm::new().add_part(
|
||||||
|
"file",
|
||||||
|
Part::bytes(b"fake_bytes".to_vec()).file_name("a.jpg"),
|
||||||
|
))
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
res.assert_status_ok();
|
res.assert_status_ok();
|
||||||
let file = res.json::<serde_json::Value>();
|
let file = res.json::<serde_json::Value>();
|
||||||
assert_eq!(file["name"], "a.txt");
|
assert_eq!(file["name"], "a.jpg");
|
||||||
assert_eq!(file["id"], 0);
|
|
||||||
|
|
||||||
let list = server
|
let list = server
|
||||||
.get("/api/files")
|
.get("/api/files")
|
||||||
.add_header(axum::http::header::COOKIE, AUTH_COOKIE)
|
.add_header(axum::http::header::COOKIE, AUTH_COOKIE)
|
||||||
.await
|
.await
|
||||||
.json::<serde_json::Value>();
|
.json::<serde_json::Value>();
|
||||||
|
|
||||||
assert_eq!(list.as_array().unwrap().len(), 1);
|
assert_eq!(list.as_array().unwrap().len(), 1);
|
||||||
|
truncate(&file_repository.pool).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
async fn test_download_file() {
|
async fn test_download_file() {
|
||||||
|
let file_repository = FileRepository::new().await.unwrap();
|
||||||
|
truncate(&file_repository.pool).await;
|
||||||
|
|
||||||
let server = test_server().await;
|
let server = test_server().await;
|
||||||
server
|
let post_res = server
|
||||||
.post("/api/files")
|
.post("/api/files")
|
||||||
.add_header(axum::http::header::COOKIE, AUTH_COOKIE)
|
.add_header(axum::http::header::COOKIE, AUTH_COOKIE)
|
||||||
.json(&json!({"name": "b.txt", "file_type": "text"}))
|
.multipart(MultipartForm::new().add_part(
|
||||||
|
"file",
|
||||||
|
Part::bytes(b"fake_bytes".to_vec()).file_name("a.jpg"),
|
||||||
|
))
|
||||||
.await;
|
.await;
|
||||||
|
let id = post_res.json::<serde_json::Value>()["id"].as_i64().unwrap();
|
||||||
let res = server
|
let res = server
|
||||||
.get("/api/files/0")
|
.get(&format!("/api/files/{id}/download"))
|
||||||
.add_header(axum::http::header::COOKIE, AUTH_COOKIE)
|
.add_header(axum::http::header::COOKIE, AUTH_COOKIE)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
res.assert_status_ok();
|
res.assert_status_ok();
|
||||||
assert_eq!(res.json::<serde_json::Value>()["name"], "b.txt");
|
assert_eq!(res.as_bytes(), b"fake_bytes".as_ref());
|
||||||
|
truncate(&file_repository.pool).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
async fn test_download_file_not_found() {
|
async fn test_download_file_not_found() {
|
||||||
let server = test_server().await;
|
let server = test_server().await;
|
||||||
server
|
server
|
||||||
.get("/api/files/99")
|
.get("/api/files/99/download")
|
||||||
.add_header(axum::http::header::COOKIE, AUTH_COOKIE)
|
.add_header(axum::http::header::COOKIE, AUTH_COOKIE)
|
||||||
.await
|
.await
|
||||||
.assert_status_not_found();
|
.assert_status_not_found();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
async fn test_delete_file() {
|
async fn test_delete_file() {
|
||||||
|
let file_repository = FileRepository::new().await.unwrap();
|
||||||
|
truncate(&file_repository.pool).await;
|
||||||
|
|
||||||
let server = test_server().await;
|
let server = test_server().await;
|
||||||
server
|
let post_res = server
|
||||||
.post("/api/files")
|
.post("/api/files")
|
||||||
.add_header(axum::http::header::COOKIE, AUTH_COOKIE)
|
.add_header(axum::http::header::COOKIE, AUTH_COOKIE)
|
||||||
.json(&json!({"name": "c.txt", "file_type": "text"}))
|
.multipart(MultipartForm::new().add_part(
|
||||||
|
"file",
|
||||||
|
Part::bytes(b"fake_bytes".to_vec()).file_name("a.jpg"),
|
||||||
|
))
|
||||||
.await;
|
.await;
|
||||||
|
let id = post_res.json::<serde_json::Value>()["id"].as_i64().unwrap();
|
||||||
|
|
||||||
server
|
server
|
||||||
.delete("/api/files/0")
|
.delete(&format!("/api/files/{id}"))
|
||||||
.add_header(axum::http::header::COOKIE, AUTH_COOKIE)
|
.add_header(axum::http::header::COOKIE, AUTH_COOKIE)
|
||||||
.await
|
.await
|
||||||
.assert_status_ok();
|
.assert_status_ok();
|
||||||
|
|
||||||
server
|
server
|
||||||
.get("/api/files/0")
|
.get(&format!("/api/files/{id}"))
|
||||||
.add_header(axum::http::header::COOKIE, AUTH_COOKIE)
|
.add_header(axum::http::header::COOKIE, AUTH_COOKIE)
|
||||||
.await
|
.await
|
||||||
.assert_status_not_found();
|
.assert_status_not_found();
|
||||||
|
|
||||||
|
truncate(&file_repository.pool).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[serial_test::serial]
|
||||||
async fn test_delete_file_not_found() {
|
async fn test_delete_file_not_found() {
|
||||||
let server = test_server().await;
|
let server = test_server().await;
|
||||||
server
|
server
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Caveat:wght@700&display=swap" rel="stylesheet" />
|
||||||
<meta name="text-scale" content="scale" />
|
<meta name="text-scale" content="scale" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 963 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.1566,22.8189c-10.4-14.8851-30.94-19.2971-45.7914-9.8348L22.2825,29.6078A29.9234,29.9234,0,0,0,8.7639,49.6506a31.5136,31.5136,0,0,0,3.1076,20.2318A30.0061,30.0061,0,0,0,7.3953,81.0653a31.8886,31.8886,0,0,0,5.4473,24.1157c10.4022,14.8865,30.9423,19.2966,45.7914,9.8348L84.7167,98.3921A29.9177,29.9177,0,0,0,98.2353,78.3493,31.5263,31.5263,0,0,0,95.13,58.117a30,30,0,0,0,4.4743-11.1824,31.88,31.88,0,0,0-5.4473-24.1157" style="fill:#ff3e00"/><path d="M45.8171,106.5815A20.7182,20.7182,0,0,1,23.58,98.3389a19.1739,19.1739,0,0,1-3.2766-14.5025,18.1886,18.1886,0,0,1,.6233-2.4357l.4912-1.4978,1.3363.9815a33.6443,33.6443,0,0,0,10.203,5.0978l.9694.2941-.0893.9675a5.8474,5.8474,0,0,0,1.052,3.8781,6.2389,6.2389,0,0,0,6.6952,2.485,5.7449,5.7449,0,0,0,1.6021-.7041L69.27,76.281a5.4306,5.4306,0,0,0,2.4506-3.631,5.7948,5.7948,0,0,0-.9875-4.3712,6.2436,6.2436,0,0,0-6.6978-2.4864,5.7427,5.7427,0,0,0-1.6.7036l-9.9532,6.3449a19.0329,19.0329,0,0,1-5.2965,2.3259,20.7181,20.7181,0,0,1-22.2368-8.2427,19.1725,19.1725,0,0,1-3.2766-14.5024,17.9885,17.9885,0,0,1,8.13-12.0513L55.8833,23.7472a19.0038,19.0038,0,0,1,5.3-2.3287A20.7182,20.7182,0,0,1,83.42,29.6611a19.1739,19.1739,0,0,1,3.2766,14.5025,18.4,18.4,0,0,1-.6233,2.4357l-.4912,1.4978-1.3356-.98a33.6175,33.6175,0,0,0-10.2037-5.1l-.9694-.2942.0893-.9675a5.8588,5.8588,0,0,0-1.052-3.878,6.2389,6.2389,0,0,0-6.6952-2.485,5.7449,5.7449,0,0,0-1.6021.7041L37.73,51.719a5.4218,5.4218,0,0,0-2.4487,3.63,5.7862,5.7862,0,0,0,.9856,4.3717,6.2437,6.2437,0,0,0,6.6978,2.4864,5.7652,5.7652,0,0,0,1.602-.7041l9.9519-6.3425a18.978,18.978,0,0,1,5.2959-2.3278,20.7181,20.7181,0,0,1,22.2368,8.2427,19.1725,19.1725,0,0,1,3.2766,14.5024,17.9977,17.9977,0,0,1-8.13,12.0532L51.1167,104.2528a19.0038,19.0038,0,0,1-5.3,2.3287" style="fill:#fff"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 352 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 113 KiB |
@@ -1,8 +0,0 @@
|
|||||||
<script>
|
|
||||||
import { greet } from './greet';
|
|
||||||
|
|
||||||
let { host = 'SvelteKit', guest = 'Vitest' } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<h1>{greet(host)}</h1>
|
|
||||||
<p>{greet(guest)}</p>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { page } from 'vitest/browser';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { render } from 'vitest-browser-svelte';
|
|
||||||
import Welcome from './Welcome.svelte';
|
|
||||||
|
|
||||||
describe('Welcome.svelte', () => {
|
|
||||||
it('renders greetings for host and guest', async () => {
|
|
||||||
render(Welcome, { host: 'SvelteKit', guest: 'Vitest' });
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.element(page.getByRole('heading', { level: 1 }))
|
|
||||||
.toHaveTextContent('Hello, SvelteKit!');
|
|
||||||
await expect.element(page.getByText('Hello, Vitest!')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { greet } from './greet';
|
|
||||||
|
|
||||||
describe('greet', () => {
|
|
||||||
it('returns a greeting', () => {
|
|
||||||
expect(greet('Svelte')).toBe('Hello, Svelte!');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export function greet(name: string): string {
|
|
||||||
return 'Hello, ' + name + '!';
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Header from './Header.svelte';
|
|
||||||
import './layout.css';
|
import './layout.css';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<Header />
|
|
||||||
<main>{@render children()}</main>
|
<main>{@render children()}</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<p>
|
|
||||||
visit
|
|
||||||
<a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a>
|
|
||||||
to learn about SvelteKit
|
|
||||||
</p>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -44,10 +37,6 @@
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer a {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 480px) {
|
@media (min-width: 480px) {
|
||||||
footer {
|
footer {
|
||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
|
|||||||
@@ -1,60 +1,190 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import welcomeFallback from '$lib/images/svelte-welcome.png';
|
import { onMount } from 'svelte';
|
||||||
import welcome from '$lib/images/svelte-welcome.webp';
|
import { fade } from 'svelte/transition';
|
||||||
|
import { flip } from 'svelte/animate'
|
||||||
|
|
||||||
import Counter from './Counter.svelte';
|
type FileRecord = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
uploadedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let fileRecords = $state<FileRecord[]>([]);
|
||||||
|
let confirmDeleteId = $state<number | null>(null);
|
||||||
|
let search = $state('');
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
let filteredFileRecords = $derived(fileRecords.filter(f =>
|
||||||
|
f.name.toLowerCase().includes(search.toLowerCase())
|
||||||
|
));
|
||||||
|
|
||||||
|
let fileInput: HTMLInputElement;
|
||||||
|
|
||||||
|
function openFilePicker() {
|
||||||
|
fileInput.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFiles() {
|
||||||
|
const res = await fetch('http://localhost:3000/api/files', {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
fileRecords = await res.json();
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(loadFiles);
|
||||||
|
|
||||||
|
async function uploadFile(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
await fetch('http://localhost:3000/api/files', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
input.value = '';
|
||||||
|
await loadFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(fileId: number, fileName: string) {
|
||||||
|
const res = await fetch(`http://localhost:3000/api/files/${fileId}/download`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyLink() {
|
||||||
|
await loadFiles();
|
||||||
|
//TODO: Generate sharable link with expiration date, add param fileId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFile(fileId: number) {
|
||||||
|
await fetch(`http://localhost:3000/api/files/${fileId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
await loadFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatName(name: string): string {
|
||||||
|
if (name.length > 50) {
|
||||||
|
name = name.slice(0, 50).concat("...");
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1000) return bytes + 'B';
|
||||||
|
if (bytes < 1000 * 1000) return (bytes / 1000).toFixed(1) + 'KB';
|
||||||
|
if (bytes < 1000 * 1000 * 1000) return (bytes / 1000 / 1000).toFixed(1) + 'MB';
|
||||||
|
return (bytes / 1000 / 1000 / 1000).toFixed(1) + 'GB';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<div class="w-full px-16 py-8">
|
||||||
<title>Home</title>
|
<h1 class="text-4xl text-white mb-6" style="font-family: 'Caveat', cursive;">rafi</h1>
|
||||||
<meta name="description" content="Svelte demo app" />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<section>
|
<div class="flex justify-center mb-4">
|
||||||
<h1>
|
<input
|
||||||
<span class="welcome">
|
type="text"
|
||||||
<picture>
|
placeholder="Search files..."
|
||||||
<source srcset={welcome} type="image/webp" />
|
bind:value={search}
|
||||||
<img src={welcomeFallback} alt="Welcome" />
|
class="w-96 px-4 py-1.5 rounded-xl bg-white/5 border border-sky-200/20 transition-colors hover:border-sky-200/40 text-white placeholder-white/30 text-sm focus:outline-none focus:ring-1 focus:ring-white/10"
|
||||||
</picture>
|
/>
|
||||||
</span>
|
</div>
|
||||||
|
|
||||||
to your new<br />SvelteKit app
|
<input type="file" bind:this={fileInput} onchange={uploadFile} class="hidden" />
|
||||||
</h1>
|
<div class="flex justify-start mb-1">
|
||||||
|
<button class="px-2 py-1 border border-sky-200/20 text-white/60 hover:border-sky-200/40 text-sm transition-colors rounded-sm cursor-pointer" onclick={openFilePicker}>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2>
|
<div class="rounded-sm border border-sky-200/20">
|
||||||
try editing <strong>src/routes/+page.svelte</strong>
|
<!-- Header row -->
|
||||||
</h2>
|
<div class="flex items-stretch border-b border-sky-200/20 bg-white/5">
|
||||||
|
<span class="font-medium text-white/40 text-sm flex-1 py-2 pl-6">Name</span>
|
||||||
|
<span class="text-sm text-white/40 w-40 py-2">Size</span>
|
||||||
|
<span class="text-sm text-white/40 w-48 py-2">Uploaded at</span>
|
||||||
|
<div class="w-px bg-sky-200/20 self-stretch"></div>
|
||||||
|
<span class="text-sm text-white/40 w-32 text-center py-2">Actions</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Counter />
|
{#each filteredFileRecords as fileRecord (fileRecord.id)}
|
||||||
</section>
|
<div
|
||||||
|
transition:fade={{ duration: 200 }}
|
||||||
<style>
|
animate:flip={{ duration: 200 }}
|
||||||
section {
|
class="flex items-stretch border-b border-sky-200/20 border-l-2 border-l-transparent last:border-b-0 hover:bg-white/2 hover:border-l-sky-400/50 transition-all"
|
||||||
display: flex;
|
>
|
||||||
flex-direction: column;
|
<span class="font-medium text-white text-sm flex-1 truncate py-2 pl-6">{formatName(fileRecord.name)}</span>
|
||||||
justify-content: center;
|
<span class="text-sm text-white/40 w-40 py-2">{formatSize(fileRecord.size)}</span>
|
||||||
align-items: center;
|
<span class="text-sm text-white/40 w-48 py-2">{new Date(fileRecord.uploadedAt).toLocaleString()}</span>
|
||||||
flex: 0.6;
|
<div class="w-px bg-sky-200/20 self-stretch"></div>
|
||||||
}
|
<div class="flex items-center gap-4 w-32 justify-center relative">
|
||||||
|
<!-- Download -->
|
||||||
h1 {
|
<button class="text-white/40 hover:text-emerald-400 hover:scale-110 transition-all cursor-pointer" title="Download" onclick={() => downloadFile(fileRecord.id, fileRecord.name)}>
|
||||||
width: 100%;
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
}
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
|
<polyline points="7 10 12 15 17 10"/>
|
||||||
.welcome {
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
display: block;
|
</svg>
|
||||||
position: relative;
|
</button>
|
||||||
width: 100%;
|
<!-- TODO: Copy -->
|
||||||
height: 0;
|
<button class="text-white/40 hover:text-blue-400 hover:scale-110 transition-all cursor-pointer" title="Copy link" onclick={() => copyLink()}>
|
||||||
padding: 0 0 calc(100% * 495 / 2048) 0;
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
}
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||||
.welcome img {
|
</svg>
|
||||||
position: absolute;
|
</button>
|
||||||
width: 100%;
|
<!-- Delete -->
|
||||||
height: 100%;
|
<div class="relative">
|
||||||
top: 0;
|
<button class="text-white/40 hover:text-red-400 hover:scale-110 transition-all cursor-pointer" title="Delete" onclick={() => confirmDeleteId = fileRecord.id}>
|
||||||
display: block;
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
}
|
<polyline points="3 6 5 6 21 6"/>
|
||||||
</style>
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
|
||||||
|
<path d="M10 11v6"/>
|
||||||
|
<path d="M14 11v6"/>
|
||||||
|
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{#if confirmDeleteId === fileRecord.id}
|
||||||
|
<div class="absolute left-6 top-1/2 -translate-y-1/2 flex items-center z-10">
|
||||||
|
<div class="w-0 h-0 border-t-4 border-b-4 border-r-4 border-t-transparent border-b-transparent border-r-sky-200/20"></div>
|
||||||
|
<div class="bg-[#0f1117] border border-sky-200/20 rounded px-3 py-2 flex items-center gap-2 whitespace-nowrap">
|
||||||
|
<span class="text-white/40 text-xs">Confirm action</span>
|
||||||
|
<button class="text-white/60 hover:text-white transition-colors cursor-pointer" title="Confirm" onclick={() => { deleteFile(fileRecord.id); confirmDeleteId = null; }}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="20 6 9 17 4 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="text-white/60 hover:text-white transition-colors cursor-pointer" title="Cancel" onclick={() => confirmDeleteId = null}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if !loading}
|
||||||
|
<p class="py-2 px-4 text-white/30 text-sm">No files yet.</p>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
// since there's no dynamic data here, we can prerender
|
|
||||||
// it so that it gets served as a static asset in production
|
|
||||||
export const prerender = true;
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Spring } from 'svelte/motion';
|
|
||||||
|
|
||||||
const count = new Spring(0);
|
|
||||||
const offset = $derived(modulo(count.current, 1));
|
|
||||||
|
|
||||||
function modulo(n: number, m: number) {
|
|
||||||
// handle negative numbers
|
|
||||||
return ((n % m) + m) % m;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="counter">
|
|
||||||
<button onclick={() => (count.target -= 1)} aria-label="Decrease the counter by one">
|
|
||||||
<svg aria-hidden="true" viewBox="0 0 1 1">
|
|
||||||
<path d="M0,0.5 L1,0.5" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="counter-viewport">
|
|
||||||
<div class="counter-digits" style="transform: translate(0, {100 * offset}%)">
|
|
||||||
<strong class="hidden" aria-hidden="true">{Math.floor(count.current + 1)}</strong>
|
|
||||||
<strong>{Math.floor(count.current)}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button onclick={() => (count.target += 1)} aria-label="Increase the counter by one">
|
|
||||||
<svg aria-hidden="true" viewBox="0 0 1 1">
|
|
||||||
<path d="M0,0.5 L1,0.5 M0.5,0 L0.5,1" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.counter {
|
|
||||||
display: flex;
|
|
||||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.counter button {
|
|
||||||
width: 2em;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border: 0;
|
|
||||||
background-color: transparent;
|
|
||||||
touch-action: manipulation;
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.counter button:hover {
|
|
||||||
background-color: var(--color-bg-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 25%;
|
|
||||||
height: 25%;
|
|
||||||
}
|
|
||||||
|
|
||||||
path {
|
|
||||||
vector-effect: non-scaling-stroke;
|
|
||||||
stroke-width: 2px;
|
|
||||||
stroke: #444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.counter-viewport {
|
|
||||||
width: 8em;
|
|
||||||
height: 4em;
|
|
||||||
overflow: hidden;
|
|
||||||
text-align: center;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.counter-viewport strong {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--color-theme-1);
|
|
||||||
font-size: 4rem;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.counter-digits {
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden {
|
|
||||||
top: -100%;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { resolve } from '$app/paths';
|
|
||||||
import { page } from '$app/state';
|
|
||||||
import github from '$lib/images/github.svg';
|
|
||||||
import logo from '$lib/images/svelte-logo.svg';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<header>
|
|
||||||
<div class="corner">
|
|
||||||
<a href="https://svelte.dev/docs/kit">
|
|
||||||
<img src={logo} alt="SvelteKit" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav>
|
|
||||||
<svg viewBox="0 0 2 3" aria-hidden="true">
|
|
||||||
<path d="M0,0 L1,2 C1.5,3 1.5,3 2,3 L2,0 Z" />
|
|
||||||
</svg>
|
|
||||||
<ul>
|
|
||||||
<li aria-current={page.url.pathname === '/' ? 'page' : undefined}>
|
|
||||||
<a href={resolve('/')}>Home</a>
|
|
||||||
</li>
|
|
||||||
<li aria-current={page.url.pathname === '/about' ? 'page' : undefined}>
|
|
||||||
<a href={resolve('/about')}>About</a>
|
|
||||||
</li>
|
|
||||||
<li aria-current={page.url.pathname.startsWith('/sverdle') ? 'page' : undefined}>
|
|
||||||
<a href={resolve('/sverdle')}>Sverdle</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<svg viewBox="0 0 2 3" aria-hidden="true">
|
|
||||||
<path d="M0,0 L0,3 C0.5,3 0.5,3 1,2 L2,0 Z" />
|
|
||||||
</svg>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="corner">
|
|
||||||
<a href="https://github.com/sveltejs/kit">
|
|
||||||
<img src={github} alt="GitHub" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.corner {
|
|
||||||
width: 3em;
|
|
||||||
height: 3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.corner a {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.corner img {
|
|
||||||
width: 2em;
|
|
||||||
height: 2em;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
--background: rgba(255, 255, 255, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 2em;
|
|
||||||
height: 3em;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
path {
|
|
||||||
fill: var(--background);
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
position: relative;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
height: 3em;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
list-style: none;
|
|
||||||
background: var(--background);
|
|
||||||
background-size: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
position: relative;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
li[aria-current='page']::before {
|
|
||||||
--size: 6px;
|
|
||||||
content: '';
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: calc(50% - var(--size));
|
|
||||||
border: var(--size) solid transparent;
|
|
||||||
border-top: var(--size) solid var(--color-theme-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a {
|
|
||||||
display: flex;
|
|
||||||
height: 100%;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
color: var(--color-text);
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.2s linear;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
color: var(--color-theme-1);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { resolve } from '$app/paths';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>About</title>
|
|
||||||
<meta name="description" content="About this app" />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="text-column">
|
|
||||||
<h1>About this app</h1>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
This is a <a href="https://svelte.dev/docs/kit">SvelteKit</a> app. You can make your own by typing
|
|
||||||
the following into your command line and following the prompts:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<pre>npx sv create</pre>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
The page you're looking at is purely static HTML, with no client-side interactivity needed.
|
|
||||||
Because of that, we don't need to load any JavaScript. Try viewing the page's source, or opening
|
|
||||||
the devtools network panel and reloading.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
The <a href={resolve('/sverdle')}>Sverdle</a> page illustrates SvelteKit's data loading and form handling.
|
|
||||||
Try using it with JavaScript disabled!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { dev } from '$app/environment';
|
|
||||||
|
|
||||||
// we don't need any JS on this page, though we'll load
|
|
||||||
// it in dev so that we get hot module replacement
|
|
||||||
export const csr = dev;
|
|
||||||
|
|
||||||
// since there's no dynamic data here, we can prerender
|
|
||||||
// it so that it gets served as a static asset in production
|
|
||||||
export const prerender = true;
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { resolve } from '$app/paths';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<a href={resolve('/demo/playwright')}>playwright</a>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<h1>Playwright e2e test demo</h1>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
|
||||||
|
|
||||||
test('has expected h1', async ({ page }) => {
|
|
||||||
await page.goto('/demo/playwright');
|
|
||||||
await expect(page.locator('h1')).toBeVisible();
|
|
||||||
});
|
|
||||||
@@ -8,12 +8,7 @@
|
|||||||
Arial, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell,
|
Arial, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell,
|
||||||
'Open Sans', 'Helvetica Neue', sans-serif;
|
'Open Sans', 'Helvetica Neue', sans-serif;
|
||||||
--font-mono: 'Fira Mono', monospace;
|
--font-mono: 'Fira Mono', monospace;
|
||||||
--color-bg-0: rgb(202, 216, 228);
|
--color-text: rgba(255, 255, 255, 0.87);
|
||||||
--color-bg-1: hsl(209, 36%, 86%);
|
|
||||||
--color-bg-2: hsl(224, 44%, 95%);
|
|
||||||
--color-theme-1: #ff3e00;
|
|
||||||
--color-theme-2: #4075a6;
|
|
||||||
--color-text: rgba(0, 0, 0, 0.7);
|
|
||||||
--column-width: 42rem;
|
--column-width: 42rem;
|
||||||
--column-margin-top: 4rem;
|
--column-margin-top: 4rem;
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
@@ -23,12 +18,7 @@
|
|||||||
body {
|
body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background-attachment: fixed;
|
background-color: #0f1117;
|
||||||
background-color: var(--color-bg-1);
|
|
||||||
background-size: 100vw 100vh;
|
|
||||||
background-image:
|
|
||||||
radial-gradient(50% 50% at 50% 50%, rgba(255, 255, 255, 0.75) 0%, rgba(255, 255, 255, 0) 100%),
|
|
||||||
linear-gradient(180deg, var(--color-bg-0) 0%, var(--color-bg-1) 15%, var(--color-bg-2) 50%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
import { fail } from '@sveltejs/kit';
|
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
|
||||||
import { Game } from './game.ts';
|
|
||||||
|
|
||||||
export const load = (({ cookies }) => {
|
|
||||||
const game = new Game(cookies.get('sverdle'));
|
|
||||||
|
|
||||||
return {
|
|
||||||
/**
|
|
||||||
* The player's guessed words so far
|
|
||||||
*/
|
|
||||||
guesses: game.guesses,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An array of strings like '__x_c' corresponding to the guesses, where 'x' means
|
|
||||||
* an exact match, and 'c' means a close match (right letter, wrong place)
|
|
||||||
*/
|
|
||||||
answers: game.answers,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The correct answer, revealed if the game is over
|
|
||||||
*/
|
|
||||||
answer: game.answers.length >= 6 ? game.answer : null
|
|
||||||
};
|
|
||||||
}) satisfies PageServerLoad;
|
|
||||||
|
|
||||||
export const actions = {
|
|
||||||
/**
|
|
||||||
* Modify game state in reaction to a keypress. If client-side JavaScript
|
|
||||||
* is available, this will happen in the browser instead of here
|
|
||||||
*/
|
|
||||||
update: async ({ request, cookies }) => {
|
|
||||||
const game = new Game(cookies.get('sverdle'));
|
|
||||||
|
|
||||||
const data = await request.formData();
|
|
||||||
const key = data.get('key');
|
|
||||||
|
|
||||||
const i = game.answers.length;
|
|
||||||
|
|
||||||
if (key === 'backspace') {
|
|
||||||
game.guesses[i] = game.guesses[i].slice(0, -1);
|
|
||||||
} else {
|
|
||||||
game.guesses[i] += key;
|
|
||||||
}
|
|
||||||
|
|
||||||
cookies.set('sverdle', game.toString(), { path: '/' });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Modify game state in reaction to a guessed word. This logic always runs on
|
|
||||||
* the server, so that people can't cheat by peeking at the JavaScript
|
|
||||||
*/
|
|
||||||
enter: async ({ request, cookies }) => {
|
|
||||||
const game = new Game(cookies.get('sverdle'));
|
|
||||||
|
|
||||||
const data = await request.formData();
|
|
||||||
const guess = data.getAll('guess') as string[];
|
|
||||||
|
|
||||||
if (!game.enter(guess)) {
|
|
||||||
return fail(400, { badGuess: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
cookies.set('sverdle', game.toString(), { path: '/' });
|
|
||||||
},
|
|
||||||
|
|
||||||
restart: async ({ cookies }) => {
|
|
||||||
cookies.delete('sverdle', { path: '/' });
|
|
||||||
}
|
|
||||||
} satisfies Actions;
|
|
||||||
@@ -1,412 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import { resolve } from '$app/paths';
|
|
||||||
import { confetti } from '@neoconfetti/svelte';
|
|
||||||
import { MediaQuery } from 'svelte/reactivity';
|
|
||||||
|
|
||||||
import type { PageProps } from './$types';
|
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
|
||||||
|
|
||||||
/** Whether the user prefers reduced motion */
|
|
||||||
const reducedMotion = new MediaQuery('(prefers-reduced-motion: reduce)');
|
|
||||||
|
|
||||||
let shake = $state(false);
|
|
||||||
|
|
||||||
/** Whether or not the user has won */
|
|
||||||
let won = $derived(data.answers.at(-1) === 'xxxxx');
|
|
||||||
|
|
||||||
/** The index of the current guess */
|
|
||||||
let i = $derived(won ? -1 : data.answers.length);
|
|
||||||
|
|
||||||
/** The current guess */
|
|
||||||
let currentGuess = $derived(data.guesses[i] || '');
|
|
||||||
|
|
||||||
/** Whether the current guess can be submitted */
|
|
||||||
let submittable = $derived(currentGuess.length === 5);
|
|
||||||
|
|
||||||
const { classnames, description } = $derived.by(() => {
|
|
||||||
/**
|
|
||||||
* A map of classnames for all letters that have been guessed,
|
|
||||||
* used for styling the keyboard
|
|
||||||
*/
|
|
||||||
let classnames: Record<string, 'exact' | 'close' | 'missing'> = {};
|
|
||||||
/**
|
|
||||||
* A map of descriptions for all letters that have been guessed,
|
|
||||||
* used for adding text for assistive technology (e.g. screen readers)
|
|
||||||
*/
|
|
||||||
let description: Record<string, string> = {};
|
|
||||||
data.answers.forEach((answer, i) => {
|
|
||||||
const guess = data.guesses[i];
|
|
||||||
for (let i = 0; i < 5; i += 1) {
|
|
||||||
const letter = guess[i];
|
|
||||||
if (answer[i] === 'x') {
|
|
||||||
classnames[letter] = 'exact';
|
|
||||||
description[letter] = 'correct';
|
|
||||||
} else if (!classnames[letter]) {
|
|
||||||
classnames[letter] = answer[i] === 'c' ? 'close' : 'missing';
|
|
||||||
description[letter] = answer[i] === 'c' ? 'present' : 'absent';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return { classnames, description };
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Modify the game state without making a trip to the server,
|
|
||||||
* if client-side JavaScript is enabled
|
|
||||||
*/
|
|
||||||
function update(event: MouseEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
const key = (event.target as HTMLButtonElement).getAttribute(
|
|
||||||
'data-key'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (key === 'backspace') {
|
|
||||||
currentGuess = currentGuess.slice(0, -1);
|
|
||||||
shake = false;
|
|
||||||
} else if (currentGuess.length < 5) {
|
|
||||||
currentGuess += key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trigger form logic in response to a keydown event, so that
|
|
||||||
* desktop users can use the keyboard to play the game
|
|
||||||
*/
|
|
||||||
function keydown(event: KeyboardEvent) {
|
|
||||||
if (event.metaKey) return;
|
|
||||||
|
|
||||||
if (event.key === 'Enter' && !submittable) return;
|
|
||||||
|
|
||||||
document
|
|
||||||
.querySelector(`[data-key="${event.key}" i]`)
|
|
||||||
?.dispatchEvent(new MouseEvent('click', { cancelable: true, bubbles: true }));
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:window onkeydown={keydown} />
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Sverdle</title>
|
|
||||||
<meta name="description" content="A Wordle clone written in SvelteKit" />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<h1 class="visually-hidden">Sverdle</h1>
|
|
||||||
|
|
||||||
<form
|
|
||||||
method="post"
|
|
||||||
action="?/enter"
|
|
||||||
use:enhance={() => {
|
|
||||||
// prevent default callback from resetting the form
|
|
||||||
return ({ result, update }) => {
|
|
||||||
shake = result.type === 'failure';
|
|
||||||
update({ reset: false });
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<a class="how-to-play" href={resolve('/sverdle/how-to-play')}>How to play</a>
|
|
||||||
|
|
||||||
<div class="grid" class:playing={!won} class:shake onanimationend={() => (shake = false)}>
|
|
||||||
{#each Array.from(Array(6).keys()) as row (row)}
|
|
||||||
{@const current = row === i}
|
|
||||||
<h2 class="visually-hidden">Row {row + 1}</h2>
|
|
||||||
<div class="row" class:current>
|
|
||||||
{#each Array.from(Array(5).keys()) as column (column)}
|
|
||||||
{@const guess = current ? currentGuess : data.guesses[row]}
|
|
||||||
{@const answer = data.answers[row]?.[column]}
|
|
||||||
{@const value = guess?.[column] ?? ''}
|
|
||||||
{@const selected = current && column === guess.length}
|
|
||||||
{@const exact = answer === 'x'}
|
|
||||||
{@const close = answer === 'c'}
|
|
||||||
{@const missing = answer === '_'}
|
|
||||||
<div class="letter" class:exact class:close class:missing class:selected>
|
|
||||||
{value}
|
|
||||||
<span class="visually-hidden">
|
|
||||||
{#if exact}
|
|
||||||
(correct)
|
|
||||||
{:else if close}
|
|
||||||
(present)
|
|
||||||
{:else if missing}
|
|
||||||
(absent)
|
|
||||||
{:else}
|
|
||||||
empty
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
<input name="guess" disabled={!current} type="hidden" {value} />
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="controls">
|
|
||||||
{#if won || data.answers.length >= 6}
|
|
||||||
{#if !won && data.answer}
|
|
||||||
<p>the answer was "{data.answer}"</p>
|
|
||||||
{/if}
|
|
||||||
<button data-key="enter" class="restart selected" formaction="?/restart">
|
|
||||||
{won ? 'you won :)' : `game over :(`} play again?
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<div class="keyboard">
|
|
||||||
<button data-key="enter" class:selected={submittable} disabled={!submittable}>enter</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onclick={update}
|
|
||||||
data-key="backspace"
|
|
||||||
formaction="?/update"
|
|
||||||
name="key"
|
|
||||||
value="backspace"
|
|
||||||
>
|
|
||||||
back
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#each ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'] as row (row)}
|
|
||||||
<div class="row">
|
|
||||||
{#each row as letter, index (index)}
|
|
||||||
<button
|
|
||||||
onclick={update}
|
|
||||||
data-key={letter}
|
|
||||||
class={classnames[letter]}
|
|
||||||
disabled={submittable}
|
|
||||||
formaction="?/update"
|
|
||||||
name="key"
|
|
||||||
value={letter}
|
|
||||||
aria-label="{letter} {description[letter] || ''}"
|
|
||||||
>
|
|
||||||
{letter}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{#if won}
|
|
||||||
<div
|
|
||||||
style="position: absolute; left: 50%; top: 30%"
|
|
||||||
use:confetti={{
|
|
||||||
particleCount: reducedMotion.current ? 0 : undefined,
|
|
||||||
force: 0.7,
|
|
||||||
stageWidth: window.innerWidth,
|
|
||||||
stageHeight: window.innerHeight,
|
|
||||||
colors: ['#ff3e00', '#40b3ff', '#676778']
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
form {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 1rem;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-to-play {
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-to-play::before {
|
|
||||||
content: 'i';
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 0.8em;
|
|
||||||
font-weight: 900;
|
|
||||||
width: 1em;
|
|
||||||
height: 1em;
|
|
||||||
padding: 0.2em;
|
|
||||||
line-height: 1;
|
|
||||||
border: 1.5px solid var(--color-text);
|
|
||||||
border-radius: 50%;
|
|
||||||
text-align: center;
|
|
||||||
margin: 0 0.5em 0 0;
|
|
||||||
position: relative;
|
|
||||||
top: -0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
--width: min(100vw, 40vh, 380px);
|
|
||||||
max-width: var(--width);
|
|
||||||
align-self: center;
|
|
||||||
justify-self: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid .row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(5, 1fr);
|
|
||||||
grid-gap: 0.2rem;
|
|
||||||
margin: 0 0 0.2rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
.grid.shake .row.current {
|
|
||||||
animation: wiggle 0.5s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid.playing .row.current {
|
|
||||||
filter: drop-shadow(3px 3px 10px var(--color-bg-0));
|
|
||||||
}
|
|
||||||
|
|
||||||
.letter {
|
|
||||||
aspect-ratio: 1;
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
text-align: center;
|
|
||||||
box-sizing: border-box;
|
|
||||||
text-transform: lowercase;
|
|
||||||
border: none;
|
|
||||||
font-size: calc(0.08 * var(--width));
|
|
||||||
border-radius: 2px;
|
|
||||||
background: white;
|
|
||||||
margin: 0;
|
|
||||||
color: rgba(0, 0, 0, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.letter.missing {
|
|
||||||
background: rgba(255, 255, 255, 0.5);
|
|
||||||
color: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.letter.exact {
|
|
||||||
background: var(--color-theme-2);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.letter.close {
|
|
||||||
border: 2px solid var(--color-theme-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected {
|
|
||||||
outline: 2px solid var(--color-theme-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
text-align: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: min(18vh, 10rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard {
|
|
||||||
--gap: 0.2rem;
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--gap);
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard .row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.2rem;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard button,
|
|
||||||
.keyboard button:disabled {
|
|
||||||
--size: min(8vw, 4vh, 40px);
|
|
||||||
background-color: white;
|
|
||||||
color: black;
|
|
||||||
width: var(--size);
|
|
||||||
border: none;
|
|
||||||
border-radius: 2px;
|
|
||||||
font-size: calc(var(--size) * 0.5);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard button.exact {
|
|
||||||
background: var(--color-theme-2);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard button.missing {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard button.close {
|
|
||||||
border: 2px solid var(--color-theme-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard button:focus {
|
|
||||||
background: var(--color-theme-1);
|
|
||||||
color: white;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard button[data-key='enter'],
|
|
||||||
.keyboard button[data-key='backspace'] {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
width: calc(1.5 * var(--size));
|
|
||||||
height: calc(1 / 3 * (100% - 2 * var(--gap)));
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-size: calc(0.3 * var(--size));
|
|
||||||
padding-top: calc(0.15 * var(--size));
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard button[data-key='enter'] {
|
|
||||||
right: calc(50% + 3.5 * var(--size) + 0.8rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard button[data-key='backspace'] {
|
|
||||||
left: calc(50% + 3.5 * var(--size) + 0.8rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard button[data-key='enter']:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.restart {
|
|
||||||
width: 100%;
|
|
||||||
padding: 1rem;
|
|
||||||
background: rgba(255, 255, 255, 0.5);
|
|
||||||
border-radius: 2px;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.restart:focus,
|
|
||||||
.restart:hover {
|
|
||||||
background: var(--color-theme-1);
|
|
||||||
color: white;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes wiggle {
|
|
||||||
0% {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
10% {
|
|
||||||
transform: translateX(-2px);
|
|
||||||
}
|
|
||||||
30% {
|
|
||||||
transform: translateX(4px);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translateX(-6px);
|
|
||||||
}
|
|
||||||
70% {
|
|
||||||
transform: translateX(+4px);
|
|
||||||
}
|
|
||||||
90% {
|
|
||||||
transform: translateX(-2px);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { allowed, words } from './words.server.ts';
|
|
||||||
|
|
||||||
export class Game {
|
|
||||||
index: number;
|
|
||||||
guesses: string[];
|
|
||||||
answers: string[];
|
|
||||||
answer: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a game object from the player's cookie, or initialise a new game
|
|
||||||
*/
|
|
||||||
constructor(serialized: string | undefined = undefined) {
|
|
||||||
if (serialized) {
|
|
||||||
const [index, guesses, answers] = serialized.split('-');
|
|
||||||
|
|
||||||
this.index = +index;
|
|
||||||
this.guesses = guesses ? guesses.split(' ') : [];
|
|
||||||
this.answers = answers ? answers.split(' ') : [];
|
|
||||||
} else {
|
|
||||||
this.index = Math.floor(Math.random() * words.length);
|
|
||||||
this.guesses = ['', '', '', '', '', ''];
|
|
||||||
this.answers = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
this.answer = words[this.index];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update game state based on a guess of a five-letter word. Returns
|
|
||||||
* true if the guess was valid, false otherwise
|
|
||||||
*/
|
|
||||||
enter(letters: string[]) {
|
|
||||||
const word = letters.join('');
|
|
||||||
const valid = allowed.has(word);
|
|
||||||
|
|
||||||
if (!valid) return false;
|
|
||||||
|
|
||||||
this.guesses[this.answers.length] = word;
|
|
||||||
|
|
||||||
const available = Array.from(this.answer);
|
|
||||||
const answer = Array(5).fill('_');
|
|
||||||
|
|
||||||
// first, find exact matches
|
|
||||||
for (let i = 0; i < 5; i += 1) {
|
|
||||||
if (letters[i] === available[i]) {
|
|
||||||
answer[i] = 'x';
|
|
||||||
available[i] = ' ';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// then find close matches (this has to happen
|
|
||||||
// in a second step, otherwise an early close
|
|
||||||
// match can prevent a later exact match)
|
|
||||||
for (let i = 0; i < 5; i += 1) {
|
|
||||||
if (answer[i] === '_') {
|
|
||||||
const index = available.indexOf(letters[i]);
|
|
||||||
if (index !== -1) {
|
|
||||||
answer[i] = 'c';
|
|
||||||
available[index] = ' ';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.answers.push(answer.join(''));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serialize game state so it can be set as a cookie
|
|
||||||
*/
|
|
||||||
toString() {
|
|
||||||
return `${this.index}-${this.guesses.join(' ')}-${this.answers.join(' ')}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
<svelte:head>
|
|
||||||
<title>How to play Sverdle</title>
|
|
||||||
<meta name="description" content="How to play Sverdle" />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="text-column">
|
|
||||||
<h1>How to play Sverdle</h1>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Sverdle is a clone of <a href="https://www.nytimes.com/games/wordle/index.html">Wordle</a>, the
|
|
||||||
word guessing game. To play, enter a five-letter English word. For example:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="example">
|
|
||||||
<span class="close">r</span>
|
|
||||||
<span class="missing">i</span>
|
|
||||||
<span class="close">t</span>
|
|
||||||
<span class="missing">z</span>
|
|
||||||
<span class="exact">y</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
The <span class="exact">y</span> is in the right place. <span class="close">r</span> and
|
|
||||||
<span class="close">t</span>
|
|
||||||
are the right letters, but in the wrong place. The other letters are wrong, and can be discarded.
|
|
||||||
Let's make another guess:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="example">
|
|
||||||
<span class="exact">p</span>
|
|
||||||
<span class="exact">a</span>
|
|
||||||
<span class="exact">r</span>
|
|
||||||
<span class="exact">t</span>
|
|
||||||
<span class="exact">y</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>This time we guessed right! You have <strong>six</strong> guesses to get the word.</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Unlike the original Wordle, Sverdle runs on the server instead of in the browser, making it
|
|
||||||
impossible to cheat. It uses <code><form></code> and cookies to submit data, meaning you can
|
|
||||||
even play with JavaScript disabled!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
span {
|
|
||||||
display: inline-flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 0.8em;
|
|
||||||
width: 2.4em;
|
|
||||||
height: 2.4em;
|
|
||||||
background-color: white;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border-radius: 2px;
|
|
||||||
border-width: 2px;
|
|
||||||
color: rgba(0, 0, 0, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.missing {
|
|
||||||
background: rgba(255, 255, 255, 0.5);
|
|
||||||
color: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.close {
|
|
||||||
border-style: solid;
|
|
||||||
border-color: var(--color-theme-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.exact {
|
|
||||||
background: var(--color-theme-2);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.example {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-start;
|
|
||||||
margin: 1rem 0;
|
|
||||||
gap: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.example span {
|
|
||||||
font-size: 1.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
p span {
|
|
||||||
position: relative;
|
|
||||||
border-width: 1px;
|
|
||||||
border-radius: 1px;
|
|
||||||
font-size: 0.4em;
|
|
||||||
transform: scale(2) translate(0, -10%);
|
|
||||||
margin: 0 1em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { dev } from '$app/environment';
|
|
||||||
|
|
||||||
// we don't need any JS on this page, though we'll load
|
|
||||||
// it in dev so that we get hot module replacement
|
|
||||||
export const csr = dev;
|
|
||||||
|
|
||||||
// since there's no dynamic data here, we can prerender
|
|
||||||
// it so that it gets served as a static asset in production
|
|
||||||
export const prerender = true;
|
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user