finish memos cover

This commit is contained in:
2020-04-18 15:58:48 +02:00
parent e9b4fb573f
commit a12af09102
27 changed files with 661 additions and 32 deletions

1
.idea/php.xml generated
View File

@@ -111,6 +111,7 @@
<path value="$PROJECT_DIR$/vendor/firebase/php-jwt" />
<path value="$PROJECT_DIR$/vendor/laravel/passport" />
<path value="$PROJECT_DIR$/vendor/spatie/laravel-web-tinker" />
<path value="$PROJECT_DIR$/vendor/intervention/image" />
</include_path>
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="7.2" />

1
.idea/portal.iml generated
View File

@@ -26,6 +26,7 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/guzzlehttp/promises" />
<excludeFolder url="file://$MODULE_DIR$/vendor/guzzlehttp/psr7" />
<excludeFolder url="file://$MODULE_DIR$/vendor/hamcrest/hamcrest-php" />
<excludeFolder url="file://$MODULE_DIR$/vendor/intervention/image" />
<excludeFolder url="file://$MODULE_DIR$/vendor/jakub-onderka/php-console-color" />
<excludeFolder url="file://$MODULE_DIR$/vendor/jakub-onderka/php-console-highlighter" />
<excludeFolder url="file://$MODULE_DIR$/vendor/laminas/laminas-diactoros" />

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers;
use App\Http\Resources\Image as ImageResource;
use App\Models\Memo;
use App\User;
use Illuminate\Http\Request;
use Intervention\Image\Facades\Image;
class ImageController extends Controller
{
public function users(User $user)
{
$data = $this->storeImage();
$newImage = auth()->user()->images()->create([
'path' => $data['path'],
'width' => $data['width'],
'height' => $data['height'],
'location' => $data['location'],
]);
return new ImageResource($newImage);
}
public function memos(Memo $memo)
{
$data = $this->storeImage();
$newImage = $memo->images()->create([
'path' => $data['path'],
'width' => $data['width'],
'height' => $data['height'],
'location' => $data['location'],
]);
return new ImageResource($newImage);
}
private function storeImage() {
$data = request()->validate([
'image' => 'required',
'width' => 'required',
'height' => 'required',
'location' => 'required',
]);
$data['path'] = $data['image']->store('images', 'public');
Image::make($data['image'])
->fit($data['width'], $data['height'])
->save(storage_path('app/public/images/'.$data['image']->hashName()));
return $data;
}
}

View File

