Merge pull request #180 from shikorism/develop

Release 20190421.0114
This commit is contained in:
shibafu 2019-04-21 01:23:03 +09:00 committed by GitHub
commit 0c18965ade
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 562 additions and 137 deletions

View File

@ -86,6 +86,15 @@ docker-compose run --rm web yarn watch
現在Docker環境でのHMRはサポートしてません。Docker外ならおそらく動くでしょう。 現在Docker環境でのHMRはサポートしてません。Docker外ならおそらく動くでしょう。
その他詳しくはlaravel-mixのドキュメントなどを当たってください。 その他詳しくはlaravel-mixのドキュメントなどを当たってください。
## phpunit によるテスト
変更をしたらPull Requestを投げる前にテストが通ることを確認してください。
テストは以下のコマンドで実行できます。
```
docker-compose exec web composer test
```
## 環境構築上の諸注意 ## 環境構築上の諸注意
- 初版時点では、DB サーバとして PostgreSQL を使うよう .env ファイルを設定するくらいです。 - 初版時点では、DB サーバとして PostgreSQL を使うよう .env ファイルを設定するくらいです。

View File

@ -2,11 +2,15 @@
namespace App; namespace App;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Staudenmeir\EloquentEagerLimit\HasEagerLimit;
class Ejaculation extends Model class Ejaculation extends Model
{ {
// use HasEagerLimit;
protected $fillable = [ protected $fillable = [
'user_id', 'ejaculated_date', 'user_id', 'ejaculated_date',
@ -34,4 +38,45 @@ class Ejaculation extends Model
return $v->name; return $v->name;
})->all()); })->all());
} }
public function likes()
{
return $this->hasMany(Like::class);
}
public function scopeWithLikes(Builder $query)
{
if (Auth::check()) {
// TODO - このスコープを使うことでlikesが常に直近10件で絞られるのは汚染されすぎ感がある。別名を付与できないか
// - (ejaculation_id, user_id) でユニークなわけですが、is_liked はサブクエリ発行させるのとLeft JoinしてNULLかどうかで結果を見るのどっちがいいんでしょうね
return $query
->with([
'likes' => function ($query) {
$query->latest()->take(10);
},
'likes.user' => function ($query) {
$query->where('is_protected', false)
->orWhere('id', Auth::id());
}
])
->withCount([
'likes',
'likes as is_liked' => function ($query) {
$query->where('user_id', Auth::id());
}
]);
} else {
return $query
->with([
'likes' => function ($query) {
$query->latest()->take(10);
},
'likes.user' => function ($query) {
$query->where('is_protected', false);
}
])
->withCount('likes')
->addSelect(DB::raw('0 as is_liked'));
}
}
} }

View File

@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers\Api;
use App\Ejaculation;
use App\Http\Controllers\Controller;
use App\Like;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
class LikeController extends Controller
{
public function store(Request $request)
{
$request->validate([
'id' => 'required|integer|exists:ejaculations'
]);
$keys = [
'user_id' => Auth::id(),
'ejaculation_id' => $request->input('id')
];
$like = Like::query()->where($keys)->first();
if ($like) {
$data = [
'errors' => [
['message' => 'このチェックインはすでにいいね済です。']
],
'ejaculation' => $like->ejaculation
];
return response()->json($data, 409);
}
$like = Like::create($keys);
return [
'ejaculation' => $like->ejaculation
];
}
public function destroy($id)
{
Validator::make(compact('id'), [
'id' => 'required|integer'
])->validate();
$like = Like::query()->where([
'user_id' => Auth::id(),
'ejaculation_id' => $id
])->first();
if ($like === null) {
$ejaculation = Ejaculation::find($id);
$data = [
'errors' => [
['message' => 'このチェックインはいいねされていません。']
],
'ejaculation' => $ejaculation
];
return response()->json($data, 404);
}
$like->delete();
return [
'ejaculation' => $like->ejaculation
];
}
}

View File

@ -78,7 +78,9 @@ class EjaculationController extends Controller
public function show($id) public function show($id)
{ {
$ejaculation = Ejaculation::findOrFail($id); $ejaculation = Ejaculation::where('id', $id)
->withLikes()
->firstOrFail();
$user = User::findOrFail($ejaculation->user_id); $user = User::findOrFail($ejaculation->user_id);
// 1つ前のチェックインからの経過時間を求める // 1つ前のチェックインからの経過時間を求める

View File

@ -69,6 +69,7 @@ SQL
->orderBy('ejaculations.ejaculated_date', 'desc') ->orderBy('ejaculations.ejaculated_date', 'desc')
->select('ejaculations.*') ->select('ejaculations.*')
->with('user', 'tags') ->with('user', 'tags')
->withLikes()
->take(10) ->take(10)
->get(); ->get();

View File

@ -28,6 +28,7 @@ class SearchController extends Controller
->where('is_private', false) ->where('is_private', false)
->orderBy('ejaculated_date', 'desc') ->orderBy('ejaculated_date', 'desc')
->with(['user', 'tags']) ->with(['user', 'tags'])
->withLikes()
->paginate(20) ->paginate(20)
->appends($inputs); ->appends($inputs);

View File

@ -46,11 +46,12 @@ class SettingController extends Controller
public function updatePrivacy(Request $request) public function updatePrivacy(Request $request)
{ {
$inputs = $request->all(['is_protected', 'accept_analytics']); $inputs = $request->all(['is_protected', 'accept_analytics', 'private_likes']);
$user = Auth::user(); $user = Auth::user();
$user->is_protected = $inputs['is_protected'] ?? false; $user->is_protected = $inputs['is_protected'] ?? false;
$user->accept_analytics = $inputs['accept_analytics'] ?? false; $user->accept_analytics = $inputs['accept_analytics'] ?? false;
$user->private_likes = $inputs['private_likes'] ?? false;
$user->save(); $user->save();
return redirect()->route('setting.privacy')->with('status', 'プライバシー設定を更新しました。'); return redirect()->route('setting.privacy')->with('status', 'プライバシー設定を更新しました。');

View File

@ -16,6 +16,7 @@ class TimelineController extends Controller
->orderBy('ejaculations.ejaculated_date', 'desc') ->orderBy('ejaculations.ejaculated_date', 'desc')
->select('ejaculations.*') ->select('ejaculations.*')
->with('user', 'tags') ->with('user', 'tags')
->withLikes()
->paginate(21); ->paginate(21);
return view('timeline.public')->with(compact('ejaculations')); return view('timeline.public')->with(compact('ejaculations'));

View File

@ -41,6 +41,7 @@ SQL
} }
$ejaculations = $query->orderBy('ejaculated_date', 'desc') $ejaculations = $query->orderBy('ejaculated_date', 'desc')
->with('tags') ->with('tags')
->withLikes()
->paginate(20); ->paginate(20);
// よく使っているタグ // よく使っているタグ
@ -176,4 +177,19 @@ SQL
return view('user.profile')->with(compact('user', 'ejaculations')); return view('user.profile')->with(compact('user', 'ejaculations'));
} }
public function likes($name)
{
$user = User::where('name', $name)->first();
if (empty($user)) {
abort(404);
}
$likes = $user->likes()
->orderBy('created_at', 'desc')
->with('ejaculation.user', 'ejaculation.tags')
->paginate(20);
return view('user.likes')->with(compact('user', 'likes'));
}
} }

