Browse Source

new leader and history routes

master
nicolas-arnaud 2 years ago
parent
commit
dae40b3630
  1. 19
      README.md
  2. 12
      back/volume/src/pong/entity/result.entity.ts
  3. 5
      back/volume/src/pong/game/Game.ts
  4. 7
      back/volume/src/pong/game/Games.ts
  5. 4
      back/volume/src/pong/pong.gateway.ts
  6. 11
      back/volume/src/pong/pong.module.ts
  7. 56
      back/volume/src/pong/pong.service.ts
  8. 1
      back/volume/src/users/dto/user.dto.ts
  9. 11
      back/volume/src/users/entity/user.entity.ts
  10. 65
      back/volume/src/users/users.controller.ts
  11. 9
      back/volume/src/users/users.module.ts
  12. 65
      back/volume/src/users/users.service.ts
  13. 2
      front/volume/src/App.svelte
  14. 2
      front/volume/src/components/Pong/Pong.svelte
  15. 10
      front/volume/src/components/Profile.svelte

19
README.md

@ -23,14 +23,21 @@ rename .env_sample to .env and customize it to your needs and credentials.
|GET |/log/out |log out user.|☑| |GET |/log/out |log out user.|☑|
|GET |/all |return all users publics datas.|☒| |GET |/all |return all users publics datas.|☒|
|GET |/online |return all online users's public datas.|☒| |GET |/online |return all online users's public datas.|☒|
|GET |/:id |return ftId: id's public datas|☒|
|GET |/ |return connected user public datas|☑|
|POST|/ |update user datas.|☑|
|GET |/friends |return users which are friends.|☑| |GET |/friends |return users which are friends.|☑|
|GET |/invits |return users which invited user to be friend.|☑| |GET |/invits |return users which invited user to be friend.|☑|
|POST|/invit/:id |invit user whith ftId: id as friend.|☑| |GET |/leader |return the global leaderboard|☑|
|GET |/avatar |return the user avatar|☒| |GET |/leader/:id |return the user(id) place in leaderboard|☑|
|POST|/avatar |set a user avatar with multipart post upload.|☑| |GET |/history |return the matchs results sorted by date|☑|
|GET |/history/:id |return the last user(id)'s results sorted by date|☑|
|POST|/avatar |set a user() avatar with multipart post upload.|☑|
|GET |/avatar |return the user() avatar|☒|
|GET |/user/:name |return the user(name)|☒|
|POST|/invit/:id |user() invit user(id) as friend.|☑|
|GET |/avatar/:id |return the user(id)'s avatar|☒|
|GET |/:id |return user(id) public datas|☒|
|POST|/:id |update/create user(id)|☑|
|GET |/ |return user()' public datas|☑|
|POST|/ |update/create user()|☑|
## Dependencies: ## Dependencies:

12
back/volume/src/pong/entity/result.entity.ts

@ -3,6 +3,7 @@ import {
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
Column, Column,
ManyToMany, ManyToMany,
CreateDateColumn
} from 'typeorm' } from 'typeorm'
import User from 'src/users/entity/user.entity' import User from 'src/users/entity/user.entity'
@ -10,11 +11,14 @@ import User from 'src/users/entity/user.entity'
@Entity() @Entity()
export default class Result { export default class Result {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id:number id: number
@ManyToMany(() => User, (player: User) => player.results) @ManyToMany(() => User, (player: User) => player.results)
players: (User | null)[] // TODO: change to User[] for final version players: Array<User | null> // TODO: change to User[] for final version
@Column('text', {array: true}) @Column('text', { array: true })
public score: number[] public score: number[]
@CreateDateColumn()
date: Date
} }

5
back/volume/src/pong/game/Game.ts