@@ -30,6 +30,11 @@ class UserController extends Controller
->setStatusCode(Response::HTTP_CREATED);
}
public function show(User $user)
{
return New UserResource($user);
}
private function validateData()
{
return request()->validate([

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class Image extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array
*/
public function toArray($request)
{
return [
'data' => [
'type' => 'images',
'image_id' => $this->id,
'attributes' => [
'path' => url('storage/'.$this->path),
'width' => $this->width,
'height' => $this->height,
'location' => $this->location,
]
],
'links' => [
'self' => url('/images/'.$this->id),
]
];
}
}

View File

@@ -2,6 +2,8 @@
namespace App\Http\Resources;
use App\Http\Resources\Image as ImageResource;
use App\Http\Resources\User as UserResource;
use Illuminate\Http\Resources\Json\JsonResource;
class Memo extends JsonResource
@@ -16,10 +18,15 @@ class Memo extends JsonResource
{
return [
'data' => [
'type' => 'memos',
'memo_id' => $this->id,
'name' => $this->name,
'memo' => $this->memo,
'last_updated' => $this->updated_at->diffForHumans(),
'attributes' => [
'posted_by' => new UserResource($this->user),
'cover_image' => new ImageResource($this->coverImage),
]
//'tags' => TagResource::collection($this->tags),
],
'links' => [

View File

@@ -2,6 +2,7 @@
namespace App\Http\Resources;
use App\Http\Resources\Image as ImageResource;
use Illuminate\Http\Resources\Json\JsonResource;
class User extends JsonResource
@@ -21,6 +22,8 @@ class User extends JsonResource
'attributes' => [
'name' => $this->name,
'email' => $this->email,
'profile_image' => new ImageResource($this->profileImage),
'cover_image' => new ImageResource($this->coverImage),
'last_login' => optional($this->login_at)->diffForHumans(),
'is_admin' => $this->isAdmin(),
],

15
app/Models/Image.php Normal file
View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Image extends Model
{
protected $guarded = [];
public function imageable()
{
return $this->morphTo();
}
}

View File

@@ -4,6 +4,8 @@ namespace App\Models;
use App\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
class Memo extends Model
@@ -24,4 +26,19 @@ class Memo extends Model
// {
// return $this->morphToMany(Tag::class, 'taggable')->withTimestamps()->withPivot('user_id');
// }
public function images(): MorphMany
{
return $this->morphMany(Image::class, 'imageable');
}
public function coverImage(): MorphOne
{
return $this->morphOne(Image::class, 'imageable')
->orderBy('id', 'desc')
->where('location', 'cover')
->withDefault(function ($userImage) {
$userImage->path = 'images/default-cover.jpg';
});
}
}

View File

@@ -2,9 +2,12 @@
namespace App;
use App\Models\Image;
use App\Models\Memo;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Passport\HasApiTokens;
@@ -54,4 +57,23 @@ class User extends Authenticatable
{
return $this->hasMany(Memo::class);
}
public function images(): MorphMany
{
return $this->morphMany(Image::class, 'imageable');
}
public function profileImage(): MorphOne
{
return $this->morphOne(Image::class, 'imageable')
->where('location', 'profile')
->orderBy('id', 'desc');
}
public function coverImage(): MorphOne
{
return $this->morphOne(Image::class, 'imageable')
->where('location', 'cover')
->orderBy('id', 'desc');
}
}

View File

@@ -12,6 +12,7 @@
"fideloper/proxy": "^4.2",
"fruitcake/laravel-cors": "^1.0",
"guzzlehttp/guzzle": "^6.5",
"intervention/image": "^2.5",
"laravel/framework": "^7.0",
"laravel/passport": "^8.4",
"laravel/tinker": "^2.0",

72
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "bc656763558a52327994265003e73f70",
"content-hash": "2c4223d87e78990cf94811823ed227a9",
"packages": [
{
"name": "asm89/stack-cors",
@@ -752,6 +752,76 @@
],
"time": "2019-07-01T23:21:34+00:00"
},
{
"name": "intervention/image",
"version": "2.5.1",
"source": {
"type": "git",
"url": "https://github.com/Intervention/image.git",
"reference": "abbf18d5ab8367f96b3205ca3c89fb2fa598c69e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Intervention/image/zipball/abbf18d5ab8367f96b3205ca3c89fb2fa598c69e",
"reference": "abbf18d5ab8367f96b3205ca3c89fb2fa598c69e",
"shasum": ""
},
"require": {
"ext-fileinfo": "*",
"guzzlehttp/psr7": "~1.1",
"php": ">=5.4.0"
},
"require-dev": {
"mockery/mockery": "~0.9.2",
"phpunit/phpunit": "^4.8 || ^5.7"
},
"suggest": {
"ext-gd": "to use GD library based image processing.",
"ext-imagick": "to use Imagick based image processing.",
"intervention/imagecache": "Caching extension for the Intervention Image library"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.4-dev"
},
"laravel": {
"providers": [
"Intervention\\Image\\ImageServiceProvider"
],
"aliases": {
"Image": "Intervention\\Image\\Facades\\Image"
}
}
},
"autoload": {
"psr-4": {
"Intervention\\Image\\": "src/Intervention/Image"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Oliver Vogel",
"email": "oliver@olivervogel.com",
"homepage": "http://olivervogel.com/"
}
],
"description": "Image handling and manipulation library with support for Laravel integration",
"homepage": "http://image.intervention.io/",
"keywords": [
"gd",
"image",
"imagick",
"laravel",
"thumbnail",
"watermark"
],
"time": "2019-11-02T09:15:47+00:00"
},
{
"name": "jakub-onderka/php-console-color",
"version": "v0.2",

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateImagesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('images', function (Blueprint $table) {
$table->id();
$table->morphs('imageable');
$table->string('path');
$table->smallInteger('width');
$table->smallInteger('height');
$table->string('location');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('images');
}
}

View File

@@ -29,6 +29,7 @@
"vuex": "^3.1.3"
},
"dependencies": {
"dropzone": "^5.7.0",
"laravel-mix-svg-vue": "^0.2.6",
"markdown-it": "^10.0.0",
"markdown-it-checkbox": "^1.1.0"

View File

@@ -0,0 +1,89 @@
<template>
<div>
<img
v-if="image"
:class="classes"
:src="imageObject.data.attributes.path"
ref="image"
:alt="alt">
</div>
</template>
<script>
import Dropzone from 'dropzone'
import { mapGetters } from 'vuex'
export default {
name: "UploadableImage",
props: {
imageWidth: {
type: Number,
required: true,
},
imageHeight: {
type: Number,
required: true,
},
location: {
type: String,
required: true
},
image: {
type: Object,
default: null,
},
author: {
type: Object,
required: true
},
id: {
type: Number,
required: true
},
model: {
type: String,
required: true
},
classes: String,
alt: String
},
data: () => {
return {
dropzone: null,
dropImage: null,
}
},
mounted() {
if(this.authUser.data.user_id === this.author.data.user_id) {
this.dropzone = new Dropzone(this.$refs.image, this.settings)
}
},
computed: {
...mapGetters({
authUser: 'authUser'
}),
settings() {
let url = '/api/images/' + this.model + '/' + this.id
return {
paramName: 'image',
url: url,
acceptedFiles: 'image/*',
params: {
'width': this.imageWidth,
'height': this.imageHeight,
'location': this.location,
},
headers: {
'X-CSRF-TOKEN': document.head.querySelector('meta[name=csrf-token]').content
},
success: (e, res) => {
this.dropImage = res
}
}
},
imageObject() {
return this.dropImage || this.image
}
}
}
</script>

View File

@@ -1,7 +1,7 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from "./views/Home"
import Profil from "./views/User/UserProfil";
import Profil from "./views/User/UserProfile";
import DashBoard from "./views/DashBoard";
import CssTesteur from "./views/CssTesteur";
import MemoIndex from "./views/Memo/MemoIndex";

View File

@@ -1,8 +1,22 @@
<template>
<div class="memo-edit p-2">
<div class="flex-between mb-1">
<router-link :to="'/memos/' + this.$route.params.id" class="btn">< Back</router-link>
<div class="memo-edit">
<div class="relative">
<UploadableImage
v-if="!loading"
:image-width=1500
:image-height=500
location="cover"
:image="form.attributes.cover_image"
:author="form.attributes.posted_by"
:id="form.memo_id"
:model="form.type"
classes="cover"
:alt="form.name"/>
<div class="flex-between ml-2 mt-1 absolute t-0">
<router-link :to="'/memos/' + this.$route.params.id" class="btn-secondary">< Back</router-link>
</div>
</div>
<div class="p-2">
<form @submit.prevent="submitForm">
<InputField name="name" :data="form.name" label="Title" placeholder="Your Title" required @update:field="form.name = $event" :errors="errors" />
<TextAreaField class="memo-text-area" name="memo" :data="form.memo" placeholder="Your Memo" required @update:field="form.memo = $event" :errors="errors" />
@@ -12,22 +26,26 @@
</form>
</div>
</div>
</template>
<script>
import InputField from "../../components/InputField";
import TextAreaField from "../../components/TextAreaField";
import UploadableImage from "../../components/UploadableImage";
export default {
name: "MemoEdit",
components: {
InputField, TextAreaField
InputField, TextAreaField, UploadableImage
},
data: function () {
return {
form: {
'name': '',
'memo': '',
'attributes': {}
},
errors: null,
loading: true,

View File

@@ -10,8 +10,11 @@
<p>No memos yet. <router-link to="/memos/create">Get Started ></router-link></p>
</div>
<router-link v-for="memo in memos" :key="memo.data.memo_id" :to="'/memos/' + memo.data.memo_id" class="card">
<h1>{{ memo.data.name }}</h1>
<div class="memo-date">{{ memo.data.last_updated }}</div>
<div>
<img :src="memo.data.attributes.cover_image.data.attributes.path" alt="" class="cover">
<h1 class="p-1">{{ memo.data.name }}</h1>
</div>
<div class="memo-date p-1">{{ memo.data.last_updated }}</div>
</router-link>
</div>
</div>

View File

@@ -1,9 +1,16 @@
<template>
<div class="p-2">
<div>
<Loader v-if="loading" />
<div v-else>
<div class="flex-between flex-center mb-1 relative">
<router-link to="/memos/" class="btn">< Back</router-link>
<div class="relative">
<img
v-if="!loading"
class="cover"
:src="memo.attributes.cover_image.data.attributes.path"
/>
<div class="flex-col flex-between absolute memo-cover">
<div class="flex-between px-2 py-1">
<router-link to="/memos/" class="btn-secondary">< Back</router-link>
<div v-if="modal" class="modal-container" @click="modal = ! modal">
<div class="modal">
<p class="m-1 text-center">Are you sure you want to delete this record ?</p>
@@ -18,12 +25,17 @@
<a href="#" class="btn-alert" @click="modal = ! modal">Delete</a>
</div>
</div>
<!-- <TagBox :memo="memo" />-->
<h1 class="memo-title flex-center">{{ memo.name }}</h1>
</div>
</div>
<!-- <TagBox :memo="memo" />-->
<div class="p-2">
<p class="memo-style pt-1" v-html="memoMarkdown"></p>
<div class="memo-change">@last update : {{ memo.last_updated }}</div>
</div>
</div>
</div>
</template>
<script>

View File

@@ -15,6 +15,7 @@
@import "components/nav";
@import "components/topbar";
@import "components/modal";
@import "components/images";
@import "components/avatar";
@import "components/alert_box";
@import "components/search_box";

10
resources/sass/components/images.scss vendored Normal file
View File

@@ -0,0 +1,10 @@
.cover {
width: 100%;
//height: 100%;
object-fit: cover;
object-position: 50% 50%;
}
.test {
height: 30rem;
}

View File

@@ -33,6 +33,18 @@
padding: 0.5rem 1rem;
}
&-title {
color: $white;
text-shadow: 1px 1px 2px $dark;
background-image: linear-gradient(transparent, rgba(0,0,0,0.7));
}
&-cover {
height: calc(100% - 5px);
top: 0;
width: 100%;
}
}
.memo-edit {

View File

@@ -8,7 +8,7 @@
.card {
background-color: $light;
display: flex;
padding: 1rem;
//padding: 1rem;
box-shadow: 1px 1px 2px $grey;
border-radius: 2px;
text-decoration: none;

View File

@@ -55,6 +55,10 @@ $base: 1rem;
width: 100%;
}
.h-100{
height: 100%;
}
.m-auto {
margin-left: auto;
margin-right: auto;
@@ -127,6 +131,22 @@ $base: 1rem;
}
}
.t-0 {
top: 0;
}
.l-0 {
left: 0;
}
.r-0 {
right: 0;
}
.b-0 {
bottom: 0;
}
.z-10 {
z-index: 10;
}

View File

@@ -18,6 +18,7 @@ Route::middleware('auth:api')->group(function () {
Route::get('auth-user', 'AuthUserController@show');
Route::apiResources([
'/users' => 'UserController',
'/memos' => 'MemosController',
@@ -26,4 +27,7 @@ Route::middleware('auth:api')->group(function () {
// '/friend-request' => 'FriendRequestController',
]);
Route::post('/images/users/{users}', 'ImageController@users');
Route::post('/images/memos/{memo}', 'ImageController@memos');
});

View File

@@ -0,0 +1,191 @@
<?php
namespace Tests\Feature;
use App\Models\Image;
use App\Models\Memo;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class ImagesTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
}
/** @test */
public function images_can_be_uploaded()
{
$this->withoutExceptionHandling();
$this->actingAs($user= factory(User::class)->create(), 'api');
$file = UploadedFile::fake()->image('test-image.jpg');
$response = $this->post('/api/images/users/'.$user->id, [
'image' => $file,
'width' => '850',
'height' => '300',
'location' => 'cover',
])->assertStatus(201);
Storage::disk('public')->assertExists('images/'.$file->hashName());
$image = Image::first();
$this->assertEquals('images/'.$file->hashName(), $image->path);
$this->assertEquals(850, $image->width);
$this->assertEquals(300, $image->height);
$this->assertEquals('cover', $image->location);
$response->assertJson([
'data' => [
'type' => 'images',
'image_id' => $image->id,
'attributes' => [
'path' => url('storage/'.$image->path),
'width' => $image->width,
'height' => $image->height,
'location' => $image->location,
]
],
'links' => [
'self' => url('/images/'.$image->id),
]
]);
}
/** @test */
public function users_are_returned_with_their_images()
{
$this->withoutExceptionHandling();
$this->actingAs($user= factory(User::class)->create(), 'api');
$file = UploadedFile::fake()->image('user-image.jpg');
$this->post('/api/images/users/'.$user->id, [
'image' => $file,
'width' => 850,
'height' => 300,
'location' => 'cover',
])->assertStatus(201);
$this->post('/api/images/users/'.$user->id, [
'image' => $file,
'width' => 850,
'height' => 300,
'location' => 'profile',
])->assertStatus(201);
$response = $this->get('/api/users/'.$user->id);
$userImage = Image::first();
$response->assertJson([
'data' => [
'type' => 'users',
'user_id' => $user->id,
'attributes' => [
'name' => $user->name,
'cover_image' => [
'data' => [
'type' => 'images',
'image_id' => 1,
'attributes' => []
]
],
'profile_image' => [
'data' => [
'type' => 'images',
'image_id' => 2,
'attributes' => []
]
]
]
]
]);
}
/** @test */
public function memos_are_returned_with_their_image_cover()
{
$this->withoutExceptionHandling();
$this->actingAs($user= factory(User::class)->create(), 'api');
$file = UploadedFile::fake()->image('user-image.jpg');
$memo = factory(Memo::class)->create(['user_id' => $user->id]);
$memo->coverImage()->create([
'path' => $file,
'width' => 850,
'height' => 300,
'location' => 'cover',
]);
$response = $this->get('/api/memos/'.$memo->id);
$memo = Memo::first();
$response->assertJson([
'data' => [
'type' => 'memos',
'memo_id' => $memo->id,
'name' => $memo->name,
'memo' => $memo->memo,
'last_updated' => $memo->updated_at->diffForHumans(),
'attributes' => [
'cover_image' => [
'data' => [
'type' => 'images',
'image_id' => 1,
'attributes' => []
]
],
]
],
]);
}
/** @test */
public function memos_images_cover_can_be_uploaded()
{
$this->withoutExceptionHandling();
$this->actingAs($user= factory(User::class)->create(), 'api');
$file = UploadedFile::fake()->image('user-image.jpg');
$memo = factory(Memo::class)->create(['user_id' => $user->id, 'id' => 123]);
$this->post('/api/images/memos/'.$memo->id, [
'image' => $file,
'width' => 850,
'height' => 300,
'location' => 'cover',
])->assertStatus(201);
$image = Image::first();
$response = $this->get('/api/memos/123');
$response->assertJson([
'data' => [
'type' => 'memos',
'attributes' => [
'cover_image' => [
'data' => [
'type' => 'images',
'image_id' => $image->id,
'attributes' => [
'path' => url('storage/'.$image->path),
'width' => $image->width,
'height' => $image->height,
'location' => $image->location,
]
],
'links' => [
'self' => url('/images/'.$memo->coverImage->id),
]
]
]
]
]);
}
}