diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 03e02a23..0344d9d7 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Http\Controllers\Traits\Responds; use Illuminate\Foundation\Bus\DispatchesJobs; use Illuminate\Routing\Controller as BaseController; use Illuminate\Foundation\Validation\ValidatesRequests; @@ -9,5 +10,5 @@ class Controller extends BaseController { - use AuthorizesRequests, DispatchesJobs, ValidatesRequests; + use AuthorizesRequests, DispatchesJobs, ValidatesRequests, Responds; } diff --git a/app/Http/Controllers/TreetController.php b/app/Http/Controllers/TreetController.php new file mode 100644 index 00000000..97a0baa9 --- /dev/null +++ b/app/Http/Controllers/TreetController.php @@ -0,0 +1,93 @@ +validate($request, [ + 'limit' => 'nullable|integer|min:6|max:90', + ]); + + $limit = $request->limit ?: 10; + + $treets = Treet::select(['id','app_name', 'image_path', 'description', 'url', 'created_at']) + ->orderBy('created_at', 'desc') + ->limit($limit) + ->get(); + + $treets->map(function ($treet) { + $treet->date = $treet->created_at->format('m/d/y'); + }); + + return $this->success($treets); + } + + /** + * Show the form for creating a new resource. + */ + public function create(Request $request) + { + $this->authorize('create', Treet::class); + + $urls = ['Treesnap' => "https://treesnap.org/", + 'FlorestaDB' => "https://app.florestadb.org/login", + 'HealthyWoods' => "https://healthywoodsapp.org/", + 'Avid Deer' => "https://aviddeer.com/", + 'Eastern Forest Pests' => "https://easternforestpests.com/"]; + + + $treet = Treet::create([ + 'app_name' => $request->app_name, + 'image_path' => $request->image_path, + 'description' => $request->description, + 'url' => $urls[$request->app_name] + ]); + + return $this->created($treet); + + } + /** + * Show the form for editing the specified resource. + */ + public function edit(Request $request, Treet $treet) + { + $urls = ['Treesnap' => "https://treesnap.org/", + 'FlorestaDB' => "https://app.florestadb.org/login", + 'HealthyWoods' => "https://healthywoodsapp.org/", + 'Avid Deer' => "https://aviddeer.com/", + 'Eastern Forest Pests' => "https://easternforestpests.com/"]; + + $this->authorize('edit', Treet::class); + + $treet = Treet::find($request->id); + + $treet->update([ + 'app_name' => $request->app_name ?: $treet->app_name, + 'image_path' => $request->image_path ?: $treet->image_path, + 'description' => $request->description ?: $treet->description, + 'url' => $urls[$request->app_name] + ]); + return $this->success($treet); + + } + + /** + * Remove the specified resource from storage. + */ + public function destroy(Request $request, Treet $treet) + { + $this->authorize('destroy', Treet::class); + + $treet->find($request->id)->delete(); + return $this->success($treet); + } + +} diff --git a/app/Policies/TreetPolicy.php b/app/Policies/TreetPolicy.php new file mode 100644 index 00000000..74b6ded0 --- /dev/null +++ b/app/Policies/TreetPolicy.php @@ -0,0 +1,69 @@ +isAdmin(); + + } + + /** + * Determine whether the user can update the model. + */ + public function edit(User $user): bool + { + return $user->isAdmin(); + + } + + /** + * Determine whether the user can delete the model. + */ + public function destroy(User $user): bool + { + return $user->isAdmin(); + + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, Treet $treet): bool + { + // + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, Treet $treet): bool + { + // + } +} diff --git a/app/Treet.php b/app/Treet.php new file mode 100644 index 00000000..0c4ab888 --- /dev/null +++ b/app/Treet.php @@ -0,0 +1,22 @@ + 'datetime', + ]; +} diff --git a/composer.json b/composer.json index e05a85a4..f1c87ac7 100755 --- a/composer.json +++ b/composer.json @@ -47,6 +47,7 @@ ], "psr-4": { "App\\": "app/", + "Database\\Factories\\": "database/factories/", "Database\\Seeders\\": "database/seeders/" } }, diff --git a/database/factories/ModelFactory.php b/database/factories/ModelFactory.php index d411c11e..f725ac4b 100644 --- a/database/factories/ModelFactory.php +++ b/database/factories/ModelFactory.php @@ -254,3 +254,19 @@ function fuzifyCoorinates($original_latitude, $original_longitude) 'observations_count' => $faker->numberBetween(10, 5000), ]; }); + + +$factory->define(\App\Treet::class, function (Faker\Generator $faker) { + $appNames = ['Treesnap','FlorestaDB','HealthyWoods', 'Avid Deer', 'Eastern Forest Pests']; + $imagePaths = ["../images/logos/treesnap_logo.png","../images/logos/florestadb_logo.png","../images/logos/healthywoods_logo.png","../images/logos/aviddeer_logo.png","../images/logos/efp_logo.png"]; + $urls = ["https://treesnap.org/","https://app.florestadb.org/login","https://healthywoodsapp.org/","https://aviddeer.com/","https://easternforestpests.com/"]; + $randApp = array_rand($appNames, 1); + + return [ + 'app_name' => $appNames[$randApp], + 'image_path' => $imagePaths[$randApp], + 'url' => $urls[$randApp], + 'description' => $faker->sentence() + ]; +}); + diff --git a/database/migrations/2024_05_14_164032_create_treets_table.php b/database/migrations/2024_05_14_164032_create_treets_table.php new file mode 100644 index 00000000..bdcfee14 --- /dev/null +++ b/database/migrations/2024_05_14_164032_create_treets_table.php @@ -0,0 +1,31 @@ +id(); + $table->text('app_name'); + $table->text('image_path'); + $table->text('url'); + $table->text('description'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('treets'); + } +}; diff --git a/database/seeders/TreetSeeder.php b/database/seeders/TreetSeeder.php new file mode 100644 index 00000000..87d92039 --- /dev/null +++ b/database/seeders/TreetSeeder.php @@ -0,0 +1,22 @@ +create(); + + } +} diff --git a/public/images/logos/aviddeer_logo.png b/public/images/logos/aviddeer_logo.png new file mode 100644 index 00000000..dc427179 Binary files /dev/null and b/public/images/logos/aviddeer_logo.png differ diff --git a/public/images/logos/efp_logo.png b/public/images/logos/efp_logo.png new file mode 100644 index 00000000..a1dfcfe4 Binary files /dev/null and b/public/images/logos/efp_logo.png differ diff --git a/public/images/logos/florestadb_logo.png b/public/images/logos/florestadb_logo.png new file mode 100644 index 00000000..a8db1399 Binary files /dev/null and b/public/images/logos/florestadb_logo.png differ diff --git a/public/images/logos/healthywoods_logo.png b/public/images/logos/healthywoods_logo.png new file mode 100644 index 00000000..1ff00514 Binary files /dev/null and b/public/images/logos/healthywoods_logo.png differ diff --git a/public/images/logos/treesnap_logo.png b/public/images/logos/treesnap_logo.png new file mode 100755 index 00000000..92612956 Binary files /dev/null and b/public/images/logos/treesnap_logo.png differ diff --git a/resources/assets/js/components/HomeJumbotron.jsx b/resources/assets/js/components/HomeJumbotron.jsx index 7faffb8a..5df2586c 100644 --- a/resources/assets/js/components/HomeJumbotron.jsx +++ b/resources/assets/js/components/HomeJumbotron.jsx @@ -25,7 +25,7 @@ export default class HomeJumbotron extends Component {

Meet the scientists that use TreeSnap data - Tutorials available on YouTube + Tutorials available on YouTube

diff --git a/resources/assets/js/components/RecentUpdates.jsx b/resources/assets/js/components/RecentUpdates.jsx new file mode 100644 index 00000000..02419756 --- /dev/null +++ b/resources/assets/js/components/RecentUpdates.jsx @@ -0,0 +1,18 @@ +import React, {Component} from 'react' + +export default class RecentUpdates extends Component { + render() { + return ( +
+
+

Recent Updates

+
+
+

The Hemlock survey has been updated to support the Lingering Hemlock Protocol

+
+
+
+
+ ) + } +} diff --git a/resources/assets/js/components/Treet.jsx b/resources/assets/js/components/Treet.jsx new file mode 100644 index 00000000..4d4a6132 --- /dev/null +++ b/resources/assets/js/components/Treet.jsx @@ -0,0 +1,160 @@ +import React, { Component } from 'react' + +export default class Treet extends Component { + constructor(props) { + super(props) + + this.state = { + loading: true, + isLoggedIn: false, + isEditing: false, + appNames: ['HealthyWoods', 'Eastern Forest Pests', 'Avid Deer', 'Treesnap', 'FlorestaDB'], + appName: '', + imagePath: this.props.treet.image_path, + description: this.props.treet.description, + currentAppName: this.props.treet.app_name, + } + this.toggle = this.toggle.bind(this) + this.onSubmit = this.onSubmit.bind(this) + this.renderAppName = this.renderAppName.bind(this) + this.handleChangeAppName = this.handleChangeAppName.bind(this) + this.handleChangeDescription = this.handleChangeDescription.bind(this) + + + } + componentDidMount() { + axios.get('/web/user/status').then(response => { + let data = response.data.data + this.setState({ + isLoggedIn: data.logged_in, + }) + }).catch(error => { + console.log(error) + }) + } + onSubmit(event) { + event.preventDefault(); + this.props.editTreet(this.props.treet.id,event.target.appName.value,this.state.imagePath,event.target.description.value) + + this.toggle() + } + handleChangeAppName(event) { + this.setState({appName: event.target.value}); + if(event.target.value == "Treesnap"){ + this.setState({imagePath: "../images/logos/treesnap_logo.png"}); + } + else if(event.target.value == "FlorestaDB"){ + this.setState({imagePath: "../images/logos/florestadb_logo.png"}); + } + else if(event.target.value == "HealthyWoods"){ + this.setState({imagePath: "../images/logos/healthywoods_logo.png"}); + } + else if(event.target.value == "Avid Deer"){ + this.setState({imagePath: "../images/logos/aviddeer_logo.png"}); + } + else if(event.target.value == "Eastern Forest Pests"){ + this.setState({imagePath: "../images/logos/efp_logo.png"}); + } + } + handleChangeDescription(event) { + this.setState({description: event.target.value}); + } + toggle() { + this.setState(prevState => ({ isEditing: !prevState.isEditing })); + } + renderAppName(appName) { + return + } + render() { + const treet = this.props.treet + return ( +
+
+ + {this.state.isEditing ? + <> +
+ +
+
+
+ + +
+ + + +
+
+
+ +
+ +
+
+
+
+
+
+ + +
+ + : + <> + +
+
+
+ + {treet.app_name} + +
+ +
+
{treet.app_name}
+
+
+
+
{treet.date}
+
+ + +
{/* end of row */} + +
+
{treet.description}
+
+ {this.state.isLoggedIn ? +
+ + +
+ :null} + + } + + + +
+
+ ) + } +} diff --git a/resources/assets/js/components/TwitterFeed.jsx b/resources/assets/js/components/TwitterFeed.jsx index dc9a8560..3ee9f309 100644 --- a/resources/assets/js/components/TwitterFeed.jsx +++ b/resources/assets/js/components/TwitterFeed.jsx @@ -1,24 +1,198 @@ -import React from 'react' +import React, { Component } from 'react' +import Treet from './Treet' + +export default class TwitterFeed extends Component { + constructor(props) { + super(props) + + this.state = { + isEditing: false, + isLoggedIn: false, + appNames: ['Eastern Forest Pests','HealthyWoods' , 'Avid Deer', 'Treesnap', 'FlorestaDB'], + appName: '', + imagePath: '', + description: '', + treets:[], + isOpen: false, + + + } + this.onSubmit = this.onSubmit.bind(this) + this.editTreet = this.editTreet.bind(this) + this.handleChangeAppName = this.handleChangeAppName.bind(this) + this.handleChangeDescription = this.handleChangeDescription.bind(this) + this.deleteTreet = this.deleteTreet.bind(this) + this.toggle = this.toggle.bind(this) + + } -export default class TwitterFeed extends React.Component { componentDidMount() { - this.interval = setInterval(() => { - if (window.twttr) { - window.twttr.widgets.load() - clearInterval(this.interval) + axios.get('/web/user/status').then(response => { + let data = response.data.data + this.setState({ + isLoggedIn: data.logged_in, + }) + }).catch(error => { + console.log(error) + }) + this.loadTreets() + + setInterval(this.loadTreets.bind(this), 120000) + } + toggle() { + this.setState(prevState => ({ isOpen: !prevState.isOpen })); + } + loadTreets() { + axios.get(`/web/treets/feed`).then(response => { + this.setState({treets: response.data.data, loading: false}) + }).catch(error => { + console.log(error) + this.setState({loading: false}) + }) + } + deleteTreet(treet){ + axios.delete(`/web/treet/${treet.id}`).then(response => { + this.loadTreets() + }).catch(error => { + console.log(error) + }) + } + editTreet(id, appName, imagePath, description) { + axios.put(`/web/treets/update/${id}`, { + app_name : appName, + image_path : imagePath, + description : description, + }).then(response => { + this.loadTreets() + + }).catch(error => { + if (error.response) { + console.log(error) } - }, 15) + }) + } + onSubmit(event) { + event.preventDefault(); + + axios.post('/web/treets/create', { + app_name : event.target.appName.value, + image_path : this.state.imagePath, + description : event.target.description.value, + }).then(response => { + this.loadTreets() + this.toggle() + this.setState({appName: ''}); + this.setState({description: ''}); + cosnole.log(response.data) + }).catch(error => { + if (error.response) { + console.log(error) + } + }) + } + handleChangeAppName(event) { + + this.setState({appName: event.target.value}); + if(event.target.value == "Treesnap"){ + this.setState({imagePath: "../images/logos/treesnap_logo.png"}); + } + else if(event.target.value == "FlorestaDB"){ + this.setState({imagePath: "../images/logos/florestadb_logo.png"}); + } + else if(event.target.value == "HealthyWoods"){ + this.setState({imagePath: "../images/logos/healthywoods_logo.png"}); + } + else if(event.target.value == "Avid Deer"){ + this.setState({imagePath: "../images/logos/aviddeer_logo.png"}); + } + else if(event.target.value == "Eastern Forest Pests"){ + this.setState({imagePath: "../images/logos/efp_logo.png"}); + } + } + handleChangeDescription(event) { + + this.setState({description: event.target.value}); + } + renderButton(isOpen) { + let button; + if(isOpen){ + return ; + } + return ; } - render() { + renderAppName(appName) { return ( + + + ) + } + +render() { + const treetList = this.state.treets.map((treet)=>( )) + + return (
- Tweets by Treesnapapp + {this.state.isLoggedIn ? + + this.renderButton(this.state.isOpen) + :null} + {this.state.isOpen ? +
+ +
+
+
+ + +
+ + + +
+
+
+ +
+ +
+
+ +
+
+
+ :null} +
+ {this.state.loading ? +

+ +

+ : null} + + {treetList} + {this.state.treets.length === 0 && !this.state.loading ? +

There are no treets at this time

+ : null}
+ +
) } } diff --git a/resources/assets/js/scenes/WelcomeScene.jsx b/resources/assets/js/scenes/WelcomeScene.jsx index aebbd072..d2fce896 100644 --- a/resources/assets/js/scenes/WelcomeScene.jsx +++ b/resources/assets/js/scenes/WelcomeScene.jsx @@ -2,30 +2,53 @@ import React from 'react' import Navbar from '../components/Navbar' import HomeJumbotron from '../components/HomeJumbotron' import FeaturesList from '../components/FeaturesList' +import RecentUpdates from '../components/RecentUpdates' import HomeFooter from '../components/HomeFooter' import Leaderboard from '../components/Leaderboard' import TwitterFeed from '../components/TwitterFeed' import ObservationFeed from '../components/ObservationsFeed' import Scene from './Scene' +import Dropdown from '../components/Dropdown' +import User from '../helpers/User' + + export default class Welcome extends Scene { constructor(props) { super(props) + this.state = { + isLoggedIn: false, + loading: true, + } document.title = 'TreeSnap - Help Our Nation\'s Trees!' } + componentDidMount() { + axios.get('/web/user/status').then(response => { + let data = response.data.data + this.setState({ + isLoggedIn: data.logged_in, + }) + }).catch(error => { + console.log(error) + }) + } + + /** * Render the scene. * * @returns {XML} */ + render() { return (
+
@@ -40,11 +63,13 @@ export default class Welcome extends Scene {

Latest Observations

- {/*
*/} - {/*

Tweets

*/} - {/*

Latest Tweets by @treesnapapp

*/} - {/* */} - {/*
*/} +
+
+

Recent Updates

+
+

Latest Updates from Staton Lab

+ +
diff --git a/resources/assets/sass/_hexagon.scss b/resources/assets/sass/_hexagon.scss index aafde16e..e39b14e6 100644 --- a/resources/assets/sass/_hexagon.scss +++ b/resources/assets/sass/_hexagon.scss @@ -8,6 +8,11 @@ strong { color: $white; } + } + &.is-lighter-light { + background-color: $primary; + + color: $white; } } diff --git a/resources/assets/sass/_layout.scss b/resources/assets/sass/_layout.scss index 5246f96f..87193ef9 100644 --- a/resources/assets/sass/_layout.scss +++ b/resources/assets/sass/_layout.scss @@ -47,7 +47,7 @@ body, } .flex-space-between { - @include justify-content(space-between); + justify-content: space-between; } .flex-wrap { @@ -120,9 +120,6 @@ a.box { } } -.image-gallery-slide-wrapper { - //background-color: #222; -} .image-gallery-slide { background-color: transparent !important; @@ -327,27 +324,105 @@ a.box { } .disabled { - justify-content: center; - padding-bottom: calc(0.5em - 1px); - padding-left: 1em; - padding-right: 1em; - padding-top: calc(0.5em - 1px); - text-align: center; - white-space: nowrap; - flex-direction: row; + justify-content: center; + padding-bottom: calc(0.5em - 1px); + padding-left: 1em; + padding-right: 1em; + padding-top: calc(0.5em - 1px); + text-align: center; + white-space: nowrap; + flex-direction: row; } -.flex{ +.update-row { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; +} + +.recent-updates { + padding: 2rem 0; + background-color: #289482; + // background-color: whitesmoke; +} + +.update-marquee { + height: 100%; + width: 100%; display: flex; + flex-direction: column; + align-items: center; +} + +.update-card { + background-color: white; + height: 200px; + width: 600px; + border-radius: 2px; + padding: 20px; } -.flex-row{ + +.w-100 { + width: 100%; +} + +.recent-updates-form { + display: flex; + flex-direction: column; + justify-content: center; +} + +.input-width { + min-width: 250px; +} + +.textarea-height-8em { + height: 8em; +} + +.textarea-height-4em { + height: 4em; +} + +.flex-column-left { + display: flex; + flex-direction: column; + align-items: start; +} + +.flex-column-center { + display: flex; + flex-direction: column; + align-items: center; +} + +.flex { + display: flex; +} + +.flex-row { display: flex; flex-direction: row; + align-items: center; +} + +.edit { + display: flex; + width: 100%; + margin-top: 10px; } -.flex-col{ + +.text-white { + color: white; +} + + +.flex-col { display: flex; flex-direction: column; } -.fit-content{ + +.fit-content { width: fit-content; -} \ No newline at end of file +} diff --git a/routes/web-services.php b/routes/web-services.php index 7f33a89b..a0c92513 100644 --- a/routes/web-services.php +++ b/routes/web-services.php @@ -24,3 +24,11 @@ // Refresh OAuth Tokens Route::post('/refresh-tokens', 'TokensController@refreshTokens'); }); +Route::group([ + 'prefix' => 'v1', + 'middleware' => ['auth:web-services'], +], function () { + // Treet Routes + Route::get('/treets/feed', 'TreetController@index'); + +}); diff --git a/routes/web.php b/routes/web.php index f88f6855..ac430c4a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -84,8 +84,13 @@ // Public Routes in the Confirmations Controller Route::get('/web/confirmations/count/{id}', 'PublicConfirmationsController@count'); +Route::get('/web/treets/feed', 'TreetController@index'); +Route::delete('/web/treet/{id}', 'TreetController@destroy'); +Route::post('/web/treets/create', 'TreetController@create'); +Route::put('/web/treets/update/{id}', 'TreetController@edit'); // Authenticated Users Only (could be admin, scientist or user) Route::group(['middleware' => ['auth']], function () { + // Treets // Observations Route::delete('/web/observation/{id}', 'ObservationsController@delete'); diff --git a/tests/Feature/ObservationsWebServiceTest.php b/tests/Feature/ObservationsWebServiceTest.php index 041375cc..13ee904e 100644 --- a/tests/Feature/ObservationsWebServiceTest.php +++ b/tests/Feature/ObservationsWebServiceTest.php @@ -4,6 +4,7 @@ use App\Observation; use App\User; +use App\Treet; use Illuminate\Foundation\Testing\DatabaseTransactions; use Laravel\Passport\Passport; use Tests\TestCase; @@ -17,7 +18,26 @@ protected function setUp(): void parent::setUp(); $this->withHeader('Accept', 'application/json'); } + public function testGetTreets() + { + /** @var User $user */ + $user = factory(User::class)->create(); + $token = $user->createToken(uniqid()); + + factory(Treet::class, 10)->create(); + + $this->actingAs($user); + $response = $this->withHeader('Authorization', "Bearer $token->accessToken") + ->get('/web-services/v1/treets/feed', [ + 'limit' => 10 + ]); + + // dd($response->json()); + + $response->assertStatus(200); + $response->assertJsonStructure($this->getTreetResponseStructure()); + } public function testMyObservationsService() { /** @var User $user */ @@ -75,6 +95,22 @@ public function testGettingAnObservation() $response->assertSuccessful(); } + protected function getTreetResponseStructure() + { + return [ + 'error_code', + 'data' => [ + [ + 'id', + 'app_name', + 'description', + 'url', + 'created_at', + 'date', + ], + ], + ]; + } protected function getPaginatedObservationResponseStructure() { return [ diff --git a/tests/Feature/RecentUpdatesTest.php b/tests/Feature/RecentUpdatesTest.php new file mode 100644 index 00000000..a87a0df2 --- /dev/null +++ b/tests/Feature/RecentUpdatesTest.php @@ -0,0 +1,94 @@ +create([ + 'id'=>999, + 'app_name'=>'test', + 'description'=>'testing' + ]); + $response = $this->get('/web/treets/feed'); + $response->assertSuccessful(); + } + public function test_edit(): void + { + //create admin user + $user = factory(User::class)->create([ + 'role_id' => Role::where('name', 'Admin')->first()->id, + ]); + $this->actingAs($user); + + //create entry + factory(Treet::class)->create([ + 'id'=>999, + 'app_name'=>'test', + 'description'=>'unchanged' + ]); + //get description + $response = $this->get('/web/treets/feed'); + $description1 = $response->json()['data'][0]['description']; + //edit description + $response = $this->put('/web/treets/update/999',[ + 'description'=>'changed' + ]); + $description2 = $response->json()['data']['description']; + + + //compare descriptions + $this->assertNotEquals($description1, $description2); + + + } + public function test_create(): void + { + //create admin user + $user = factory(User::class)->create([ + 'role_id' => Role::where('name', 'Admin')->first()->id, + ]); + $this->actingAs($user); + + //create entry + $response = $this->post('/web/treets/create',[ + 'app_name' => 'Test Name', + 'image_path' => 'ImagePath', + 'description' => 'Description' + ]); + + $response->assertSuccessful(); + + } + public function test_destroy(): void + { + //create admin user + $user = factory(User::class)->create([ + 'role_id' => Role::where('name', 'Admin')->first()->id, + ]); + $this->actingAs($user); + + //create entry + factory(Treet::class)->create([ + 'id'=>999, + 'app_name'=>'test', + 'description'=>'testing' + ]); + //delete entry + $response = $this->delete('/web/treet/999'); + + //assert that entry has been removed + $this->assertFalse(Treet::where('id','999')->exists()); + + } +}