View File

@ -38,7 +38,12 @@ class Kernel extends HttpKernel
\App\Http\Middleware\NormalizeLineEnding::class, \App\Http\Middleware\NormalizeLineEnding::class,
], ],
// 現時点では内部APIしかないので、認証の手間を省くためにステートフルにしている。
'api' => [ 'api' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
'throttle:60,1', 'throttle:60,1',
'bindings', 'bindings',
], ],

23
app/Like.php Normal file
View File

@ -0,0 +1,23 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
use Staudenmeir\EloquentEagerLimit\HasEagerLimit;
class Like extends Model
{
use HasEagerLimit;
protected $fillable = ['user_id', 'ejaculation_id'];
public function user()
{
return $this->belongsTo(User::class);
}
public function ejaculation()
{
return $this->belongsTo(Ejaculation::class)->withLikes();
}
}

View File

@ -73,6 +73,10 @@ class ActivityPubResolver implements Resolver, Parser
private function html2text(string $html): string private function html2text(string $html): string
{ {
if (empty($html)) {
return '';
}
$html = mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'); $html = mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8');
$html = preg_replace('~<br\s*/?\s*>|</p>\s*<p[^>]*>~i', "\n", $html); $html = preg_replace('~<br\s*/?\s*>|</p>\s*<p[^>]*>~i', "\n", $html);
$dom = new \DOMDocument(); $dom = new \DOMDocument();

View File

@ -13,7 +13,7 @@ class MetadataResolver implements Resolver
'~nijie\.info/view(_popup)?\.php~' => NijieResolver::class, '~nijie\.info/view(_popup)?\.php~' => NijieResolver::class,
'~komiflo\.com(/#!)?/comics/(\\d+)~' => KomifloResolver::class, '~komiflo\.com(/#!)?/comics/(\\d+)~' => KomifloResolver::class,
'~www\.melonbooks\.co\.jp/detail/detail\.php~' => MelonbooksResolver::class, '~www\.melonbooks\.co\.jp/detail/detail\.php~' => MelonbooksResolver::class,
'~ec\.toranoana\.jp/tora_r/ec/item/.*~' => ToranoanaResolver::class, '~ec\.toranoana\.(jp|shop)/(tora|joshi)(_[rd]+)?/(ec|digi)/item/~' => ToranoanaResolver::class,
'~iwara\.tv/videos/.*~' => IwaraResolver::class, '~iwara\.tv/videos/.*~' => IwaraResolver::class,
'~www\.dlsite\.com/.*/work/=/product_id/..\d+\.html~' => DLsiteResolver::class, '~www\.dlsite\.com/.*/work/=/product_id/..\d+\.html~' => DLsiteResolver::class,
'~dlsite\.jp/mawtw/..\d+~' => DLsiteResolver::class, '~dlsite\.jp/mawtw/..\d+~' => DLsiteResolver::class,

View File

@ -20,6 +20,7 @@ class User extends Authenticatable
'is_protected', 'accept_analytics', 'is_protected', 'accept_analytics',
'display_name', 'description', 'display_name', 'description',
'twitter_id', 'twitter_name', 'twitter_id', 'twitter_name',
'private_likes',
]; ];
/** /**
@ -51,4 +52,9 @@ class User extends Authenticatable
{ {
return Auth::check() && $this->id === Auth::user()->id; return Auth::check() && $this->id === Auth::user()->id;
} }
public function likes()
{
return $this->hasMany(Like::class);
}
} }

View File

@ -12,7 +12,8 @@
"guzzlehttp/guzzle": "^6.3", "guzzlehttp/guzzle": "^6.3",
"laravel/framework": "5.5.*", "laravel/framework": "5.5.*",
"laravel/tinker": "~1.0", "laravel/tinker": "~1.0",
"misd/linkify": "^1.1" "misd/linkify": "^1.1",
"staudenmeir/eloquent-eager-limit": "^1.0"
}, },
"require-dev": { "require-dev": {
"barryvdh/laravel-debugbar": "^3.1", "barryvdh/laravel-debugbar": "^3.1",
@ -50,6 +51,9 @@
], ],
"fix": [ "fix": [
"php-cs-fixer fix" "php-cs-fixer fix"
],
"test": [
"phpunit"
] ]
}, },
"config": { "config": {

44
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "79423bebbfa31e28aab2d06ba7e19828", "content-hash": "665f6f5eb180a1295fb60303d2ea5051",
"packages": [ "packages": [
{ {
"name": "anhskohbo/no-captcha", "name": "anhskohbo/no-captcha",
@ -1873,6 +1873,48 @@
], ],
"time": "2018-07-19T23:38:55+00:00" "time": "2018-07-19T23:38:55+00:00"
}, },
{
"name": "staudenmeir/eloquent-eager-limit",
"version": "v1.2",
"source": {
"type": "git",
"url": "https://github.com/staudenmeir/eloquent-eager-limit.git",
"reference": "4ee5c70268b5bc019618c7de66145d7addb3c5dc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/staudenmeir/eloquent-eager-limit/zipball/4ee5c70268b5bc019618c7de66145d7addb3c5dc",
"reference": "4ee5c70268b5bc019618c7de66145d7addb3c5dc",
"shasum": ""
},
"require": {
"illuminate/database": "~5.5.43|~5.6.34|5.7.*",
"illuminate/support": "^5.5.14",
"php": ">=7.0"
},
"require-dev": {
"laravel/homestead": "^7.18",
"phpunit/phpunit": "~6.5"
},
"type": "library",
"autoload": {
"psr-4": {
"Staudenmeir\\EloquentEagerLimit\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jonas Staudenmeir",
"email": "mail@jonas-staudenmeir.de"
}
],
"description": "Laravel Eloquent eager loading with limit",
"time": "2019-01-23T19:25:27+00:00"
},
{ {
"name": "swiftmailer/swiftmailer", "name": "swiftmailer/swiftmailer",
"version": "v6.1.3", "version": "v6.1.3",

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateLikesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('likes', function (Blueprint $table) {
$table->increments('id');
$table->integer('user_id')->index();
$table->integer('ejaculation_id')->index();
$table->timestamps();
$table->unique(['user_id', 'ejaculation_id']);
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
$table->foreign('ejaculation_id')->references('id')->on('ejaculations')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('likes');
}
}

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddPrivateLikesToUsers extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->boolean('private_likes')->default(false);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('private_likes');
});
}
}

View File

@ -27,4 +27,69 @@ $(() => {
event.preventDefault(); event.preventDefault();
$deleteCheckinModal.modal('show', this); $deleteCheckinModal.modal('show', this);
}); });
$(document).on('click', '[data-href]', function (event) {
location.href = $(this).data('href');
});
$(document).on('click', '.like-button', function (event) {
event.preventDefault();
const $this = $(this);
const targetId = $this.data('id');
const isLiked = $this.data('liked');
if (isLiked) {
const callback = (data) => {
$this.data('liked', false);
$this.find('.oi-heart').removeClass('text-danger');
const count = data.ejaculation ? data.ejaculation.likes_count : 0;
$this.find('.like-count').text(count ? count : '');
};
$.ajax({
url: '/api/likes/' + encodeURIComponent(targetId),
method: 'delete',
type: 'json'
})
.then(callback)
.catch(function (xhr) {
if (xhr.status === 404) {
callback(JSON.parse(xhr.responseText));
return;
}
console.error(xhr);
alert('いいねを解除できませんでした。');
});
} else {
const callback = (data) => {
$this.data('liked', true);
$this.find('.oi-heart').addClass('text-danger');
const count = data.ejaculation ? data.ejaculation.likes_count : 0;
$this.find('.like-count').text(count ? count : '');
};
$.ajax({
url: '/api/likes',
method: 'post',
type: 'json',
data: {
id: targetId
}
})
.then(callback)
.catch(function (xhr) {
if (xhr.status === 409) {
callback(JSON.parse(xhr.responseText));
return;
}
console.error(xhr);
alert('いいねできませんでした。');
});
}
});
}); });

View File

@ -12,4 +12,5 @@ $primary: #e53fb1;
@import "tissue.css"; @import "tissue.css";
// Components // Components
@import "components/link-card"; @import "components/ejaculation";
@import "components/link-card";

View File

@ -0,0 +1,21 @@
.ejaculation-actions {
& > button:not(:last-child) {
margin-right: 24px;
}
}
.like-button {
text-decoration: none !important;
}
.like-count:not(:empty) {
padding-left: 0.5rem;
}
.like-users {
height: 30px;
}
.like-users-tall {
height: 36px;
}

View File

@ -40,10 +40,6 @@
border-top: none; border-top: none;
} }
.timeline-action-item {
margin-left: 16px;
}
.tis-global-count-graph { .tis-global-count-graph {
height: 90px; height: 90px;
border-bottom: 1px solid rgba(0, 0, 0, .125); border-bottom: 1px solid rgba(0, 0, 0, .125);

View File

@ -0,0 +1,53 @@
<!-- span -->
<div>
<h5>
<a href="{{ route('user.profile', ['id' => $ejaculation->user->name]) }}" class="text-dark"><img src="{{ $ejaculation->user->getProfileImageUrl(30) }}" width="30" height="30" class="rounded d-inline-block align-bottom"> <bdi>{{ $ejaculation->user->display_name }}</bdi></a>
<a href="{{ route('checkin.show', ['id' => $ejaculation->id]) }}" class="text-muted"><small>{{ $ejaculation->ejaculated_date->format('Y/m/d H:i') }}</small></a>
</h5>
</div>
<!-- tags -->
@if ($ejaculation->tags->isNotEmpty())
<p class="mb-2">
@foreach ($ejaculation->tags as $tag)
<a class="badge badge-secondary" href="{{ route('search', ['q' => $tag->name]) }}"><span class="oi oi-tag"></span> {{ $tag->name }}</a>
@endforeach
</p>
@endif
<!-- okazu link -->
@if (!empty($ejaculation->link))
<div class="row mx-0">
@component('components.link-card', ['link' => $ejaculation->link])
@endcomponent
<p class="d-flex align-items-baseline mb-2 col-12 px-0">
<span class="oi oi-link-intact mr-1"></span><a class="overflow-hidden" href="{{ $ejaculation->link }}" target="_blank" rel="noopener">{{ $ejaculation->link }}</a>
</p>
</div>
@endif
<!-- note -->
@if (!empty($ejaculation->note))
<p class="mb-2 text-break">
{!! Formatter::linkify(nl2br(e($ejaculation->note))) !!}
</p>
@endif
<!-- likes -->
@if ($ejaculation->likes_count > 0)
<div class="my-2 py-1 border-top border-bottom d-flex align-items-center">
<div class="ml-2 mr-3 text-secondary flex-shrink-0"><small><strong>{{ $ejaculation->likes_count }}</strong> 件のいいね</small></div>
<div class="like-users flex-grow-1 overflow-hidden">
@foreach ($ejaculation->likes as $like)
@if ($like->user !== null)
<a href="{{ route('user.profile', ['name' => $like->user->name]) }}"><img src="{{ $like->user->getProfileImageUrl(30) }}" width="30" height="30" class="rounded" data-toggle="tooltip" data-placement="bottom" title="{{ $like->user->display_name }}"></a>
@endif
@endforeach
</div>
</div>
@endif
<!-- actions -->
<div class="ejaculation-actions">
<button type="button" class="btn btn-link text-secondary"
data-toggle="tooltip" data-placement="bottom"
title="同じオカズでチェックイン" data-href="{{ route('checkin', ['link' => $ejaculation->link, 'tags' => $ejaculation->textTags()]) }}"><span class="oi oi-reload"></span></button>
<button type="button" class="btn btn-link text-secondary like-button"
data-toggle="tooltip" data-placement="bottom" data-trigger="hover"
title="いいね" data-id="{{ $ejaculation->id }}" data-liked="{{ (bool)$ejaculation->is_liked }}"><span class="oi oi-heart {{ $ejaculation->is_liked ? 'text-danger' : '' }}"></span><span class="like-count">{{ $ejaculation->likes_count ? $ejaculation->likes_count : '' }}</span></button>
</div>

View File

@ -30,15 +30,8 @@
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<!-- span --> <!-- span -->
<div class="d-flex justify-content-between"> <div>
<h5>{{ $ejaculatedSpan ?? '精通' }} <small class="text-muted">{{ $ejaculation->before_date }}{{ !empty($ejaculation->before_date) ? ' ' : '' }}{{ $ejaculation->ejaculated_date->format('Y/m/d H:i') }}</small></h5> <h5>{{ $ejaculatedSpan ?? '精通' }} <small class="text-muted">{{ $ejaculation->before_date }}{{ !empty($ejaculation->before_date) ? ' ' : '' }}{{ $ejaculation->ejaculated_date->format('Y/m/d H:i') }}</small></h5>
<div>
<a class="text-secondary timeline-action-item" href="{{ route('checkin', ['link' => $ejaculation->link, 'tags' => $ejaculation->textTags()]) }}"><span class="oi oi-reload" data-toggle="tooltip" data-placement="bottom" title="同じオカズでチェックイン"></span></a>
@if ($user->isMe())
<a class="text-secondary timeline-action-item" href="{{ route('checkin.edit', ['id' => $ejaculation->id]) }}"><span class="oi oi-pencil" data-toggle="tooltip" data-placement="bottom" title="修正"></span></a>
<a class="text-secondary timeline-action-item" href="#" data-target="#deleteCheckinModal" data-id="{{ $ejaculation->id }}" data-date="{{ $ejaculation->ejaculated_date }}"><span class="oi oi-trash" data-toggle="tooltip" data-placement="bottom" title="削除"></span></a>
@endif
</div>
</div> </div>
<!-- tags --> <!-- tags -->
@if ($ejaculation->is_private || $ejaculation->tags->isNotEmpty()) @if ($ejaculation->is_private || $ejaculation->tags->isNotEmpty())
@ -63,10 +56,32 @@
@endif @endif
<!-- note --> <!-- note -->
@if (!empty($ejaculation->note)) @if (!empty($ejaculation->note))
<p class="mb-0 text-break"> <p class="mb-2 text-break">
{!! Formatter::linkify(nl2br(e($ejaculation->note))) !!} {!! Formatter::linkify(nl2br(e($ejaculation->note))) !!}
</p> </p>
@endif @endif
<!-- likes -->
@if ($ejaculation->likes_count > 0)
<div class="my-2 py-1 border-top border-bottom d-flex align-items-center">
<div class="ml-2 mr-3 text-secondary flex-shrink-0"><small><strong>{{ $ejaculation->likes_count }}</strong> 件のいいね</small></div>
<div class="like-users-tall flex-grow-1 overflow-hidden">
@foreach ($ejaculation->likes as $like)
@if ($like->user !== null)
<a href="{{ route('user.profile', ['name' => $like->user->name]) }}"><img src="{{ $like->user->getProfileImageUrl(36) }}" width="36" height="36" class="rounded" data-toggle="tooltip" data-placement="bottom" title="{{ $like->user->display_name }}"></a>
@endif
@endforeach
</div>
</div>
@endif
<!-- actions -->
<div class="ejaculation-actions">
<button type="button" class="btn btn-link text-secondary" data-toggle="tooltip" data-placement="bottom" title="同じオカズでチェックイン" data-href="{{ route('checkin', ['link' => $ejaculation->link, 'tags' => $ejaculation->textTags()]) }}"><span class="oi oi-reload"></span></button>
<button type="button" class="btn btn-link text-secondary like-button" data-toggle="tooltip" data-placement="bottom" data-trigger="hover" title="いいね" data-id="{{ $ejaculation->id }}" data-liked="{{ (bool)$ejaculation->is_liked }}"><span class="oi oi-heart {{ $ejaculation->is_liked ? 'text-danger' : '' }}"></span><span class="like-count">{{ $ejaculation->likes_count ? $ejaculation->likes_count : '' }}</span></button>
@if ($user->isMe())
<button type="button" class="btn btn-link text-secondary" data-toggle="tooltip" data-placement="bottom" title="修正" data-href="{{ route('checkin.edit', ['id' => $ejaculation->id]) }}"><span class="oi oi-pencil"></span></button>
<button type="button" class="btn btn-link text-secondary" data-toggle="tooltip" data-placement="bottom" title="削除" data-target="#deleteCheckinModal" data-id="{{ $ejaculation->id }}" data-date="{{ $ejaculation->ejaculated_date }}"><span class="oi oi-trash"></span></button>
@endif
</div>
</div> </div>
</div> </div>
@endif @endif

View File

@ -55,40 +55,8 @@
<ul class="list-group"> <ul class="list-group">
@foreach ($publicLinkedEjaculations as $ejaculation) @foreach ($publicLinkedEjaculations as $ejaculation)
<li class="list-group-item no-side-border pt-3 pb-3 text-break"> <li class="list-group-item no-side-border pt-3 pb-3 text-break">
<!-- span --> @component('components.ejaculation', compact('ejaculation'))
<div class="d-flex justify-content-between"> @endcomponent
<h5>
<a href="{{ route('user.profile', ['id' => $ejaculation->user->name]) }}" class="text-dark"><img src="{{ $ejaculation->user->getProfileImageUrl(30) }}" width="30" height="30" class="rounded d-inline-block align-bottom"> <bdi>{{ $ejaculation->user->display_name }}</bdi></a>
<a href="{{ route('checkin.show', ['id' => $ejaculation->id]) }}" class="text-muted"><small>{{ $ejaculation->ejaculated_date->format('Y/m/d H:i') }}</small></a>
</h5>
<div>
<a class="text-secondary timeline-action-item" href="{{ route('checkin', ['link' => $ejaculation->link, 'tags' => $ejaculation->textTags()]) }}"><span class="oi oi-reload" data-toggle="tooltip" data-placement="bottom" title="同じオカズでチェックイン"></span></a>
</div>
</div>
<!-- tags -->
@if ($ejaculation->tags->isNotEmpty())
<p class="mb-2">
@foreach ($ejaculation->tags as $tag)
<a class="badge badge-secondary" href="{{ route('search', ['q' => $tag->name]) }}"><span class="oi oi-tag"></span> {{ $tag->name }}</a>
@endforeach
</p>
@endif
<!-- okazu link -->
@if (!empty($ejaculation->link))
<div class="row mx-0">
@component('components.link-card', ['link' => $ejaculation->link])
@endcomponent
<p class="d-flex align-items-baseline mb-2 col-12 px-0">
<span class="oi oi-link-intact mr-1"></span><a class="overflow-hidden" href="{{ $ejaculation->link }}" target="_blank" rel="noopener">{{ $ejaculation->link }}</a>
</p>
</div>
@endif
<!-- note -->
@if (!empty($ejaculation->note))
<p class="mb-0 text-break">
{!! Formatter::linkify(nl2br(e($ejaculation->note))) !!}
</p>
@endif
</li> </li>
@endforeach @endforeach
<li class="list-group-item no-side-border text-right"> <li class="list-group-item no-side-border text-right">

View File

@ -50,6 +50,9 @@
</p> </p>
</a> </a>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<a href="{{ route('user.profile', ['name' => Auth::user()->name]) }}" class="dropdown-item">プロフィール</a>
<a href="{{ route('user.likes', ['name' => Auth::user()->name]) }}" class="dropdown-item">いいね</a>
<div class="dropdown-divider"></div>
<a href="{{ route('setting') }}" class="dropdown-item">設定</a> <a href="{{ route('setting') }}" class="dropdown-item">設定</a>
<a href="{{ route('logout') }}" class="dropdown-item" onclick="event.preventDefault(); document.getElementById('logout-form').submit();">ログアウト</a> <a href="{{ route('logout') }}" class="dropdown-item" onclick="event.preventDefault(); document.getElementById('logout-form').submit();">ログアウト</a>
</div> </div>
@ -104,6 +107,9 @@
</p> </p>
</a> </a>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<a href="{{ route('user.profile', ['name' => Auth::user()->name]) }}" class="dropdown-item">プロフィール</a>
<a href="{{ route('user.likes', ['name' => Auth::user()->name]) }}" class="dropdown-item">いいね</a>
<div class="dropdown-divider"></div>
<a href="{{ route('setting') }}" class="dropdown-item">設定</a> <a href="{{ route('setting') }}" class="dropdown-item">設定</a>
@can ('admin') @can ('admin')
<a href="{{ route('admin.dashboard') }}" class="dropdown-item">管理</a> <a href="{{ route('admin.dashboard') }}" class="dropdown-item">管理</a>

View File

@ -7,43 +7,8 @@
<ul class="list-group"> <ul class="list-group">
@foreach($results as $ejaculation) @foreach($results as $ejaculation)
<li class="list-group-item border-bottom-only pt-3 pb-3 text-break"> <li class="list-group-item border-bottom-only pt-3 pb-3 text-break">
<!-- span --> @component('components.ejaculation', compact('ejaculation'))
<div class="d-flex justify-content-between"> @endcomponent
<h5>
<a href="{{ route('user.profile', ['id' => $ejaculation->user->name]) }}" class="text-dark"><img src="{{ $ejaculation->user->getProfileImageUrl(30) }}" width="30" height="30" class="rounded d-inline-block align-bottom"> <bdi>{{ $ejaculation->user->display_name }}</bdi></a>
<a href="{{ route('checkin.show', ['id' => $ejaculation->id]) }}" class="text-muted"><small>{{ $ejaculation->ejaculated_date->format('Y/m/d H:i') }}</small></a>
</h5>
<div>
<a class="text-secondary timeline-action-item" href="{{ route('checkin', ['link' => $ejaculation->link, 'tags' => $ejaculation->textTags()]) }}"><span class="oi oi-reload" data-toggle="tooltip" data-placement="bottom" title="同じオカズでチェックイン"></span></a>
</div>
</div>
<!-- tags -->
@if ($ejaculation->is_private || $ejaculation->tags->isNotEmpty())
<p class="mb-2">
@if ($ejaculation->is_private)
<span class="badge badge-warning"><span class="oi oi-lock-locked"></span> 非公開</span>
@endif
@foreach ($ejaculation->tags as $tag)
<a class="badge badge-secondary" href="{{ route('search', ['q' => $tag->name]) }}"><span class="oi oi-tag"></span> {{ $tag->name }}</a>
@endforeach
</p>
@endif
<!-- okazu link -->
@if (!empty($ejaculation->link))
<div class="row mx-0">
@component('components.link-card', ['link' => $ejaculation->link])
@endcomponent
<p class="d-flex align-items-baseline mb-2 col-12 px-0">
<span class="oi oi-link-intact mr-1"></span><a class="overflow-hidden" href="{{ $ejaculation->link }}" target="_blank" rel="noopener">{{ $ejaculation->link }}</a>
</p>
</div>
@endif
<!-- note -->
@if (!empty($ejaculation->note))
<p class="mb-0 text-break">
{!! Formatter::linkify(nl2br(e($ejaculation->note))) !!}
</p>
@endif
</li> </li>
@endforeach @endforeach
</ul> </ul>

View File

@ -11,6 +11,11 @@
<div class="custom-control custom-checkbox mb-2"> <div class="custom-control custom-checkbox mb-2">
<input id="protected" name="is_protected" class="custom-control-input" type="checkbox" {{ (old('is_protected') ?? Auth::user()->is_protected ) ? 'checked' : '' }}> <input id="protected" name="is_protected" class="custom-control-input" type="checkbox" {{ (old('is_protected') ?? Auth::user()->is_protected ) ? 'checked' : '' }}>
<label class="custom-control-label" for="protected">全てのチェックイン履歴を非公開にする</label> <label class="custom-control-label" for="protected">全てのチェックイン履歴を非公開にする</label>
<small class="form-text text-muted">プロフィール情報を除いて、全ての情報が非公開になります。</small>
</div>
<div class="custom-control custom-checkbox mb-2">
<input id="private-likes" name="private_likes" class="custom-control-input" type="checkbox" {{ (old('private_likes') ?? Auth::user()->private_likes ) ? 'checked' : '' }}>
<label class="custom-control-label" for="private-likes">いいねしたチェックイン一覧を非公開にする</label>
</div> </div>
<div class="custom-control custom-checkbox"> <div class="custom-control custom-checkbox">
<input id="accept-analytics" name="accept_analytics" class="custom-control-input" type="checkbox" {{ (old('accept_analytics') ?? Auth::user()->accept_analytics ) ? 'checked' : '' }}> <input id="accept-analytics" name="accept_analytics" class="custom-control-input" type="checkbox" {{ (old('accept_analytics') ?? Auth::user()->accept_analytics ) ? 'checked' : '' }}>

View File

@ -8,6 +8,11 @@
<form action="{{ route('setting.profile.update') }}" method="post"> <form action="{{ route('setting.profile.update') }}" method="post">
{{ csrf_field() }} {{ csrf_field() }}
<div class="from-group"> <div class="from-group">
<label for="name">アイコン</label>
<img src="{{ Auth::user()->getProfileImageUrl(128) }}" class="rounded d-block">
<small class="form-text text-muted">変更は<a href="https://gravatar.com/" target="_blank">Gravatar</a>から行えます。</small>
</div>
<div class="from-group mt-3">
<label for="display_name">名前</label> <label for="display_name">名前</label>
<input id="display_name" name="display_name" type="text" class="form-control {{ $errors->has('display_name') ? ' is-invalid' : '' }}" <input id="display_name" name="display_name" type="text" class="form-control {{ $errors->has('display_name') ? ' is-invalid' : '' }}"
value="{{ old('display_name') ?? Auth::user()->display_name }}" maxlength="20" autocomplete="off"> value="{{ old('display_name') ?? Auth::user()->display_name }}" maxlength="20" autocomplete="off">
@ -26,6 +31,11 @@
</div> </div>
<small class="form-text text-muted">現在は変更できません。</small> <small class="form-text text-muted">現在は変更できません。</small>
</div> </div>
<div class="from-group mt-3">
<label for="name">メールアドレス</label>
<input id="name" name="name" type="text" class="form-control" value="{{ Auth::user()->email }}" disabled>
<small class="form-text text-muted">現在は変更できません。</small>
</div>
<div class="form-group mt-3"> <div class="form-group mt-3">
<label for="bio">自己紹介</label> <label for="bio">自己紹介</label>
<textarea id="bio" name="bio" rows="3" class="form-control {{ $errors->has('bio') ? ' is-invalid' : '' }}">{{ old('bio') ?? Auth::user()->bio }}</textarea> <textarea id="bio" name="bio" rows="3" class="form-control {{ $errors->has('bio') ? ' is-invalid' : '' }}">{{ old('bio') ?? Auth::user()->bio }}</textarea>

View File

@ -11,43 +11,8 @@
<div class="row mx-1"> <div class="row mx-1">
@foreach($ejaculations as $ejaculation) @foreach($ejaculations as $ejaculation)
<div class="col-12 col-lg-6 col-xl-4 py-3 text-break border-top"> <div class="col-12 col-lg-6 col-xl-4 py-3 text-break border-top">
<!-- span --> @component('components.ejaculation', compact('ejaculation'))
<div class="d-flex justify-content-between"> @endcomponent
<h5>
<a href="{{ route('user.profile', ['id' => $ejaculation->user->name]) }}" class="text-dark"><img src="{{ $ejaculation->user->getProfileImageUrl(30) }}" width="30" height="30" class="rounded d-inline-block align-bottom"> <bdi>{{ $ejaculation->user->display_name }}</bdi></a>
<a href="{{ route('checkin.show', ['id' => $ejaculation->id]) }}" class="text-muted"><small>{{ $ejaculation->ejaculated_date->format('Y/m/d H:i') }}</small></a>
</h5>
<div>
<a class="text-secondary timeline-action-item" href="{{ route('checkin', ['link' => $ejaculation->link, 'tags' => $ejaculation->textTags()]) }}"><span class="oi oi-reload" data-toggle="tooltip" data-placement="bottom" title="同じオカズでチェックイン"></span></a>
</div>
</div>
<!-- tags -->
@if ($ejaculation->is_private || $ejaculation->tags->isNotEmpty())
<p class="mb-2">
@if ($ejaculation->is_private)
<span class="badge badge-warning"><span class="oi oi-lock-locked"></span> 非公開</span>
@endif
@foreach ($ejaculation->tags as $tag)
<a class="badge badge-secondary" href="{{ route('search', ['q' => $tag->name]) }}"><span class="oi oi-tag"></span> {{ $tag->name }}</a>
@endforeach
</p>
@endif
<!-- okazu link -->
@if (!empty($ejaculation->link))
<div class="row mx-0">
@component('components.link-card', ['link' => $ejaculation->link])
@endcomponent
<p class="d-flex align-items-baseline mb-2 col-12 px-0">
<span class="oi oi-link-intact mr-1"></span><a class="overflow-hidden" href="{{ $ejaculation->link }}" target="_blank" rel="noopener">{{ $ejaculation->link }}</a>
</p>
</div>
@endif
<!-- note -->
@if (!empty($ejaculation->note))
<p class="mb-0 text-break">
{!! Formatter::linkify(nl2br(e($ejaculation->note))) !!}
</p>
@endif
</div> </div>
@endforeach @endforeach
</div> </div>

View File

@ -20,6 +20,13 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {{ Route::currentRouteName() === 'user.okazu' ? 'active' : '' }}" href="{{ route('user.okazu', ['name' => $user->name]) }}">オカズ</a> <a class="nav-link {{ Route::currentRouteName() === 'user.okazu' ? 'active' : '' }}" href="{{ route('user.okazu', ['name' => $user->name]) }}">オカズ</a>
</li> </li>
<li class="nav-item">
<a class="nav-link {{ Route::currentRouteName() === 'user.likes' ? 'active' : '' }}" href="{{ route('user.likes', ['name' => $user->name]) }}">いいね
@if ($user->isMe() || !($user->is_protected || $user->private_likes))
<span class="badge badge-primary">{{ $user->likes()->count() }}</span>
@endif
</a>
</li>
</ul> </ul>
<div class="tab-content"> <div class="tab-content">
@yield('tab-content') @yield('tab-content')

View File

@ -0,0 +1,25 @@
@extends('user.base')
@section('title', $user->display_name . ' (@' . $user->name . ') さんがいいねしたチェックイン')
@section('tab-content')
@if (($user->is_protected || $user->private_likes) && !$user->isMe())
<p class="mt-4">
<span class="oi oi-lock-locked"></span> このユーザはいいね一覧を公開していません。
</p>
@else
<ul class="list-group">
@forelse ($likes as $like)
<li class="list-group-item border-bottom-only pt-3 pb-3 text-break">
@component('components.ejaculation', ['ejaculation' => $like->ejaculation])
@endcomponent
</li>
@empty
<li class="list-group-item border-bottom-only">
<p>まだ何もいいと思ったことがありません。</p>
</li>
@endforelse
</ul>
{{ $likes->links(null, ['className' => 'mt-4 justify-content-center']) }}
@endif
@endsection

View File

@ -36,15 +36,8 @@
@forelse ($ejaculations as $ejaculation) @forelse ($ejaculations as $ejaculation)
<li class="list-group-item border-bottom-only pt-3 pb-3 text-break"> <li class="list-group-item border-bottom-only pt-3 pb-3 text-break">
<!-- span --> <!-- span -->
<div class="d-flex justify-content-between"> <div>
<h5>{{ $ejaculation->ejaculated_span ?? '精通' }} <a href="{{ route('checkin.show', ['id' => $ejaculation->id]) }}" class="text-muted"><small>{{ $ejaculation->before_date }}{{ !empty($ejaculation->before_date) ? ' ' : '' }}{{ $ejaculation->ejaculated_date->format('Y/m/d H:i') }}</small></a></h5> <h5>{{ $ejaculation->ejaculated_span ?? '精通' }} <a href="{{ route('checkin.show', ['id' => $ejaculation->id]) }}" class="text-muted"><small>{{ $ejaculation->before_date }}{{ !empty($ejaculation->before_date) ? ' ' : '' }}{{ $ejaculation->ejaculated_date->format('Y/m/d H:i') }}</small></a></h5>
<div>
<a class="text-secondary timeline-action-item" href="{{ route('checkin', ['link' => $ejaculation->link, 'tags' => $ejaculation->textTags()]) }}"><span class="oi oi-reload" data-toggle="tooltip" data-placement="bottom" title="同じオカズでチェックイン"></span></a>
@if ($user->isMe())
<a class="text-secondary timeline-action-item" href="{{ route('checkin.edit', ['id' => $ejaculation->id]) }}"><span class="oi oi-pencil" data-toggle="tooltip" data-placement="bottom" title="修正"></span></a>
<a class="text-secondary timeline-action-item" href="#" data-target="#deleteCheckinModal" data-id="{{ $ejaculation->id }}" data-date="{{ $ejaculation->ejaculated_date }}"><span class="oi oi-trash" data-toggle="tooltip" data-placement="bottom" title="削除"></span></a>
@endif
</div>
</div> </div>
<!-- tags --> <!-- tags -->
@if ($ejaculation->is_private || $ejaculation->tags->isNotEmpty()) @if ($ejaculation->is_private || $ejaculation->tags->isNotEmpty())
@ -69,10 +62,32 @@
@endif @endif
<!-- note --> <!-- note -->
@if (!empty($ejaculation->note)) @if (!empty($ejaculation->note))
<p class="mb-0 text-break"> <p class="mb-2 text-break">
{!! Formatter::linkify(nl2br(e($ejaculation->note))) !!} {!! Formatter::linkify(nl2br(e($ejaculation->note))) !!}
</p> </p>
@endif @endif
<!-- likes -->
@if ($ejaculation->likes_count > 0)
<div class="my-2 py-1 border-top border-bottom d-flex align-items-center">
<div class="ml-2 mr-3 text-secondary flex-shrink-0"><small><strong>{{ $ejaculation->likes_count }}</strong> 件のいいね</small></div>
<div class="like-users flex-grow-1 overflow-hidden">
@foreach ($ejaculation->likes as $like)
@if ($like->user !== null)
<a href="{{ route('user.profile', ['name' => $like->user->name]) }}"><img src="{{ $like->user->getProfileImageUrl(30) }}" width="30" height="30" class="rounded" data-toggle="tooltip" data-placement="bottom" title="{{ $like->user->display_name }}"></a>
@endif
@endforeach
</div>
</div>
@endif
<!-- actions -->
<div class="ejaculation-actions">
<button type="button" class="btn btn-link text-secondary" data-toggle="tooltip" data-placement="bottom" title="同じオカズでチェックイン" data-href="{{ route('checkin', ['link' => $ejaculation->link, 'tags' => $ejaculation->textTags()]) }}"><span class="oi oi-reload"></span></button>
<button type="button" class="btn btn-link text-secondary like-button" data-toggle="tooltip" data-placement="bottom" data-trigger="hover" title="いいね" data-id="{{ $ejaculation->id }}" data-liked="{{ (bool)$ejaculation->is_liked }}"><span class="oi oi-heart {{ $ejaculation->is_liked ? 'text-danger' : '' }}"></span><span class="like-count">{{ $ejaculation->likes_count ? $ejaculation->likes_count : '' }}</span></button>
@if ($user->isMe())
<button type="button" class="btn btn-link text-secondary" data-toggle="tooltip" data-placement="bottom" title="修正" data-href="{{ route('checkin.edit', ['id' => $ejaculation->id]) }}"><span class="oi oi-pencil"></span></button>
<button type="button" class="btn btn-link text-secondary" data-toggle="tooltip" data-placement="bottom" title="削除" data-target="#deleteCheckinModal" data-id="{{ $ejaculation->id }}" data-date="{{ $ejaculation->ejaculated_date }}"><span class="oi oi-trash"></span></button>
@endif
</div>
</li> </li>
@empty @empty
<li class="list-group-item border-bottom-only"> <li class="list-group-item border-bottom-only">

View File

@ -16,3 +16,8 @@
//}); //});
Route::get('/checkin/card', 'Api\\CardController@show'); Route::get('/checkin/card', 'Api\\CardController@show');
Route::middleware('auth')->group(function () {
Route::post('/likes', 'Api\\LikeController@store');
Route::delete('/likes/{id}', 'Api\\LikeController@destroy');
});

View File

@ -19,6 +19,7 @@ Route::get('/user', 'UserController@redirectMypage')->middleware('auth');
Route::get('/user/{name?}', 'UserController@profile')->name('user.profile'); Route::get('/user/{name?}', 'UserController@profile')->name('user.profile');
Route::get('/user/{name}/stats', 'UserController@stats')->name('user.stats'); Route::get('/user/{name}/stats', 'UserController@stats')->name('user.stats');
Route::get('/user/{name}/okazu', 'UserController@okazu')->name('user.okazu'); Route::get('/user/{name}/okazu', 'UserController@okazu')->name('user.okazu');
Route::get('/user/{name}/likes', 'UserController@likes')->name('user.likes');
Route::get('/checkin/{id}', 'EjaculationController@show')->name('checkin.show'); Route::get('/checkin/{id}', 'EjaculationController@show')->name('checkin.show');
Route::middleware('auth')->group(function () { Route::middleware('auth')->group(function () {