@ -15,7 +15,7 @@ import { Spectator } from './Spectator'
import { type MapDtoValidated } from '../dtos/MapDtoValidated' import { type MapDtoValidated } from '../dtos/MapDtoValidated'
import { type GameUpdate } from '../dtos/GameUpdate' import { type GameUpdate } from '../dtos/GameUpdate'
import { type GameInfo } from '../dtos/GameInfo' import { type GameInfo } from '../dtos/GameInfo'
import { PongService } from '../pong.service' import { type PongService } from '../pong.service'
import { Injectable, Inject } from '@nestjs/common' import { Injectable, Inject } from '@nestjs/common'
function gameLoop (game: Game): void { function gameLoop (game: Game): void {
@ -67,7 +67,6 @@ export class Game {
map: MapDtoValidated, map: MapDtoValidated,
gameStoppedCallback: (name: string) => void, gameStoppedCallback: (name: string) => void,
private readonly pongService: PongService private readonly pongService: PongService
) { ) {
this.id = randomUUID() this.id = randomUUID()
this.timer = null this.timer = null
@ -149,7 +148,7 @@ export class Game {
async stop (): Promise<void> { async stop (): Promise<void> {
if (this.timer !== null) { if (this.timer !== null) {
await this.pongService.saveResult(this.players) await this.pongService.saveResult(this.players)
this.gameStoppedCallback(this.players[0].name) this.gameStoppedCallback(this.players[0].name)
clearInterval(this.timer) clearInterval(this.timer)

7
back/volume/src/pong/game/Games.ts

@ -4,7 +4,7 @@ import { Point } from './utils'
import { type MapDtoValidated as GameMap } from '../dtos/MapDtoValidated' import { type MapDtoValidated as GameMap } from '../dtos/MapDtoValidated'
import { type GameCreationDtoValidated } from '../dtos/GameCreationDtoValidated' import { type GameCreationDtoValidated } from '../dtos/GameCreationDtoValidated'
import { type GameInfo } from '../dtos/GameInfo' import { type GameInfo } from '../dtos/GameInfo'
import { PongService } from '../pong.service' import { type PongService } from '../pong.service'
import { import {
DEFAULT_BALL_SIZE, DEFAULT_BALL_SIZE,
DEFAULT_PADDLE_SIZE, DEFAULT_PADDLE_SIZE,
@ -12,9 +12,8 @@ import {
DEFAULT_WIN_SCORE DEFAULT_WIN_SCORE
} from './constants' } from './constants'
export class Games { export class Games {
constructor (private readonly pongService : PongService) {} constructor (private readonly pongService: PongService) {}
private readonly playerNameToGameIndex = new Map<string, number>() private readonly playerNameToGameIndex = new Map<string, number>()
private readonly games = new Array<Game>() private readonly games = new Array<Game>()
@ -33,7 +32,7 @@ export class Games {
names, names,
map, map,
this.gameStopped.bind(this, names[0]), this.gameStopped.bind(this, names[0]),
this.pongService, this.pongService
) )
) )
this.playerNameToGameIndex.set(names[0], this.games.length - 1) this.playerNameToGameIndex.set(names[0], this.games.length - 1)

4
back/volume/src/pong/pong.gateway.ts

@ -28,9 +28,7 @@ interface WebSocketWithId extends WebSocket {
@WebSocketGateway() @WebSocketGateway()
export class PongGateway implements OnGatewayConnection, OnGatewayDisconnect { export class PongGateway implements OnGatewayConnection, OnGatewayDisconnect {
constructor( constructor (private readonly pongService: PongService) {}
private readonly pongService: PongService
) {}
private readonly games: Games = new Games(this.pongService) private readonly games: Games = new Games(this.pongService)
private readonly socketToPlayerName = new Map<WebSocketWithId, string>() private readonly socketToPlayerName = new Map<WebSocketWithId, string>()

11
back/volume/src/pong/pong.module.ts

@ -1,15 +1,14 @@
import { Module } from '@nestjs/common' import { forwardRef, Module } from '@nestjs/common'
import { PongGateway } from './pong.gateway' import { PongGateway } from './pong.gateway'
import Result from './entity/result.entity' import Result from './entity/result.entity'
import {TypeOrmModule } from '@nestjs/typeorm' import { TypeOrmModule } from '@nestjs/typeorm'
import {PongService } from './pong.service' import { PongService } from './pong.service'
import { UsersModule } from 'src/users/users.module' import { UsersModule } from 'src/users/users.module'
@Module({ @Module({
imports: [ imports: [
UsersModule, forwardRef(() => UsersModule),
TypeOrmModule.forFeature([Result]) TypeOrmModule.forFeature([Result])],
],
providers: [PongGateway, PongService], providers: [PongGateway, PongService],
exports: [PongService] exports: [PongService]
}) })

56
back/volume/src/pong/pong.service.ts

@ -1,40 +1,52 @@
import { Inject, Injectable } from "@nestjs/common"; import { Inject, Injectable, forwardRef } from '@nestjs/common'
import { InjectRepository } from "@nestjs/typeorm"; import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm' import { Repository } from 'typeorm'
import { UsersService } from 'src/users/users.service' import { UsersService } from 'src/users/users.service'
import Result from './entity/result.entity' import Result from './entity/result.entity'
import User from 'src/users/entity/user.entity' import type User from 'src/users/entity/user.entity'
import { Player } from './game/Player' import { type Player } from './game/Player'
@Injectable() @Injectable()
export class PongService { export class PongService {
constructor( constructor (
@InjectRepository(Result)private readonly resultsRepository: Repository<Result>, @InjectRepository(Result)
private readonly resultsRepository: Repository<Result>,
private readonly usersService: UsersService private readonly usersService: UsersService
) { } ) {}
async updatePlayer(i: number, result: Result) { async updatePlayer (i: number, result: Result) {
let player: User | null = result.players[i] const player: User | null = result.players[i]
if (!player) return if (player == null) return
player.matchs++ player.matchs++
if (result.score[i] > result.score[Math.abs(i-1)]) if (result.score[i] > result.score[Math.abs(i - 1)]) player.wins++
player.wins++; else player.looses++
else player.winrate = (100 * player.wins) / player.matchs
player.looses++;
player.results.push(result) player.results.push(result)
this.usersService.save(player) this.usersService.save(player)
} }
async saveResult(players: Player[]) { async saveResult (players: Player[]) {
let result = new Result; const result = new Result()
result.players = await Promise.all(players.map(async (p): Promise<User | null> => { result.players = await Promise.all(
return await this.usersService.findUserByName(p.name) players.map(async (p): Promise<User | null> => {
})) return await this.usersService.findUserByName(p.name)
result.score = players.map((p) => p.score); })
)
result.score = players.map((p) => p.score)
this.updatePlayer(0, result) this.updatePlayer(0, result)
this.updatePlayer(1, result) this.updatePlayer(1, result)
this.resultsRepository.save(result) this.resultsRepository.save(result)
} }
async getHistory (): Promise<Result[]> {
return await this.resultsRepository.find({
order: { date: 'DESC' }
})
}
async getHistoryById (ftId: number): Promise<Result[]> {
const results = await this.usersService.getResults(ftId)
return results.sort((a, b) => (a.date < b.date ? 1 : -1))
}
} }

1
back/volume/src/users/dto/user.dto.ts

@ -14,7 +14,6 @@ export class UserDto {
@IsOptional() @IsOptional()
readonly status: string readonly status: string
} }
export class AvatarUploadDto { export class AvatarUploadDto {

11
back/volume/src/users/entity/user.entity.ts

@ -26,17 +26,20 @@ export class User {
status: string status: string
@Column({ name: 'avatar' }) @Column({ name: 'avatar' })
public avatar?: string avatar: string
@Column({default: 0}) @Column({ default: 0 })
wins: number wins: number
@Column({default: 0}) @Column({ default: 0 })
looses: number looses: number
@Column({default: 0}) @Column({ default: 0 })
matchs: number matchs: number
@Column({ default: 0 })
winrate: number
@ManyToMany(() => Result, (result: Result) => result.players) @ManyToMany(() => Result, (result: Result) => result.players)
@JoinTable() @JoinTable()
results: Result[] results: Result[]

65
back/volume/src/users/users.controller.ts

@ -20,6 +20,7 @@ import { diskStorage } from 'multer'
import { type User } from './entity/user.entity' import { type User } from './entity/user.entity'
import { UsersService } from './users.service' import { UsersService } from './users.service'
import { UserDto, AvatarUploadDto } from './dto/user.dto' import { UserDto, AvatarUploadDto } from './dto/user.dto'
import { PongService } from 'src/pong/pong.service'
import { AuthenticatedGuard } from 'src/auth/42-auth.guard' import { AuthenticatedGuard } from 'src/auth/42-auth.guard'
import { FtUser } from 'src/auth/42.decorator' import { FtUser } from 'src/auth/42.decorator'
@ -32,30 +33,58 @@ import { join } from 'path'
@Controller() @Controller()
export class UsersController { export class UsersController {
constructor (private readonly usersService: UsersService) {} constructor(
private readonly usersService: UsersService,
private readonly pongService: PongService
) { }
@Get('all') @Get('all')
async getAllUsers (): Promise<User[]> { async getAllUsers(): Promise<User[]> {
return await this.usersService.findUsers() return await this.usersService.findUsers()
} }
@Get('online') @Get('online')
async getOnlineUsers (): Promise<User[]> { async getOnlineUsers(): Promise<User[]> {
return await this.usersService.findOnlineUsers() return await this.usersService.findOnlineUsers()
} }
@Get('friends') @Get('friends')
@UseGuards(AuthenticatedGuard) @UseGuards(AuthenticatedGuard)
async getFriends (@FtUser() profile: Profile) { async getFriends(@FtUser() profile: Profile) {
return await this.usersService.getFriends(profile.id) return await this.usersService.getFriends(profile.id)
} }
@Get('invits') @Get('invits')
@UseGuards(AuthenticatedGuard) @UseGuards(AuthenticatedGuard)
async getInvits (@FtUser() profile: Profile) { async getInvits(@FtUser() profile: Profile) {
return await this.usersService.getInvits(profile.id) return await this.usersService.getInvits(profile.id)
} }
@Get('leader')
@UseGuards(AuthenticatedGuard)
async getLeader() {
return await this.usersService.getLeader()
}
@Get('leader/:id')
@UseGuards(AuthenticatedGuard)
async getRank(@Param('id', ParseIntPipe) id: number) {
return await this.usersService.getRank(id)
}
@Get('history')
@UseGuards(AuthenticatedGuard)
async getHistory() {
return await this.pongService.getHistory()
}
@Get('history/:id')
@UseGuards(AuthenticatedGuard)
async getHistoryById(@Param('id', ParseIntPipe) id: number) {
return this.pongService.getHistoryById(id)
}
@Post('avatar') @Post('avatar')
@UseGuards(AuthenticatedGuard) @UseGuards(AuthenticatedGuard)
@Redirect('http://localhost') @Redirect('http://localhost')
@ -78,8 +107,8 @@ export class UsersController {
description: 'A new avatar for the user', description: 'A new avatar for the user',
type: AvatarUploadDto type: AvatarUploadDto
}) })
async addAvatar ( async addAvatar(
@FtUser() profile: Profile, @FtUser() profile: Profile,
@UploadedFile() file: Express.Multer.File @UploadedFile() file: Express.Multer.File
) { ) {
await this.usersService.addAvatar(profile.id, file.filename) await this.usersService.addAvatar(profile.id, file.filename)
@ -87,30 +116,30 @@ export class UsersController {
@Get('avatar') @Get('avatar')
@UseGuards(AuthenticatedGuard) @UseGuards(AuthenticatedGuard)
async getAvatar ( async getAvatar(
@FtUser() profile: Profile, @FtUser() profile: Profile,
@Res({ passthrough: true }) response: Response @Res({ passthrough: true }) response: Response
) { ) {
return await this.getAvatarById(profile.id, response) return await this.getAvatarById(profile.id, response)
} }
@Get('user/:name') @Get('user/:name')
async getUserByName (@Param('name') username: string): Promise<User | null> { async getUserByName(@Param('name') username: string): Promise<User | null> {
return await this.usersService.findUserByName(username) return await this.usersService.findUserByName(username)
} }
@Get('invit/:id') @Get('invit/:id')
@UseGuards(AuthenticatedGuard) @UseGuards(AuthenticatedGuard)
async invitUser ( async invitUser(
@FtUser() profile: Profile, @FtUser() profile: Profile,
@Param('id', ParseIntPipe) id: number @Param('id', ParseIntPipe) id: number
) { ) {
return await this.usersService.invit(profile.id, id) return await this.usersService.invit(profile.id, id)
} }
@Get('avatar/:id') @Get('avatar/:id')
async getAvatarById ( async getAvatarById(
@Param('id', ParseIntPipe) ftId: number, @Param('id', ParseIntPipe) ftId: number,
@Res({ passthrough: true }) response: Response @Res({ passthrough: true }) response: Response
) { ) {
const user = await this.usersService.findUser(ftId) const user = await this.usersService.findUser(ftId)
@ -125,7 +154,7 @@ export class UsersController {
} }
@Get(':id') @Get(':id')
async getUserById ( async getUserById(
@Param('id', ParseIntPipe) ftId: number @Param('id', ParseIntPipe) ftId: number
): Promise<User | null> { ): Promise<User | null> {
return await this.usersService.findUser(ftId) return await this.usersService.findUser(ftId)
@ -133,7 +162,7 @@ export class UsersController {
@Post(":id") @Post(":id")
@UseGuards(AuthenticatedGuard) @UseGuards(AuthenticatedGuard)
async createById (@Body() payload: UserDto) { async createById(@Body() payload: UserDto) {
const user = await this.usersService.findUser(payload.ftId) const user = await this.usersService.findUser(payload.ftId)
if (user != null) { if (user != null) {
return await this.usersService.update(user, payload) return await this.usersService.update(user, payload)
@ -144,13 +173,13 @@ export class UsersController {
@Get() @Get()
@UseGuards(AuthenticatedGuard) @UseGuards(AuthenticatedGuard)
async getUser (@FtUser() profile: Profile): Promise<User | null> { async getUser(@FtUser() profile: Profile): Promise<User | null> {
return await this.usersService.findUser(profile.id) return await this.usersService.findUser(profile.id)
} }
@Post() @Post()
@UseGuards(AuthenticatedGuard) @UseGuards(AuthenticatedGuard)
async create (@Body() payload: UserDto, @FtUser() profile: Profile) { async create(@Body() payload: UserDto, @FtUser() profile: Profile) {
const user = await this.usersService.findUser(profile.id) const user = await this.usersService.findUser(profile.id)
if (user != null) { if (user != null) {
return await this.usersService.update(user, payload) return await this.usersService.update(user, payload)

9
back/volume/src/users/users.module.ts

@ -1,13 +1,16 @@
import { Module } from '@nestjs/common' import { forwardRef, Module } from '@nestjs/common'
import { TypeOrmModule } from '@nestjs/typeorm' import { TypeOrmModule } from '@nestjs/typeorm'
import { User } from './entity/user.entity' import { User } from './entity/user.entity'
import { UsersController } from './users.controller' import { UsersController } from './users.controller'
import { UsersService } from './users.service' import { UsersService } from './users.service'
import { PongModule } from 'src/pong/pong.module'
@Module({ @Module({
imports: [TypeOrmModule.forFeature([User])], imports: [
forwardRef(() => PongModule),
TypeOrmModule.forFeature([User])],
controllers: [UsersController], controllers: [UsersController],
providers: [UsersService], providers: [UsersService],
exports: [UsersService] exports: [UsersService]
}) })
export class UsersModule {} export class UsersModule { }

65
back/volume/src/users/users.service.ts

@ -4,14 +4,15 @@ import { Repository } from 'typeorm'
import { User } from './entity/user.entity' import { User } from './entity/user.entity'
import { type UserDto } from './dto/user.dto' import { type UserDto } from './dto/user.dto'
import { type Channel } from 'src/chat/entity/channel.entity' import { type Channel } from 'src/chat/entity/channel.entity'
import Result from 'src/pong/entity/result.entity'
@Injectable() @Injectable()
export class UsersService { export class UsersService {
constructor ( constructor (
@InjectRepository(User) private readonly usersRepository: Repository<User> @InjectRepository(User) private readonly usersRepository: Repository<User>,
) {} ) {}
save(user: User) { save (user: User) {
this.usersRepository.save(user) this.usersRepository.save(user)
} }
@ -20,13 +21,12 @@ export class UsersService {
} }
async findUserByName (username: string): Promise<User | null> { async findUserByName (username: string): Promise<User | null> {
let user = await this.usersRepository.findOne({ const user = await this.usersRepository.findOne({
where: { username: username }, where: { username },
relations : {results: true} relations: { results: true }
}) })
if (!user) return null; if (user == null) return null
else return user; else return user
} }
async findUser (ftId: number): Promise<User | null> { async findUser (ftId: number): Promise<User | null> {
@ -75,19 +75,43 @@ export class UsersService {
friends: true friends: true
} }
}) })
if (user == null) return [] if (user != null) return user.friends
return user.friends return []
} }
async getInvits (ftId: number) { async getInvits (ftId: number): Promise<User[]> {
const user = await this.usersRepository.findOne({ const user = await this.usersRepository.findOne({
where: { ftId }, where: { ftId },
relations: { relations: {
followers: true followers: true
} }
}) })
if (user == null) return null if (user != null) return user.followers
return user.followers return []
}
async getResults (ftId: number): Promise<Result[]> {
const user = await this.usersRepository.findOne({
where: { ftId },
relations: {
results: true
}
})
if (user != null) return user.results
return []
}
async getLeader (): Promise<User[]> {
return await this.usersRepository.find({
order: {
winrate: 'DESC'
}
})
}
async getRank (ftId: number): Promise<number> {
const leader = await this.getLeader()
return leader.findIndex((user) => user.ftId == ftId)
} }
async invit (ftId: number, targetFtId: number) { async invit (ftId: number, targetFtId: number) {
@ -95,18 +119,20 @@ export class UsersService {
where: { ftId }, where: { ftId },
relations: { relations: {
followers: true, followers: true,
friends: true, friends: true
} }
}) })
if (user == null) return null if (user == null) {
if (user.friends.findIndex( return new NotFoundException(`Error: user id ${ftId} isn't in our db.`)
(friend) => friend.ftId === targetFtId) != -1) }
if (user.friends.findIndex((friend) => friend.ftId === targetFtId) != -1) {
return null return null
}
const target = await this.usersRepository.findOne({ const target = await this.usersRepository.findOne({
where: { ftId: targetFtId }, where: { ftId: targetFtId },
relations: { relations: {
followers: true, followers: true,
friends: true, friends: true
} }
}) })
if (target == null) { if (target == null) {
@ -122,8 +148,7 @@ export class UsersService {
`Friend relation complete between ${user.username} and ${target.username}` `Friend relation complete between ${user.username} and ${target.username}`
) )
user.friends.push(target) user.friends.push(target)
if (user != target) if (user != target) target.friends.push(user)
target.friends.push(user)
user.followers.slice(id, 1) user.followers.slice(id, 1)
this.usersRepository.save(user) this.usersRepository.save(user)
} else { } else {

2
front/volume/src/App.svelte

@ -169,7 +169,7 @@
username={$store.username} username={$store.username}
wins={$store.wins} wins={$store.wins}
losses={$store.looses} losses={$store.looses}
winrate={$store.matchs? $store.wins / $store.matchs : 0} winrate={$store.winrate}
rank={23} rank={23}
is2faEnabled={false} is2faEnabled={false}
/> />

2
front/volume/src/components/Pong/Pong.svelte

@ -8,7 +8,7 @@
import SpectateFriend from "./SpectateFriend.svelte"; import SpectateFriend from "./SpectateFriend.svelte";
import Matchmaking from "./Matchmaking.svelte"; import Matchmaking from "./Matchmaking.svelte";
import type { MatchmakingDto } from "./dtos/MatchmakingDto"; import type { MatchmakingDto } from "./dtos/MatchmakingDto";
import { store } from '../../Auth' import { store } from "../../Auth";
const SERVER_URL = `ws://${import.meta.env.VITE_HOST}:${ const SERVER_URL = `ws://${import.meta.env.VITE_HOST}:${
import.meta.env.VITE_BACK_PORT import.meta.env.VITE_BACK_PORT

10
front/volume/src/components/Profile.svelte

@ -37,9 +37,13 @@
<div class="overlay"> <div class="overlay">
<div class="profile" on:click|stopPropagation on:keydown|stopPropagation> <div class="profile" on:click|stopPropagation on:keydown|stopPropagation>
<div class="profile-header"> <div class="profile-header">
<form action={API_URL + "/avatar"} method="post" <form
enctype="multipart/form-data" id= "upload_avatar"> action={API_URL + "/avatar"}
<div class=input-avatar> method="post"
enctype="multipart/form-data"
id="upload_avatar"
>
<div class="input-avatar">
<label for="avatar-input"> <label for="avatar-input">
<img src={API_URL + "/avatar"} alt="avatar" class="profile-img" /> <img src={API_URL + "/avatar"} alt="avatar" class="profile-img" />
</label> </label>

Loading…
Cancel
Save