diff --git a/Pipfile b/Pipfile index 4d377014ae..f75fb3d98a 100644 --- a/Pipfile +++ b/Pipfile @@ -20,6 +20,7 @@ typing-extensions = "*" flask-jwt-extended = "==4.6.0" wtforms = "==3.1.2" sqlalchemy = "*" +flask-bcrypt = "*" [requires] python_version = "3.13" diff --git a/Pipfile.lock b/Pipfile.lock index d9e474e972..38336a5b2a 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "ffbfb32d0afa5e4bcaba5c2d08c81381a97abd90f22284d2b76647365df5dc50" + "sha256": "9c612f7eebcf717533779dc02b40fce5eeaf66bfab71c81d166828128c272240" }, "pipfile-spec": 6, "requires": { @@ -24,6 +24,75 @@ "markers": "python_version >= '3.10'", "version": "==1.17.1" }, + "bcrypt": { + "hashes": [ + "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", + "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", + "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", + "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", + "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", + "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", + "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", + "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", + "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", + "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", + "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", + "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", + "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", + "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", + "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", + "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", + "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", + "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", + "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", + "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", + "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", + "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", + "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", + "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", + "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", + "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", + "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", + "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", + "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", + "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", + "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", + "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", + "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", + "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", + "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", + "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", + "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", + "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", + "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", + "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", + "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", + "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", + "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", + "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", + "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", + "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", + "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", + "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", + "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", + "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", + "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", + "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", + "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", + "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", + "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", + "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", + "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", + "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", + "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", + "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", + "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", + "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", + "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b" + ], + "markers": "python_version >= '3.8'", + "version": "==5.0.0" + }, "blinker": { "hashes": [ "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", @@ -42,11 +111,11 @@ }, "click": { "hashes": [ - "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", - "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4" + "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", + "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96" ], "markers": "python_version >= '3.10'", - "version": "==8.3.0" + "version": "==8.4.1" }, "cloudinary": { "hashes": [ @@ -58,12 +127,12 @@ }, "flask": { "hashes": [ - "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", - "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c" + "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", + "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c" ], "index": "pypi", "markers": "python_version >= '3.9'", - "version": "==3.1.2" + "version": "==3.1.3" }, "flask-admin": { "hashes": [ @@ -74,6 +143,14 @@ "markers": "python_version >= '3.10'", "version": "==2.0.0" }, + "flask-bcrypt": { + "hashes": [ + "sha256:062fd991dc9118d05ac0583675507b9fe4670e44416c97e0e6819d03d01f808a", + "sha256:f07b66b811417ea64eb188ae6455b0b708a793d966e1a80ceec4a23bc42a4369" + ], + "index": "pypi", + "version": "==1.0.1" + }, "flask-cors": { "hashes": [ "sha256:c7b2cbfb1a31aa0d2e5341eea03a6805349f7a61647daee1a15c46bbe981494c", @@ -575,11 +652,11 @@ }, "werkzeug": { "hashes": [ - "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", - "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746" + "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", + "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44" ], "markers": "python_version >= '3.9'", - "version": "==3.1.3" + "version": "==3.1.8" }, "wtforms": { "hashes": [ diff --git a/index.html b/index.html index 27a99f796e..053231b6da 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,7 @@ + Hello Rigo diff --git a/migrations/versions/0763d677d453_.py b/migrations/versions/0763d677d453_.py deleted file mode 100644 index 88964176f1..0000000000 --- a/migrations/versions/0763d677d453_.py +++ /dev/null @@ -1,35 +0,0 @@ -"""empty message - -Revision ID: 0763d677d453 -Revises: -Create Date: 2025-02-25 14:47:16.337069 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '0763d677d453' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('user', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('email', sa.String(length=120), nullable=False), - sa.Column('password', sa.String(), nullable=False), - sa.Column('is_active', sa.Boolean(), nullable=False), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('email') - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('user') - # ### end Alembic commands ### diff --git a/migrations/versions/e14276dda57d_.py b/migrations/versions/e14276dda57d_.py new file mode 100644 index 0000000000..83ec6aae4b --- /dev/null +++ b/migrations/versions/e14276dda57d_.py @@ -0,0 +1,55 @@ +"""empty message + +Revision ID: e14276dda57d +Revises: +Create Date: 2026-06-26 17:23:17.959680 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'e14276dda57d' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('follow', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('foro_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['foro_id'], ['foro.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('comment', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('content', sa.String(length=255), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('post_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['post_id'], ['post.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_column('created_at') + batch_op.drop_column('updated_at') + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=False)) + batch_op.add_column(sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=False)) + + op.drop_table('comment') + op.drop_table('follow') + # ### end Alembic commands ### diff --git a/migrations/versions/fa6aabc3e826_.py b/migrations/versions/fa6aabc3e826_.py new file mode 100644 index 0000000000..0308ef399d --- /dev/null +++ b/migrations/versions/fa6aabc3e826_.py @@ -0,0 +1,34 @@ +"""empty message + +Revision ID: fa6aabc3e826 +Revises: e14276dda57d +Create Date: 2026-06-27 11:50:06.432812 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'fa6aabc3e826' +down_revision = 'e14276dda57d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False)) + batch_op.add_column(sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_column('updated_at') + batch_op.drop_column('created_at') + + # ### end Alembic commands ### diff --git a/package-lock.json b/package-lock.json index 8d43d98ab7..96b93b961d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "prop-types": "^15.8.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-icons": "^5.6.0", "react-router-dom": "^6.18.0" }, "devDependencies": { @@ -877,19 +878,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", @@ -997,14 +985,6 @@ "@babel/types": "^7.20.7" } }, - "node_modules/@types/node": { - "version": "16.11.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.12.tgz", - "integrity": "sha512-+2Iggwg7PxoO5Kyhvsq9VarmPbIelXP070HMImEpbtGCoyWNINQj4wzjbQCXzdHTRXnqufutJb5KAURZANNBAw==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/@types/prop-types": { "version": "15.7.14", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", @@ -1307,14 +1287,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -3506,6 +3478,15 @@ "react": "^18.3.1" } }, + "node_modules/react-icons": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", + "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -3915,29 +3896,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -4074,35 +4032,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/terser": { - "version": "5.38.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.38.1.tgz", - "integrity": "sha512-GWANVlPM/ZfYzuPHjq0nxT+EbOEDDN3Jwhwdg1D8TU8oSkktp8w64Uq4auuGLxFSoNTRDncTq2hQHX1Ld9KHkA==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -4944,18 +4873,6 @@ "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true }, - "@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, "@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", @@ -5044,14 +4961,6 @@ "@babel/types": "^7.20.7" } }, - "@types/node": { - "version": "16.11.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.12.tgz", - "integrity": "sha512-+2Iggwg7PxoO5Kyhvsq9VarmPbIelXP070HMImEpbtGCoyWNINQj4wzjbQCXzdHTRXnqufutJb5KAURZANNBAw==", - "dev": true, - "optional": true, - "peer": true - }, "@types/prop-types": { "version": "15.7.14", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", @@ -5252,14 +5161,6 @@ "update-browserslist-db": "^1.1.1" } }, - "buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "optional": true, - "peer": true - }, "call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -6715,6 +6616,12 @@ "scheduler": "^0.23.2" } }, + "react-icons": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", + "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", + "requires": {} + }, "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -6982,28 +6889,6 @@ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true }, - "source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - }, - "dependencies": { - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "optional": true, - "peer": true - } - } - }, "string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -7094,30 +6979,6 @@ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true }, - "terser": { - "version": "5.38.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.38.1.tgz", - "integrity": "sha512-GWANVlPM/ZfYzuPHjq0nxT+EbOEDDN3Jwhwdg1D8TU8oSkktp8w64Uq4auuGLxFSoNTRDncTq2hQHX1Ld9KHkA==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "dependencies": { - "commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "optional": true, - "peer": true - } - } - }, "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", diff --git a/package.json b/package.json index 0caab10749..05b8fd5fb9 100755 --- a/package.json +++ b/package.json @@ -8,10 +8,10 @@ "main": "index.js", "scripts": { "dev": "vite", - "start": "vite", - "build": "vite build", - "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", - "preview": "vite preview" + "start": "vite", + "build": "vite build", + "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" }, "author": { "name": "Alejandro Sanchez", @@ -30,13 +30,13 @@ "license": "ISC", "devDependencies": { "@types/react": "^18.2.18", - "@types/react-dom": "^18.2.7", - "@vitejs/plugin-react": "^4.0.4", - "eslint": "^8.46.0", - "eslint-plugin-react": "^7.33.1", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-react-refresh": "^0.4.3", - "vite": "^4.4.8" + "@types/react-dom": "^18.2.7", + "@vitejs/plugin-react": "^4.0.4", + "eslint": "^8.46.0", + "eslint-plugin-react": "^7.33.1", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.3", + "vite": "^4.4.8" }, "babel": { "presets": [ @@ -55,8 +55,9 @@ }, "dependencies": { "prop-types": "^15.8.1", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-router-dom": "^6.18.0" + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-icons": "^5.6.0", + "react-router-dom": "^6.18.0" } } diff --git a/src/api/admin.py b/src/api/admin.py index d1bd1d25ba..e450adc950 100644 --- a/src/api/admin.py +++ b/src/api/admin.py @@ -1,8 +1,8 @@ import os import inspect from flask_admin import Admin -from . import models -from .models import db +from api import models +from api.database.db import db from flask_admin.contrib.sqla import ModelView from flask_admin.theme import Bootstrap4Theme diff --git a/src/api/cloudinary/cloudinary_config.py b/src/api/cloudinary/cloudinary_config.py new file mode 100644 index 0000000000..18230f12a3 --- /dev/null +++ b/src/api/cloudinary/cloudinary_config.py @@ -0,0 +1,14 @@ +import os +import cloudinary + +print("CLOUD_NAME =", os.getenv("CLOUDINARY_CLOUD_NAME")) +print("API_KEY =", os.getenv("CLOUDINARY_API_KEY")) +print("API_SECRET =", os.getenv("CLOUDINARY_API_SECRET")) + + +cloudinary.config( + cloud_name= os.getenv("CLOUDINARY_CLOUD_NAME"), + api_key= os.getenv("CLOUDINARY_API_KEY"), + api_secret= os.getenv("CLOUDINARY_API_SECRET"), + secure=True +) \ No newline at end of file diff --git a/src/api/commands.py b/src/api/commands.py index 19806164d3..d934329676 100644 --- a/src/api/commands.py +++ b/src/api/commands.py @@ -1,6 +1,6 @@ import click -from api.models import db, User +from api.models.models_user import db, User """ In this file, you can add as many commands as you want using the @app.cli.command decorator diff --git a/src/api/database/db.py b/src/api/database/db.py new file mode 100644 index 0000000000..f0b13d6f2a --- /dev/null +++ b/src/api/database/db.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/src/api/extension.py b/src/api/extension.py new file mode 100644 index 0000000000..4f7710b60b --- /dev/null +++ b/src/api/extension.py @@ -0,0 +1,3 @@ +from flask_bcrypt import Bcrypt + +bcrypt = Bcrypt() \ No newline at end of file diff --git a/src/api/models.py b/src/api/models.py deleted file mode 100644 index da515f6a1a..0000000000 --- a/src/api/models.py +++ /dev/null @@ -1,19 +0,0 @@ -from flask_sqlalchemy import SQLAlchemy -from sqlalchemy import String, Boolean -from sqlalchemy.orm import Mapped, mapped_column - -db = SQLAlchemy() - -class User(db.Model): - id: Mapped[int] = mapped_column(primary_key=True) - email: Mapped[str] = mapped_column(String(120), unique=True, nullable=False) - password: Mapped[str] = mapped_column(nullable=False) - is_active: Mapped[bool] = mapped_column(Boolean(), nullable=False) - - - def serialize(self): - return { - "id": self.id, - "email": self.email, - # do not serialize the password, its a security breach - } \ No newline at end of file diff --git a/src/api/models/__init__.py b/src/api/models/__init__.py new file mode 100644 index 0000000000..eca0f8726c --- /dev/null +++ b/src/api/models/__init__.py @@ -0,0 +1,9 @@ +from api.database.db import db +from api.models.models_user import User +from api.models.models_foro import Foro +from api.models.model_follow import Follow +from api.models.model_post import Post +from api.models.model_tag import Tag +from api.models.model_select_tag import Tag_select +from api.models.model_comment import Comment + diff --git a/src/api/models/model_comment.py b/src/api/models/model_comment.py new file mode 100644 index 0000000000..21f9c6f3c2 --- /dev/null +++ b/src/api/models/model_comment.py @@ -0,0 +1,34 @@ +from api.database.db import db +from sqlalchemy import String, Boolean, Text, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from typing import List +from datetime import datetime + + +class Comment (db.Model): + id: Mapped[int] = mapped_column(primary_key=True) + content: Mapped[str] = mapped_column(String(255),nullable=False) + user_id = mapped_column(ForeignKey("user.id"), nullable=False) + post_id: Mapped[int] = mapped_column(ForeignKey("post.id"), nullable=False) + created_at: Mapped[datetime] = mapped_column(server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now()) + user = db.relationship('User', overlaps="comment") + post = db.relationship('Post', overlaps="comment") + + + def serialize(self): + return { + "id": self.id, + "user_id": self.user_id, + "post_id": self.post_id, + "content": self.content, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None + } + + def serialize_comment(self): + return { + "id": self.id, + "user_id": self.user_id, + "content": self.content + } \ No newline at end of file diff --git a/src/api/models/model_follow.py b/src/api/models/model_follow.py new file mode 100644 index 0000000000..1cdac49d4d --- /dev/null +++ b/src/api/models/model_follow.py @@ -0,0 +1,18 @@ +from api.database.db import db +from sqlalchemy import String, Boolean, Text, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +class Follow(db.Model): + id: Mapped[int] = mapped_column(primary_key=True) + user_id = mapped_column(ForeignKey ("user.id"), nullable=False) + foro_id = mapped_column(ForeignKey ("foro.id"), nullable=False) + user = relationship("User") + foro = relationship("Foro") + + + def serialize_follow(self): + return{ + "id": self.id, + "user_id": self.user_id, + "foro_id": self.foro_id + } \ No newline at end of file diff --git a/src/api/models/model_post.py b/src/api/models/model_post.py new file mode 100644 index 0000000000..007cd10bfb --- /dev/null +++ b/src/api/models/model_post.py @@ -0,0 +1,35 @@ +from api.database.db import db +from sqlalchemy import String, Boolean, Text, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from typing import List +from datetime import datetime + + + + +class Post (db.Model): + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(120), nullable=False) + content: Mapped[str] = mapped_column(String(255),nullable=False) + img: Mapped[str] = mapped_column(Text,nullable=True) + user_id = mapped_column(ForeignKey("user.id"), nullable=False) + user = relationship('User', back_populates="post") + foro_id: Mapped[int] = mapped_column(ForeignKey("foro.id"), nullable=True) + foro = relationship("Foro", back_populates="post") + comment = relationship('Comment') + created_at: Mapped[datetime] = mapped_column(server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now()) + + + def serialize_post(self): + return { + "id": self.id, + "title": self.title, + "content": self.content, + "img": self.img, + "user_id": self.user_id, + "foro_id": self.foro_id, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "user": {"name":self.user.username, "img_user": self.user.img} + } \ No newline at end of file diff --git a/src/api/models/model_select_tag.py b/src/api/models/model_select_tag.py new file mode 100644 index 0000000000..0720a4443f --- /dev/null +++ b/src/api/models/model_select_tag.py @@ -0,0 +1,30 @@ +from api.database.db import db +from sqlalchemy import String, Boolean, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from typing import List + + + + +class Tag_select(db.Model): + id: Mapped[int] = mapped_column(primary_key=True) + user_id = mapped_column(ForeignKey("user.id"), nullable=True) + foro_id = mapped_column(ForeignKey("foro.id"), nullable=True) + tag_id = mapped_column(ForeignKey("tag.id"), nullable=False) + user = db.relationship('User', overlaps="tag") + foro = db.relationship('Foro', overlaps="tag") + tag = db.relationship('Tag', overlaps="tag") + + def serialize_tag_user(self): + return{ + "id": self.id, + "user_id": self.user_id, + "tag": self.tag.serialize_tag() + } + + def serialize_tag_foro(self): + return{ + "id": self.id, + "foro_id": self.foro_id, + "tag": self.tag.serialize_tag() + } \ No newline at end of file diff --git a/src/api/models/model_tag.py b/src/api/models/model_tag.py new file mode 100644 index 0000000000..bde433fd9f --- /dev/null +++ b/src/api/models/model_tag.py @@ -0,0 +1,17 @@ +from api.database.db import db +from sqlalchemy import String, Boolean, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from typing import List + + + +class Tag(db.Model): + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(120), unique=True, nullable=False) + tag = relationship('Tag_select') + + def serialize_tag(self): + return{ + "id": self.id, + "title": self.title, + } \ No newline at end of file diff --git a/src/api/models/models_foro.py b/src/api/models/models_foro.py new file mode 100644 index 0000000000..5d558f0294 --- /dev/null +++ b/src/api/models/models_foro.py @@ -0,0 +1,38 @@ +from api.database.db import db +from sqlalchemy import String, Boolean, Text, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from typing import List +from datetime import datetime + + + +class Foro(db.Model): + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(120), nullable=False) + img: Mapped[str] = mapped_column(Text, nullable=True) + description: Mapped[str] = mapped_column(Text, nullable=True) + user_id = mapped_column(ForeignKey("user.id"), nullable=False) + tag = relationship('Tag_select') + post = relationship( "Post", back_populates="foro") + following= relationship("Follow", cascade="all, delete-orphan") + created_at: Mapped[datetime] = mapped_column(server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now()) + + + def serialize_foro(self): + return { + "id": self.id, + "title": self.title, + "img": self.img, + "description": self.description, + "user_id": self.user_id, + "tags": [ + tag.serialize_tag_foro() + for tag in self.tag], + "posts": [ + post.serialize_post() + for post in self.post], + "created_at": str(self.created_at), + "updated_at": str(self.updated_at) + + } \ No newline at end of file diff --git a/src/api/models/models_user.py b/src/api/models/models_user.py new file mode 100644 index 0000000000..73a2f4397d --- /dev/null +++ b/src/api/models/models_user.py @@ -0,0 +1,48 @@ +from api.database.db import db +from sqlalchemy import String, Boolean, ForeignKey, func, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship +from typing import List +from datetime import datetime + + +class User(db.Model): + id: Mapped[int] = mapped_column(primary_key=True) + username: Mapped[str] = mapped_column( + String(120), unique=True, nullable=False) + email: Mapped[str] = mapped_column( + String(120), unique=True, nullable=False) + password: Mapped[str] = mapped_column(String(255), nullable=False) + img: Mapped[str] = mapped_column(Text, nullable=True) + first_name: Mapped[str] = mapped_column(String(255), nullable=True) + last_name: Mapped[str] = mapped_column(String(255), nullable=True) + date_birth: Mapped[str] = mapped_column(String(255), nullable=True) + description: Mapped[str] = mapped_column(String(255), nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean(), default=True) + foro = relationship('Foro') + following = relationship("Follow", cascade="all, delete-orphan") + post = relationship('Post', back_populates="user") + tag = relationship('Tag_select') + created_at: Mapped[datetime] = mapped_column(server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now()) + + def serialize(self): + return { + "id": self.id, + "username": self.username, + "email": self.email, + "created_at": str(self.created_at), + } + + def serialize_all(self): + return { + "id": self.id, + "username": self.username, + "email": self.email, + "img": self.img, + "first_name": self.first_name, + "last_name": self.last_name, + "date_birth": self.date_birth, + "description": self.description, + "updated_at": str(self.updated_at) + } + \ No newline at end of file diff --git a/src/api/routes.py b/src/api/routes.py deleted file mode 100644 index 029589a3a1..0000000000 --- a/src/api/routes.py +++ /dev/null @@ -1,22 +0,0 @@ -""" -This module takes care of starting the API Server, Loading the DB and Adding the endpoints -""" -from flask import Flask, request, jsonify, url_for, Blueprint -from api.models import db, User -from api.utils import generate_sitemap, APIException -from flask_cors import CORS - -api = Blueprint('api', __name__) - -# Allow CORS requests to this API -CORS(api) - - -@api.route('/hello', methods=['POST', 'GET']) -def handle_hello(): - - response_body = { - "message": "Hello! I'm a message that came from the backend, check the network tab on the google inspector and you will see the GET request" - } - - return jsonify(response_body), 200 diff --git a/src/api/routes/__init__.py b/src/api/routes/__init__.py new file mode 100644 index 0000000000..d801a95db3 --- /dev/null +++ b/src/api/routes/__init__.py @@ -0,0 +1,8 @@ +from flask import Blueprint +from flask_cors import CORS + +api = Blueprint('api', __name__) +CORS(api) + +# Las rutas se registran importando los módulos DESPUÉS de crear el blueprint +from api.routes import routes_user, routes_tag, routes_foro, routes_post, routes_follow, routes_comment \ No newline at end of file diff --git a/src/api/routes/routes_comment.py b/src/api/routes/routes_comment.py new file mode 100644 index 0000000000..b094f30b0a --- /dev/null +++ b/src/api/routes/routes_comment.py @@ -0,0 +1,67 @@ +from flask import request, jsonify +from flask_jwt_extended import jwt_required, get_jwt_identity +from api.models.model_comment import Comment +from api.database.db import db +from api.models.model_post import Post + +from api.routes import api + +@api.route('/comment', methods=['POST']) +@jwt_required() +def create_comment(): + + data = request.get_json() + user_id = data.get('user_id') + post_id = data.get('post_id') + content = data.get('content') + + if content is None or post_id is None or user_id is None: + return jsonify({ + "msg": "Content and post_id is required" + }), 400 + + new_comment = Comment(user_id=user_id, content=content, post_id=post_id) + db.session.add(new_comment) + db.session.commit() + + return jsonify({"msg": "Comment created",'comment': new_comment.serialize()}), 201 + + +@api.route('/comment/', methods=['GET']) +@jwt_required() +def get_comments_by_posts(post_id): + + post = db.session.get(Post, post_id) + if post is None: + return jsonify({"msg": "Post not found"}), 400 + + comments_list = db.session.query(Comment).all() + comments = list(map(lambda Comment: Comment.serialize_comment(), comments_list)) + + return jsonify({"post_id": post_id, "comments": comments}) + + +@api.route('/comment/', methods=['PUT']) +@jwt_required() +def edit_comment(comment_id): + + data = request.get_json() + if data is None: + return jsonify({"msg": "You must to send the body"}), 400 + + comment_update = db.session.get(Comment, comment_id) + if comment_update is None: + return jsonify({"msg": "Comment not found"}), 404 + + user_token = int(get_jwt_identity()) + if user_token != comment_update.user_id: + return jsonify({"msg": "You do not have permission to update this comment"}), 403 + + content = data.get('content') + if not content: + return jsonify({"msg": "Content is required"}), 400 + + comment_update.content = content + db.session.commit() + + return jsonify({"msg": "Comment updated", "comment": comment_update.serialize()}), 200 diff --git a/src/api/routes/routes_follow.py b/src/api/routes/routes_follow.py new file mode 100644 index 0000000000..fb745bb832 --- /dev/null +++ b/src/api/routes/routes_follow.py @@ -0,0 +1,83 @@ +from flask import request, jsonify +from flask_jwt_extended import jwt_required, get_jwt_identity +from api.models.models_foro import Foro +from api.models.model_follow import Follow +from api.database.db import db +from . import api + +@api.route("/follow/", methods=["POST"]) +@jwt_required() +def follow_foro(foro_id): + + user_id = get_jwt_identity() + + foro = Foro.query.get(foro_id) + + + if not foro: + return jsonify({"msg": "Foro no encontrado"}), 400 + + exist = Follow.query.filter_by( user_id=user_id, foro_id=foro_id ).first() + + if exist: + return jsonify({"msg": "Ya sigues este foro"}), 400 + + new_follow = Follow( + user_id=user_id, + foro_id=foro_id + ) + + db.session.add(new_follow) + db.session.commit() + + return jsonify({"msg": "Foro seguido correctamente"}), 201 + + +@api.route("/unfollow/", methods=["DELETE"]) +@jwt_required() +def delete_follow(foro_id): + + user_id= get_jwt_identity() + + follow= Follow.query.filter_by( + user_id=user_id, + foro_id=foro_id + ).first() + + if not follow: + return jsonify({"msg": "No sigues este foro"}), 404 + + db.session.delete(follow) + db.session.commit() + + return jsonify({"msg": "Foro eliminado"}) + + +@api.route("/myfollow", methods=["GET"]) +@jwt_required() +def get_follow(): + + user_id = get_jwt_identity() + + follows = Follow.query.filter_by( + user_id=user_id + ).all() + + result = [] + + for follow in follows: + + foro = Foro.query.get(follow.foro_id) + + if foro: + + foro_data = foro.serialize_foro() + + foro_data["posts"] = [ + post.serialize_post() + for post in foro.post[-3:] + ] + + result.append(foro_data) + + return jsonify(result), 200 \ No newline at end of file diff --git a/src/api/routes/routes_foro.py b/src/api/routes/routes_foro.py new file mode 100644 index 0000000000..21512fa56b --- /dev/null +++ b/src/api/routes/routes_foro.py @@ -0,0 +1,104 @@ +from flask import request, jsonify +from flask_jwt_extended import jwt_required, get_jwt_identity +from api.models.models_foro import Foro +from api.database.db import db +from . import api + +import cloudinary +import cloudinary.uploader +from api.cloudinary.cloudinary_config import * + +@api.route('/foro', methods=["POST"]) +@jwt_required() +def create_forum(): + + title = request.form.get("title") + description = request.form.get("description") + + if not title: + return jsonify({"msg": "Title is required"}), 400 + + img_url = None + + if 'img' in request.files: + file_to_upload = request.files['img'] + if file_to_upload.filename != '': + try: + upload_result = cloudinary.uploader.upload(file_to_upload) + img_url = upload_result["secure_url"] + except Exception as e: + return jsonify({"msg": str(e)}), 500 + + new_forum = Foro( + title=title, + img=img_url, + description=description, + user_id=int(get_jwt_identity()) + ) + + db.session.add(new_forum) + db.session.commit() + + return jsonify({ + "msg": "Forum created", + "forum": new_forum.serialize_foro() + }), 201 + +@api.route('/foros', methods=['GET']) +def get_forums(): + + forums = Foro.query.all() + + return jsonify([ + forum.serialize_foro() + for forum in forums + ]), 200 + + + + +@api.route("/foro/", methods=["GET"]) +def get_forum_id(forum_id): + + forum = Foro.query.get(forum_id) + + if forum is None: + return jsonify({ + "msg": "Forum not found" + }), 404 + + return jsonify( + forum.serialize_foro() + ), 200 + + +@api.route("/foro/user/", methods=["GET"]) +def get_foros_from_user(user_id): + + foros = Foro.query.filter_by( + user_id=user_id + ).all() + + return jsonify([ + foro.serialize_foro() + for foro in foros + ]), 200 + +@api.route("/foros/search", methods=["GET"]) +def get_forum_search(): + + query = request.args.get("query") + + if not query: + return jsonify({ + "msg": "Query is required" + }), 400 + + forums = Foro.query.filter( + Foro.title.ilike(f"%{query}%") + ).all() + + return jsonify([ + forum.serialize_foro() + for forum in forums + ]), 200 \ No newline at end of file diff --git a/src/api/routes/routes_post.py b/src/api/routes/routes_post.py new file mode 100644 index 0000000000..0bca3502b0 --- /dev/null +++ b/src/api/routes/routes_post.py @@ -0,0 +1,148 @@ +from flask import request, jsonify +from flask_jwt_extended import jwt_required, get_jwt_identity +from api.models.model_post import Post +from api.database.db import db +from api.service.save_img import save_img +import cloudinary +import cloudinary.uploader +from api.cloudinary.cloudinary_config import * + +# Importación circular segura: se resuelve porque routes/__init__.py +# ya terminó de crear `api` antes de llegar a esta línea +from api.routes import api + + +@api.route('/post', methods=['POST']) +@jwt_required() +def create_post(): + + try: + + content = request.form.get('content') + foro_id = request.form.get('foro_id') + title = request.form.get('title') + + print("TITLE:", title) + print("CONTENT:", content) + print("FORO_ID:", foro_id) + print("JWT IDENTITY:", get_jwt_identity()) + + if not title: + return jsonify({ + "msg": "Title is required" + }), 400 + + if not content: + return jsonify({ + "msg": "Content is required" + }), 400 + + img_url = None + + if 'img' in request.files: + + file_to_upload = request.files['img'] + + if file_to_upload.filename != '': + + upload_result = cloudinary.uploader.upload( + file_to_upload, + upload_preset="neqycdyx" + ) + + img_url = upload_result["secure_url"] + + new_post = Post( + title=title, + content=content, + img=img_url, + foro_id=int(foro_id) if foro_id else None, + user_id=int(get_jwt_identity()) + ) + + db.session.add(new_post) + db.session.commit() + + return jsonify({ + "msg": "Post created", + "post": new_post.serialize_post() + }), 201 + + except Exception as e: + + db.session.rollback() + + print("ERROR CREANDO POST:", str(e)) + + return jsonify({ + "error": str(e) + }), 500 + +@api.route('/post/', methods=['GET']) +def get_post_id(post_id): + + post = Post.query.get(post_id) + + if post is None: + return jsonify({ + "msg": "Post not found" + }), 404 + + return jsonify( + post.serialize_post() + ), 200 + + +@api.route('/foro//posts', methods=['GET']) +def get_posts_by_foro(foro_id): + + posts = Post.query.filter_by( + foro_id=foro_id + ).order_by( + Post.created_at.desc() + ).all() + + return jsonify([ + post.serialize_post() + for post in posts + ]), 200 + + +@api.route('/foro//posts/top', methods=['GET']) +def get_top_three_posts(foro_id): + + posts = Post.query.filter_by( + foro_id=foro_id + ).order_by( + Post.created_at.desc() + ).limit(3).all() + + return jsonify([ + post.serialize_post() + for post in posts + ]), 200 + +@api.route('/post/', methods=['PUT']) +@jwt_required() +def edit_posts(post_id): + + post_update = db.session.get(Post, post_id) + + if post_update is None: + return jsonify({"msg": "Post not found"}), 404 + + user_token = int(get_jwt_identity()) + if user_token != post_update.user_id: + return jsonify({"msg": "You do not have permission to update this post"}), 403 + + if 'img' in request.files: + file_to_upload = request.files['img'] + img_url = save_img(file_to_upload) + if isinstance(img_url, str): + post_update.img = img_url + + post_update.title = request.form.get('title', post_update.title) + post_update.content = request.form.get('content', post_update.content) + + db.session.commit() + return jsonify({"msg": "Post updated", "Post": post_update.serialize_post()}), 200 \ No newline at end of file diff --git a/src/api/routes/routes_tag.py b/src/api/routes/routes_tag.py new file mode 100644 index 0000000000..f223da71b0 --- /dev/null +++ b/src/api/routes/routes_tag.py @@ -0,0 +1,180 @@ +from flask import request, jsonify +from flask_jwt_extended import jwt_required, get_jwt_identity +from api.models.model_tag import Tag +from api.models.models_user import User +from api.models.models_foro import Foro +from api.models.model_select_tag import Tag_select +from api.database.db import db +from . import api + +#para uso de creacion de una tag personalizada para que usuariO la cree +# @api.route('/tag', methods=["POST"]) +# @jwt_required() +# def create_tag(): +# data = request.get_json() + +# title = data.get("title") + +# if title is None: +# return jsonify({"msg": "Title is required"}), 400 + +# title_check = title.strip().lower() + +# tag_check = db.session.execute(db.select(Tag).where(Tag.title == title_check)).scalar_one_or_none() +# if tag_check is not None: +# return jsonify({"msg": "That tag is already registered"}), 400 + +# new_tag = Tag(title=title_check, user_id = get_jwt_identity()) +# db.session.add(new_tag) +# db.session.commit() + +# return jsonify({"msg": "Tag created", 'tag': new_tag.serialize_tag()}), 201 + +@api.route('/tag', methods=["GET"]) +@jwt_required() +def all_tags(): + tag_list = db.session.query(Tag).all() + tag = list(map(lambda tag: tag.serialize_tag(), tag_list)) + + response_body = {"tag": tag} + + return jsonify(response_body), 200 + +@api.route('/tag-select', methods=["POST"]) +@jwt_required() +def select_tag(): + + user_token = get_jwt_identity() + + data = request.get_json() + tags_id = data.get('tags_id') + user_id = int(user_token) + + if tags_id is None: + return jsonify({ + "msg": "Bad request i need tag_id" + }), 400 + + for tag in tags_id: + + tag_exists = db.session.get(Tag, tag) + + if tag_exists is None: + return jsonify({ + "msg": "Tag not found" + }), 404 + + existing_tag = Tag_select.query.filter_by( + user_id=user_id, + tag_id=tag + ).first() + + if existing_tag: + continue + + new_tag_select = Tag_select( + tag_id=tag, + user_id=user_id + ) + + db.session.add(new_tag_select) + + db.session.commit() + + return jsonify({ + "msg": "Tag assigned" + }), 201 + + +@api.route('/tag-select-foro', methods=["POST"]) +@jwt_required() +def select_tagForo(): + + data = request.get_json() + + foro_id = data.get('foro_id') + tags_id = data.get('tags_id') + + if tags_id is None: + return jsonify({ + "msg": "Bad request, I need tags_id" + }), 400 + + for tag in tags_id: + + tag_exists = db.session.get(Tag, tag) + + if tag_exists is None: + return jsonify({ + "msg": "Tag not found" + }), 404 + + existing_tag = Tag_select.query.filter_by( + foro_id=foro_id, + tag_id=tag + ).first() + + if existing_tag: + continue + + new_tag_select = Tag_select( + tag_id=tag, + foro_id=foro_id + ) + + db.session.add(new_tag_select) + + db.session.commit() + + return jsonify({ + "msg": "Tag assigned", + "foro_id": foro_id + }), 201 + +@api.route('/tag/user/', methods=['GET']) +@jwt_required() +def get_tag_from_user(user_id): + user = db.session.get(User, user_id) + if user is None: + return jsonify({"msg": "User not found"}), 400 + query= db.select(Tag_select).where(Tag_select.user_id == user_id) + tag_select_list = db.session.execute(query).scalars().all() + tags = list(map(lambda Tag_select: Tag_select.serialize_tag_user(), tag_select_list)) + + return jsonify ({"user_id": user_id, "tags": tags}) + +@api.route('/tag/foro/', methods=['GET']) +@jwt_required() +def get_tag_from_foro(foro_id): + foro = db.session.get(Foro, foro_id) + if foro is None: + return jsonify({"msg": "Foro not found"}), 400 + query= db.select(Tag_select).where(Tag_select.foro_id == foro_id) + tag_select_list = db.session.execute(query).scalars().all() + tags = list(map(lambda Tag_select: Tag_select.serialize_tag_foro(), tag_select_list)) + + return jsonify ({"foro_id": foro_id, "tags": tags}) + + +@api.route('/tag-select/', methods=['DELETE']) +@jwt_required() +def delete_tag_from_user(tag_id): + + user_id = get_jwt_identity() + + tag_select = Tag_select.query.filter_by( + user_id=user_id, + tag_id=tag_id + ).first() + + if tag_select is None: + return jsonify({ + "msg": "Tag not found" + }), 404 + + db.session.delete(tag_select) + db.session.commit() + + return jsonify({ + "msg": "Tag deleted" + }), 200 \ No newline at end of file diff --git a/src/api/routes/routes_user.py b/src/api/routes/routes_user.py new file mode 100644 index 0000000000..a4eed10e41 --- /dev/null +++ b/src/api/routes/routes_user.py @@ -0,0 +1,144 @@ +from flask import request, jsonify +from flask_jwt_extended import create_access_token, get_jwt_identity, jwt_required +from api.models.models_user import User +from api.database.db import db +from api.extension import bcrypt +from api.service.save_img import save_img +from . import api + + +@api.route('/login', methods=['POST']) +def login(): + + body = request.get_json() + + if not body: + return jsonify({ + "success": False, + "msg": "Datos inválidos" + }), 400 + + email = body.get("email") + password = body.get("password") + + if not email or not password: + return jsonify({ + "success": False, + "msg": "Email y contraseña son obligatorios" + }), 400 + + user = User.query.filter_by(email=email).first() + + if not user: + return jsonify({ + "success": False, + "msg": "Credenciales incorrectas" + }), 401 + + if not bcrypt.check_password_hash(user.password, password): + return jsonify({ + "success": False, + "msg": "Credenciales incorrectas" + }), 401 + + token = create_access_token( + identity=str(user.id) + ) + + return jsonify({ + "success": True, + "token": token, + "user": { + "id": user.id, + "username": user.username, + "email": user.email, + "img": user.img, + "first_name": user.first_name, + "last_name": user.last_name + } + }), 200 + + +@api.route('/register', methods=['POST']) +def register_user(): + data = request.get_json() + email = data.get('email') + password = data.get('password') + username = data.get('username') + + if email is None or password is None or username is None: + return jsonify({"msg": "i need email, password and username"}), 400 + + email_check = db.session.execute(db.select(User).where(User.email == email)).scalar_one_or_none() + if email_check is not None: + return jsonify({"msg": "That email is already registered"}), 400 + + username_check = db.session.execute(db.select(User).where(User.username == username)).scalar_one_or_none() + if username_check is not None: + return jsonify({"msg": "That username is already taken"}), 400 + + hashed_password = bcrypt.generate_password_hash(password).decode('utf-8') + + + new_user = User(email=email, password=hashed_password, username=username, is_active=True) + db.session.add(new_user) + db.session.commit() + token = create_access_token(identity=str(new_user.id)) + jsonify + return ({"msg": "User created", "token": token, 'user': new_user.serialize()}), 201 + + +@api.route('/profile/eddit/', methods=['PUT']) +@jwt_required() +def profile_edit(id): + + user_token = get_jwt_identity() + user_id = int(user_token) + + if user_id != id: + return jsonify({"msg": "You do not have permission to update this profile"}), 403 + + user_update = db.session.get(User, id) + + if user_update is None: + return jsonify({"msg": "User not found"}), 404 + + if 'img' in request.files: + file_to_upload = request.files['img'] + img_url = save_img(file_to_upload) + if isinstance(img_url, str): + user_update.img = img_url + + user_update.username = request.form.get('username', user_update.username) + user_update.email = request.form.get('email', user_update.email) + user_update.first_name = request.form.get('first_name', user_update.first_name) + user_update.last_name = request.form.get('last_name', user_update.last_name) + user_update.date_birth = request.form.get('date_birth', user_update.date_birth) + user_update.description = request.form.get('description', user_update.description) + + password = request.form.get('password') + + if password is not None: + hashed_password = bcrypt.generate_password_hash(password['password']).decode('utf-8') + user_update.password = hashed_password + + db.session.commit() + return jsonify({"msg": "User updated", "user": user_update.serialize_all()}), 200 + +@api.route("/profile/", methods=["GET"]) +@jwt_required() +def get_user_id(id): + + user_token = get_jwt_identity() + user_id = int(user_token) + + if user_id != id: + return jsonify({"msg": "You do not have permission to view this profile"}), 403 + + user = db.session.get(User, id) + + if user is None: + return jsonify({"msg": "User not found" }), 400 + + return jsonify(user.serialize_all()), 200 + diff --git a/src/api/service/save_img.py b/src/api/service/save_img.py new file mode 100644 index 0000000000..e67e3b9796 --- /dev/null +++ b/src/api/service/save_img.py @@ -0,0 +1,13 @@ +from flask import request, jsonify +import cloudinary +import cloudinary.uploader +from api.cloudinary.cloudinary_config import * + + +def save_img(file): + if file.filename != '': + try: + upload_result = cloudinary.uploader.upload(file) + return upload_result["secure_url"] + except Exception as e: + return jsonify({"msg": str(e)}), 500 \ No newline at end of file diff --git a/src/app.py b/src/app.py index 1b3340c0fa..d9a54019c9 100644 --- a/src/app.py +++ b/src/app.py @@ -1,72 +1,104 @@ """ This module takes care of starting the API Server, Loading the DB and Adding the endpoints """ + import os -from flask import Flask, request, jsonify, url_for, send_from_directory +from flask import Flask, jsonify, send_from_directory +from flask_cors import CORS from flask_migrate import Migrate -from flask_swagger import swagger +from flask_jwt_extended import JWTManager from api.utils import APIException, generate_sitemap -from api.models import db +from api.models.models_user import db from api.routes import api from api.admin import setup_admin from api.commands import setup_commands +from api.extension import bcrypt -# from models import Person ENV = "development" if os.getenv("FLASK_DEBUG") == "1" else "production" -static_file_dir = os.path.join(os.path.dirname( - os.path.realpath(__file__)), '../dist/') + +static_file_dir = os.path.join( + os.path.dirname(os.path.realpath(__file__)), + "../dist/" +) + app = Flask(__name__) app.url_map.strict_slashes = False -# database condiguration +CORS(app) +# JWT +#app.config["JWT_SECRET_KEY"] = "mindfed-secret-key" +app.config["JWT_SECRET_KEY"] = os.getenv("FLASK_JWT") +# db.init_app(app) +jwt = JWTManager(app) +bcrypt.init_app(app) + + +# Database db_url = os.getenv("DATABASE_URL") -if db_url is not None: - app.config['SQLALCHEMY_DATABASE_URI'] = db_url.replace( - "postgres://", "postgresql://") + +if db_url: + app.config["SQLALCHEMY_DATABASE_URI"] = db_url.replace( + "postgres://", + "postgresql://" + ) else: - app.config['SQLALCHEMY_DATABASE_URI'] = "sqlite:////tmp/test.db" + app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:////tmp/test.db" + +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False -app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False -MIGRATE = Migrate(app, db, compare_type=True) db.init_app(app) +Migrate(app, db, compare_type=True) -# add the admin +# Admin setup_admin(app) -# add the admin +# Commands setup_commands(app) -# Add all endpoints form the API with a "api" prefix -app.register_blueprint(api, url_prefix='/api') - -# Handle/serialize errors like a JSON object +# API +app.register_blueprint(api, url_prefix="/api") @app.errorhandler(APIException) def handle_invalid_usage(error): return jsonify(error.to_dict()), error.status_code -# generate sitemap with all your endpoints - -@app.route('/') +@app.route("/") def sitemap(): if ENV == "development": return generate_sitemap(app) - return send_from_directory(static_file_dir, 'index.html') -# any other endpoint will try to serve it like a static file -@app.route('/', methods=['GET']) + return send_from_directory( + static_file_dir, + "index.html" + ) + + +@app.route("/", methods=["GET"]) def serve_any_other_file(path): - if not os.path.isfile(os.path.join(static_file_dir, path)): - path = 'index.html' - response = send_from_directory(static_file_dir, path) - response.cache_control.max_age = 0 # avoid cache memory + + if not os.path.isfile( + os.path.join(static_file_dir, path) + ): + path = "index.html" + + response = send_from_directory( + static_file_dir, + path + ) + + response.cache_control.max_age = 0 + return response -# this only runs if `$ python src/main.py` is executed -if __name__ == '__main__': - PORT = int(os.environ.get('PORT', 3001)) - app.run(host='0.0.0.0', port=PORT, debug=True) +if __name__ == "__main__": + PORT = int(os.environ.get("PORT", 3001)) + + app.run( + host="0.0.0.0", + port=PORT, + debug=True + ) \ No newline at end of file diff --git a/src/css/Landing.css b/src/css/Landing.css new file mode 100644 index 0000000000..5bd4a1f7f5 --- /dev/null +++ b/src/css/Landing.css @@ -0,0 +1,226 @@ +.landing-page { +min-height: 100vh; +background: linear-gradient( +135deg, +#f8faff 0%, +#eef4ff 50%, +#e0e7ff 100% +); +position: relative; +overflow: hidden; +} + +/* EFECTOS DE FONDO */ + +.landing-page::before { +content: ""; +position: absolute; +width: 500px; +height: 500px; +border-radius: 50%; +background: rgba(37, 99, 235, 0.08); +top: -200px; +left: -200px; +filter: blur(80px); +} + +.landing-page::after { +content: ""; +position: absolute; +width: 400px; +height: 400px; +border-radius: 50%; +background: rgba(99, 102, 241, 0.08); +bottom: -150px; +right: -150px; +filter: blur(80px); +} + +/* COLUMNA IZQUIERDA */ + +.landing-left { +position: relative; +z-index: 2; +padding-right: 4rem; +} + +.landing-title { +font-size: 4rem; +font-weight: 800; +line-height: 1.1; +color: #0f172a; +margin-bottom: 1.5rem; +} + +.landing-title span { +color: #2563eb; +} + +.landing-description { +font-size: 1.25rem; +line-height: 1.8; +color: #64748b; +margin-bottom: 2.5rem; +max-width: 650px; +} + +/* FEATURES */ + +.features { +margin-top: 2rem; +} + +.feature-item { +display: flex; +align-items: center; +gap: 15px; +margin-bottom: 1.5rem; +font-size: 1.1rem; +font-weight: 500; +color: #1e293b; +padding: 15px 20px; +background: rgba(255,255,255,.7); +backdrop-filter: blur(10px); +border-radius: 16px; +transition: all .3s ease; +} + +.feature-item:hover { +transform: translateX(8px); +background: white; +box-shadow: 0 10px 25px rgba(0,0,0,.08); +} + +.feature-icon { +color: #2563eb; +font-size: 1.7rem; +flex-shrink: 0; +} + +/* LOGIN CARD */ + +.login-card { +position: relative; +z-index: 2; +width: 480px; +background: rgba(255,255,255,.95); +backdrop-filter: blur(20px); +padding: 3rem; +border-radius: 28px; +border: 1px solid rgba(255,255,255,.5); +box-shadow: +0 20px 50px rgba(0,0,0,.08), +0 5px 15px rgba(0,0,0,.04); +} + +.login-title { +text-align: center; +margin-bottom: 2rem; +color: #0f172a; +font-weight: 700; +font-size: 2rem; +} + +/* INPUTS */ + +.login-card .form-control { +height: 55px; +border-radius: 14px; +border: 1px solid #e2e8f0; +padding: 0 15px; +transition: all .3s ease; +} + +.login-card .form-control:focus { +border-color: #2563eb; +box-shadow: 0 0 0 4px rgba(37,99,235,.15); +} + +/* BOTONES */ + +.login-card .btn-primary { +height: 55px; +border-radius: 14px; +font-weight: 600; +background: linear-gradient( +135deg, +#2563eb, +#3b82f6 +); +border: none; +transition: all .3s ease; +} + +.login-card .btn-primary:hover { +transform: translateY(-2px); +box-shadow: 0 10px 20px rgba(37,99,235,.25); +} + +/* RESPONSIVE */ + +@media (max-width: 992px) { + +``` +.landing-left { + text-align: center; + padding-right: 0; + margin-bottom: 3rem; +} + +.landing-title { + font-size: 2.8rem; +} + +.landing-description { + margin: 0 auto 2rem auto; +} + +.feature-item { + justify-content: center; + text-align: left; +} + +.login-card { + width: 100%; + max-width: 500px; + margin: auto; +} +``` + +} + +@media (max-width: 576px) { + +``` +.landing-title { + font-size: 2.2rem; +} + +.landing-description { + font-size: 1rem; +} + +.login-card { + padding: 2rem; +} +``` + +} + +.password-container { + position: relative; +} + +.password-icon { + position: absolute; + right: 15px; + top: 50%; + transform: translateY(-50%); + cursor: pointer; + color: #6c757d; + font-size: 18px; +} + +.password-icon:hover { + color: #0d6efd; +} \ No newline at end of file diff --git a/src/css/Navbar.css b/src/css/Navbar.css new file mode 100644 index 0000000000..4c49a6292b --- /dev/null +++ b/src/css/Navbar.css @@ -0,0 +1,124 @@ +.navbar-content { +display: flex; +align-items: center; +justify-content: space-between; +gap: 20px; +padding: 12px 20px; +} + + + +.mindfed-logo { +height: 75px; +object-fit: contain; +transition: transform .3s ease; +} + +.mindfed-logo:hover { +transform: scale(1.05); +} + + + +.navbar-search-container { +flex: 1; +max-width: 700px; +} + +.navbar-search { +width: 100%; +height: 52px; +border: none; +border-radius: 18px; +padding: 0 20px; +background: #f8f9fa; +box-shadow: +0 4px 12px rgba(0,0,0,.05), +inset 0 1px 2px rgba(0,0,0,.03); +transition: all .3s ease; +} + +.navbar-search:focus { +outline: none; +background: white; +box-shadow: +0 0 0 3px rgba(13,110,253,.15), +0 8px 20px rgba(13,110,253,.12); +} + + + +.menu-btn { +width: 55px; +height: 55px; +border: none; +border-radius: 16px; +background: white; +color: #0d6efd; +font-size: 24px; +display: flex; +align-items: center; +justify-content: center; +box-shadow: +0 6px 16px rgba(0,0,0,.08); +transition: all .3s ease; +} + +.menu-btn:hover { +transform: translateY(-3px); +background: #0d6efd; +color: white; +box-shadow: +0 12px 24px rgba(13,110,253,.25); +} + + +.navbar { +background: rgba(255,255,255,.95); +backdrop-filter: blur(12px); +border-bottom: 1px solid rgba(0,0,0,.06); +box-shadow: +0 4px 20px rgba(0,0,0,.04); +} + + + +@media (max-width: 768px) { + +``` +.navbar-content { + gap: 12px; + padding: 10px; +} + +.mindfed-logo { + height: 60px; +} + +.navbar-search { + height: 45px; + font-size: 14px; + border-radius: 14px; +} + +.menu-btn { + width: 45px; + height: 45px; + font-size: 20px; +} +``` + +} + +.dropdown-menu { + z-index: 9999 !important; + border-radius: 15px; + border: none; + box-shadow: 0 15px 40px rgba(0,0,0,.15); + padding: 10px; +} + +.navbar { + position: relative; + z-index: 1000; +} \ No newline at end of file diff --git a/src/css/Post.css b/src/css/Post.css new file mode 100644 index 0000000000..ecba45696d --- /dev/null +++ b/src/css/Post.css @@ -0,0 +1,154 @@ +.creacion-post-container { + min-height: 100vh; + padding: 60px 20px; + background: #f5f7fb; + display: flex; + justify-content: center; + align-items: flex-start; +} + +.creacion-post-card { + width: 100%; + max-width: 850px; + background: #ffffff; + border: 1px solid #e9eef5; + border-radius: 24px; + box-shadow: + 0 2px 8px rgba(15, 23, 42, 0.04), + 0 12px 32px rgba(15, 23, 42, 0.06); + overflow: hidden; +} + +.card-body { + padding: 40px; +} + +.creacion-post-title { + font-size: 2rem; + font-weight: 700; + color: #0f172a; + margin-bottom: 30px; + text-align: center; +} + +.form-label { + font-size: 0.95rem; + font-weight: 600; + color: #334155; + margin-bottom: 8px; +} + +.creacion-post-input, +.creacion-post-textarea { + border: 1px solid #dbe3ee; + border-radius: 16px; + background: #fff; + padding: 14px 18px; + font-size: 1rem; + color: #1e293b; + transition: all .2s ease; +} + +.creacion-post-input:hover, +.creacion-post-textarea:hover { + border-color: #cbd5e1; +} + +.creacion-post-input:focus, +.creacion-post-textarea:focus { + border-color: #2563eb; + box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.10); +} + +.creacion-post-textarea { + min-height: 220px; + resize: vertical; +} + +.preview-container { + margin-top: 25px; + text-align: center; +} + +.preview-title { + font-size: 0.95rem; + font-weight: 600; + color: #475569; + margin-bottom: 15px; +} + +.preview-image { + width: 100%; + max-height: 420px; + object-fit: cover; + border-radius: 18px; + border: 1px solid #e2e8f0; +} + +.button-group { + display: flex; + justify-content: flex-end; + gap: 15px; + margin-top: 35px; +} + +.btn-publicar { + min-width: 160px; + height: 52px; + border: none; + border-radius: 14px; + background: #2563eb; + color: white; + font-weight: 600; + transition: all .2s ease; +} + +.btn-publicar:hover { + background: #1d4ed8; + transform: translateY(-1px); +} + +.btn-cancelar { + min-width: 160px; + height: 52px; + border-radius: 14px; + border: 1px solid #dbe3ee; + background: white; + color: #475569; + font-weight: 600; + transition: all .2s ease; +} + +.btn-cancelar:hover { + background: #f8fafc; +} + +.separator { + height: 1px; + background: #e2e8f0; + margin: 30px 0; +} + +@media (max-width: 768px) { + + .card-body { + padding: 25px; + } + + .creacion-post-title { + font-size: 1.7rem; + } + + .button-group { + flex-direction: column; + } + + .btn-publicar, + .btn-cancelar { + width: 100%; + } + + .preview-image { + max-height: 250px; + } +} \ No newline at end of file diff --git a/src/css/feed.css b/src/css/feed.css new file mode 100644 index 0000000000..b46d2acf27 --- /dev/null +++ b/src/css/feed.css @@ -0,0 +1,309 @@ +.feed-page { + display: flex; + gap: 35px; + padding: 30px; + min-height: calc(100vh - 100px); + background: linear-gradient( + 180deg, + #f8faff 0%, + #eef4ff 100% + ); +} + +.feed-sidebar { + width: 320px; + min-width: 320px; + display: flex; + flex-direction: column; + gap: 25px; +} + +.feed-main { + flex: 1; + display: flex; + flex-direction: column; + gap: 30px; +} + + + +.profile-card, +.sidebar-card, +.feed-foro-card { + background: white; + border-radius: 28px; + border: 1px solid rgba(0,0,0,.05); + box-shadow: + 0 10px 35px rgba(0,0,0,.06), + 0 2px 8px rgba(0,0,0,.04); +} + +.profile-card:hover, +.sidebar-card:hover, +.feed-foro-card:hover { + transform: translateY(-5px); + transition: .3s ease; +} + + +.profile-card { + padding: 30px; + text-align: center; +} + +.profile-avatar { + width: 140px; + height: 140px; + border-radius: 50%; + object-fit: cover; + border: 5px solid white; + box-shadow: 0 10px 25px rgba(13,110,253,.25); +} + +.profile-card h5 { + margin-top: 20px; + font-size: 1.5rem; + font-weight: 700; + color: #1e293b; +} + + + +.sidebar-card { + padding: 25px; +} + +.sidebar-card h5 { + font-size: 1.4rem; + font-weight: 700; + margin-bottom: 20px; +} + + + +.forum-list { + list-style: none; + margin: 0; + padding: 0; +} + +.forum-item { + display: flex; + align-items: center; + gap: 16px; + padding: 15px; + border-radius: 18px; + background: #f8f9fa; + margin-bottom: 14px; + transition: .3s ease; +} + +.forum-item:hover { + background: #eef4ff; + transform: translateX(5px); +} + +.forum-image { + width: 70px; + height: 70px; + border-radius: 16px; + object-fit: cover; + box-shadow: 0 4px 12px rgba(0,0,0,.15); +} + +.forum-item span { + font-size: 1rem; + font-weight: 600; + color: #2d3436; +} + + + +.feed-tags { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.feed-tag { + background: #0d6efd; + color: white; + padding: 8px 14px; + border-radius: 50px; + font-size: .9rem; + font-weight: 600; +} + + +.carousel { + border-radius: 30px; + overflow: hidden; + box-shadow: 0 10px 35px rgba(0,0,0,.08); +} + +.carousel-foro-image { + width: 100%; + height: 520px; + object-fit: cover; + object-position: center; +} + +.carousel-caption { + background: rgba(0,0,0,.45); + backdrop-filter: blur(10px); + border-radius: 20px; + padding: 20px; + margin-bottom: 30px; +} + +.carousel-caption h3 { + font-size: 2.5rem; + font-weight: 800; +} + +.carousel-caption p { + font-size: 1.1rem; +} + + + +.feed-foro-card { + padding: 30px; +} + +.foro-card-image { + width: 100%; + height: 520px; + object-fit: cover; + object-position: center; + border-radius: 24px; + margin-bottom: 25px; +} + +.feed-foro-card h3 { + font-size: clamp(2rem,3vw,3rem); + font-weight: 800; + color: #1e293b; + margin-bottom: 12px; +} + +.feed-foro-card p { + font-size: 1.15rem; + color: #64748b; + margin-bottom: 25px; +} + +.feed-foro-card .btn { + padding: 12px 30px; + border-radius: 14px; + font-size: 1rem; + font-weight: 600; +} + + +.empty-search-card { + background: white; + border-radius: 24px; + padding: 70px 50px; + text-align: center; + border: 1px solid #e2e8f0; + box-shadow: + 0 10px 30px rgba(15,23,42,.05); +} + +.empty-search-card h3 { + font-size: 2rem; + font-weight: 800; + color: #1e293b; +} + +.empty-search-card p { + color: #64748b; + font-size: 1.1rem; +} + + + +@media (max-width: 1400px) { + + .foro-card-image, + .carousel-foro-image { + height: 430px; + } +} + + +@media (max-width: 992px) { + + .feed-page { + flex-direction: column; + } + + .feed-sidebar { + width: 100%; + min-width: 100%; + } + + .foro-card-image, + .carousel-foro-image { + height: 320px; + } + + .feed-foro-card h3 { + font-size: 2rem; + } + + .carousel-caption h3 { + font-size: 2rem; + } +} + + + +@media (max-width: 768px) { + + .feed-page { + padding: 15px; + gap: 20px; + } + + .profile-avatar { + width: 110px; + height: 110px; + } + + .forum-image { + width: 60px; + height: 60px; + } + + .foro-card-image, + .carousel-foro-image { + height: 250px; + } + + .feed-foro-card { + padding: 20px; + } + + .feed-foro-card h3 { + font-size: 1.8rem; + } + + .feed-foro-card p { + font-size: 1rem; + } + + .carousel-caption { + padding: 12px; + margin-bottom: 10px; + } + + .carousel-caption h3 { + font-size: 1.4rem; + } + + .carousel-caption p { + font-size: .9rem; + } +} \ No newline at end of file diff --git a/src/css/profile.css b/src/css/profile.css new file mode 100644 index 0000000000..f8297958d1 --- /dev/null +++ b/src/css/profile.css @@ -0,0 +1,589 @@ + +:root { + --pf-primary: #2563eb; + --pf-primary-dark: #1d4ed8; + --pf-bg-soft: #f8fafc; + --pf-border: #e2e8f0; + --pf-text: #0f172a; + --pf-text-muted: #64748b; + --pf-danger: #ef4444; + --pf-danger-dark: #dc2626; + --pf-success: #16a34a; + --pf-success-dark: #15803d; + --pf-radius: 18px; + + --pf-shadow: + 0 4px 10px rgba(15, 23, 42, 0.04), + 0 2px 4px rgba(15, 23, 42, 0.03); + + --pf-shadow-hover: + 0 12px 30px rgba(15, 23, 42, 0.08); +} + + + +.profile-page { + display: grid; + grid-template-columns: 360px 1fr; + gap: 28px; + + max-width: 1400px; + margin: 30px auto; + + padding: 0 24px; + + align-items: start; +} + +.profile-left { + display: flex; + flex-direction: column; + gap: 24px; +} + +.profile-right { + display: flex; + flex-direction: column; + gap: 24px; +} + + +.profile-avatar-card, +.profile-follow-card, +.profile-data-card, +.profile-tags-card { + background: white; + + border: 1px solid var(--pf-border); + + border-radius: var(--pf-radius); + + padding: 24px; + + box-shadow: var(--pf-shadow); + + transition: .25s ease; +} + +.profile-avatar-card:hover, +.profile-follow-card:hover, +.profile-data-card:hover, +.profile-tags-card:hover { + transform: translateY(-2px); + + box-shadow: var(--pf-shadow-hover); +} + + +.profile-avatar-card { + display: flex; + flex-direction: column; + align-items: center; + position: relative; +} + +.profile-avatar { + width: 165px; + height: 165px; + + object-fit: cover; + + border-radius: 50%; + + border: 5px solid white; + + box-shadow: + 0 10px 30px rgba(37,99,235,.15); +} + +.profile-avatar-card h3 { + margin-top: 20px; + + font-size: 1.5rem; + font-weight: 700; + + color: var(--pf-text); +} + +.edit-btn { + position: absolute; + + top: 140px; + right: 100px; + + width: 42px; + height: 42px; + + border: none; + border-radius: 50%; + + background: var(--pf-primary); + + color: white; + + display: flex; + justify-content: center; + align-items: center; + + cursor: pointer; + + transition: .2s; +} + +.edit-btn:hover { + background: var(--pf-primary-dark); + transform: scale(1.05); +} + + + +.card-header-profile { + display: flex; + justify-content: space-between; + align-items: center; + + margin-bottom: 18px; +} + +.card-header-profile h3, +.card-header-profile h4 { + margin: 0; + + font-weight: 700; + color: var(--pf-text); +} + +.edit-btn-small { + width: 36px; + height: 36px; + + border: 1px solid var(--pf-border); + + border-radius: 10px; + + background: var(--pf-bg-soft); + + color: var(--pf-text-muted); + + display: flex; + justify-content: center; + align-items: center; + + cursor: pointer; + + transition: .2s; +} + +.edit-btn-small:hover { + background: var(--pf-primary); + color: white; + border-color: var(--pf-primary); +} + + + +.follow-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.follow-item { + display: flex; + justify-content: space-between; + align-items: center; + + gap: 12px; + + padding: 14px 16px; + + border-radius: 14px; + + background: var(--pf-bg-soft); + + border: 1px solid var(--pf-border); + + transition: .2s ease; +} + +.follow-item:hover { + transform: translateY(-2px); + + box-shadow: + 0 8px 18px rgba(15,23,42,.06); + + border-color: + rgba(37,99,235,.25); +} + +.follow-content { + display: flex; + align-items: center; + gap: 14px; + + flex: 1; + + min-width: 0; +} + +.follow-image { + width: 55px; + height: 55px; + + object-fit: cover; + + border-radius: 14px; + + flex-shrink: 0; +} + +.follow-title { + font-size: .95rem; + font-weight: 700; + + color: var(--pf-text); + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.unfollow-btn { + border: 1px solid #fecaca; + + background: #fff5f5; + + color: var(--pf-danger); + + padding: 8px 14px; + + border-radius: 10px; + + font-size: .8rem; + font-weight: 700; + + cursor: pointer; + + transition: .2s; +} + +.unfollow-btn:hover { + background: var(--pf-danger); + color: white; +} + + + +.profile-info { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 18px; +} + +.info-item { + display: flex; + flex-direction: column; + gap: 6px; + + padding: 16px; + + background: white; + + border: 1px solid var(--pf-border); + + border-radius: 14px; +} + +.info-item span { + font-size: .75rem; + font-weight: 700; + + color: var(--pf-text-muted); + + text-transform: uppercase; + letter-spacing: .05em; +} + +.info-item p { + margin: 0; + + font-size: .95rem; + font-weight: 600; + + color: var(--pf-text); +} + +.info-item textarea { + min-height: 90px; +} + + + +.info-item:last-child { + grid-column: 1 / -1; +} + + + +.profile-tags-card { + background: white; + border-radius: 22px; + padding: 32px; + border: 1px solid #e2e8f0; + + box-shadow: + 0 10px 30px rgba(15,23,42,.04); +} + +.profile-tags-card h3 { + font-size: 2rem; + font-weight: 800; + color: #0f172a; + margin-bottom: 8px; +} + +.tags-subtitle { + color: #64748b; + font-size: .95rem; + margin-bottom: 28px; +} + + + +.tag-container { + display: flex; + flex-wrap: wrap; + gap: 14px; + + margin-bottom: 28px; +} + +.tag-item-profile { + display: flex; + align-items: center; + gap: 10px; + + padding: 12px 20px; + + border-radius: 999px; + + background: + linear-gradient( + 135deg, + #2563eb, + #3b82f6 + ); + + color: white; + + font-size: .95rem; + font-weight: 700; + + box-shadow: + 0 8px 20px rgba(37,99,235,.25); + + transition: .25s ease; +} + +.tag-item-profile:hover { + transform: translateY(-2px); +} + +.remove-tag-btn { + width: 24px; + height: 24px; + + border: none; + border-radius: 50%; + + background: rgba(255,255,255,.2); + + color: white; + + display: flex; + justify-content: center; + align-items: center; + + cursor: pointer; + + transition: .2s ease; +} + +.remove-tag-btn:hover { + background: rgba(255,255,255,.35); + transform: scale(1.1); +} + + + +.tags-divider { + margin: 30px 0; + border: none; + border-top: 1px solid #e2e8f0; +} + + + +.tags-selector-title { + font-size: 1.35rem; + font-weight: 700; + color: #0f172a; + + margin-bottom: 22px; +} + +.tag-selector { + display: flex; + flex-wrap: wrap; + gap: 16px; +} + +.tag-option { + display: flex; + align-items: center; + gap: 12px; + + padding: 14px 20px; + + border-radius: 16px; + + border: 1px solid #e2e8f0; + + background: white; + + cursor: pointer; + + transition: .25s ease; + + box-shadow: + 0 4px 12px rgba(15,23,42,.04); +} + +.tag-option:hover { + transform: translateY(-2px); + + border-color: #2563eb; + + box-shadow: + 0 8px 20px rgba(15,23,42,.08); +} + +.tag-option.selected { + border-color: #2563eb; + background: #eff6ff; +} + +.tag-icon { + width: 38px; + height: 38px; + + border-radius: 12px; + + display: flex; + justify-content: center; + align-items: center; + + background: #f8fafc; + + color: #2563eb; + + transition: .25s ease; +} + +.tag-option.selected .tag-icon { + background: #2563eb; + color: white; +} + +.tag-title { + font-size: .95rem; + font-weight: 700; + color: #0f172a; +} + + + +.save-tags-container { + display: flex; + justify-content: flex-end; + + margin-top: 30px; +} + +.save-tags-btn { + padding: 14px 32px; + + border: none; + border-radius: 14px; + + background: + linear-gradient( + 135deg, + #16a34a, + #22c55e + ); + + color: white; + + font-size: 1rem; + font-weight: 700; + + cursor: pointer; + + box-shadow: + 0 10px 24px rgba(22,163,74,.25); + + transition: .25s ease; +} + +.save-tags-btn:hover { + transform: translateY(-2px); + + box-shadow: + 0 14px 30px rgba(22,163,74,.35); +} + + +.text-muted { + color: var(--pf-text-muted); +} + +.mt-2 { + margin-top: 8px; +} + +.mt-3 { + margin-top: 16px; +} + +.mb-3 { + margin-bottom: 16px; +} + + + +@media (max-width: 1100px) { + + .profile-page { + grid-template-columns: 1fr; + max-width: 750px; + } + +} + +@media (max-width: 768px) { + + .profile-info { + grid-template-columns: 1fr; + } + + .follow-item { + flex-direction: column; + align-items: flex-start; + } + + .unfollow-btn { + width: 100%; + } + +} \ No newline at end of file diff --git a/src/front/assets/img/logomin.png b/src/front/assets/img/logomin.png new file mode 100644 index 0000000000..51df5caf7e Binary files /dev/null and b/src/front/assets/img/logomin.png differ diff --git a/src/front/assets/img/userImg.png b/src/front/assets/img/userImg.png new file mode 100644 index 0000000000..998d04f69e Binary files /dev/null and b/src/front/assets/img/userImg.png differ diff --git a/src/front/components/BodyTag.jsx b/src/front/components/BodyTag.jsx new file mode 100644 index 0000000000..5bd7682a3e --- /dev/null +++ b/src/front/components/BodyTag.jsx @@ -0,0 +1,25 @@ + + + +export const BodyTag = ({tag, onSelectedTag, isSelected}) =>{ + const onClick = (e)=> { + e.preventDefault() + onSelectedTag(tag) + } + return( +
+ +
{tag.title}
+
+ ) +} + +export const BodyTagSimply = ({tag}) =>{ + return( +
+
{tag.tag?.title}
+
+ ) +} \ No newline at end of file diff --git a/src/front/components/CardForo.jsx b/src/front/components/CardForo.jsx new file mode 100644 index 0000000000..e5ca869a6a --- /dev/null +++ b/src/front/components/CardForo.jsx @@ -0,0 +1,41 @@ +import { BodyTagSimply } from "../components/BodyTag.jsx"; +import logo from "../assets/img/logomin.png"; + + +export const CardForoSimply = ({ foro }) => { + + return ( +
+ +
+ {foro.title} +
+ +
+
+

{foro.title}

+

{foro.description}

+
+ +
+ +
+ {foro.tags && foro.tags.length > 0 ? ( + foro.tags.map((tag) => ( + + )) + ) : ( + Sin etiquetas + )} +
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/front/components/Navbar.jsx b/src/front/components/Navbar.jsx index 30d43a2636..1657ccaddd 100644 --- a/src/front/components/Navbar.jsx +++ b/src/front/components/Navbar.jsx @@ -1,19 +1,119 @@ import { Link } from "react-router-dom"; +import logo from "../assets/img/logomin.png"; +import { useNavigate } from "react-router-dom"; +import { useState } from "react"; +import "../../css/Navbar.css"; export const Navbar = () => { - return ( - - ); + const navigate = useNavigate(); + const [search, setSearch] = useState(""); + + const user = JSON.parse(localStorage.getItem("user")); + + const handleSearch = (e) => { + if (e.key === "Enter") { + navigate(`/feed?search=${search}`); + } + }; + + return ( + + + + ); }; \ No newline at end of file diff --git a/src/front/components/Post/Createpost.jsx b/src/front/components/Post/Createpost.jsx new file mode 100644 index 0000000000..b2f298f86c --- /dev/null +++ b/src/front/components/Post/Createpost.jsx @@ -0,0 +1,136 @@ +import { useCreatePost } from "../../hooks/Hooks_post/useCreatePost"; +import "../../../css/Post.css"; + +export const CreatePost = ({ forumId }) => { + + const { + title, + setTitle, + content, + setContent, + setImg, + preview, + setPreview, + success, + handleSubmit + } = useCreatePost(forumId); + + return ( +
+ +
+ +
+ +
+ +
+ +

+ CREACIÓN POST +

+ + {success && ( +
+ ✅ ¡Post creado exitosamente! +
+ )} + + + + setTitle(e.target.value) + } + /> + + { + + const file = + e.target.files[0]; + + setImg(file); + + if (file) { + setPreview( + URL.createObjectURL(file) + ); + } + }} + /> + + { + preview && ( +
+ + preview + +
+ ) + } + + +
+
+
+ + +
+ +
+ {store.tags && store.tags.map((tag) => { + const handleSelected = selectedTag.includes(tag.id); + return (); + })} +
+
+ +
+
+ + Back Home + +
+ +
+ +
+
+ + + +
+ ); +}; \ No newline at end of file diff --git a/src/front/pages/DataProfile.jsx b/src/front/pages/DataProfile.jsx new file mode 100644 index 0000000000..90cc49a104 --- /dev/null +++ b/src/front/pages/DataProfile.jsx @@ -0,0 +1,159 @@ +import React from "react"; +import { useEffect } from "react"; +import { useProfile } from "../hooks/useProfile"; +import { useTag } from "../hooks/useTag"; +import { BodyTag } from "../components/BodyTag.jsx"; +import useGlobalReducer from "../hooks/useGlobalReducer.jsx"; + + +export const DataProfile = () => { + + const { store, dispatch } = useGlobalReducer() + + const { getDataTag, onSelectedTag, handleSave, selectedTag } = useTag(); + + useEffect(() => { + getDataTag(); + }, []); + + const { + firstName, setFirstName, + lastName, setLastName, + birthDate, setBirthDate, + profileImg, setProfileImg, + description, setDescription, + error, + loading, + success, + handleUpdateProfile + } = useProfile(); + + const saveProfile = async (e) => { + e.preventDefault() + try { + const res = await handleUpdateProfile(); + const resTag = await handleSave(); + } catch (err) { + console.error("Error to save profile", err); + } + } + + return ( +
+
+
+
+

Completa tu Perfil

+ + {error &&
{error}
} + {success &&
¡Perfil actualizado con éxito!
} + +
+
+
+
+ + setFirstName(e.target.value)} + /> +
+
+ + setLastName(e.target.value)} + /> +
+
+ +
+ + setBirthDate(e.target.value)} + /> +
+ +
+ {profileImg ? ( + Foro preview { + e.target.src = "https://placehold.co/65?text=Foro"; + }} + />) : (
+ 💬 +
)} + + setProfileImg(e.target.files[0])} + /> +
+ +
+ + +
+ +
+ +
+ {store.tags && store.tags.map((tag) => { + const handleSelected = selectedTag.includes(tag.id); + return (); + })} +
+
+ +
+ +
+
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/front/pages/Feed.jsx b/src/front/pages/Feed.jsx new file mode 100644 index 0000000000..c4c65a7f2d --- /dev/null +++ b/src/front/pages/Feed.jsx @@ -0,0 +1,302 @@ +import { useFeed } from "../hooks/useFeed"; +import "../../css/feed.css"; +import { FaUserCircle } from "react-icons/fa"; +import { Link } from "react-router-dom"; +import { followForo } from "../../services/followService"; + +export const Feed = () => { + + const user = JSON.parse(localStorage.getItem("user")); + + const handleFollow = async (foroId) => { + try { + const data = await followForo(foroId); + console.log(data); + + await loadFollowForos(); + + + } catch (error) { + console.log(error); + + } + } + + const { + loading, + search, + setSearch, + myForos, + followForos, + userTags, + filteredForos, + loadFollowForos, + allTags, + activeTag, + setActiveTag + } = useFeed(); + + if (loading) { + return ( +
+

Cargando...

+
+ ); + } + + return ( +
+ + + + + + +
+ +
+
+ + {filteredForos.slice(0, 3).map((foro, index) => ( + +
+ + {foro.title} + +
+

{foro.title}

+

{foro.description}

+
+ +
+ + ))} + +
+ + + + + +
+ + + { + filteredForos.length > 0 ? ( + + filteredForos.map((foro) => ( + +
+ + {foro.title} + +

{foro.title}

+ +

{foro.description}

+ + + +
+ + )) + + ) : ( + +
+ +

+ 🔍 No encontramos resultados +

+ +

+ No existe ningún foro que coincida con tu búsqueda. +

+ +
+ + ) + } + +
+ + +
+ ); +}; \ No newline at end of file diff --git a/src/front/pages/ForoPage.jsx b/src/front/pages/ForoPage.jsx new file mode 100644 index 0000000000..3cf08607f0 --- /dev/null +++ b/src/front/pages/ForoPage.jsx @@ -0,0 +1,114 @@ +import React from "react"; +import { Link, useParams } from "react-router-dom"; +import { useEffect, useState } from "react"; +import { PostCard } from "../components/Post/PostCard" +import { BodyTagSimply } from "../components/BodyTag.jsx"; +import { useForoById } from "../hooks/Hooks_foro/useForo.js"; +import logo from "../assets/img/logomin.png"; + + +export const ForoPage = () => { + const {foro_id} = useParams(); + const{ + getForoForId, + foro, + loading + }=useForoById(foro_id) + + if (loading) { + return ( +
+

Cargando...

+
+ ); + } + + if (!foro) { + return ( +
+

Foro no encontrado.

+
+ ); + } + + + return ( +
+
+
+ Foto del foro +
+
+

+ {foro.title} +

+
+
+ + + +
+
+
+
+
Acerca de este foro:
+

{foro.description}

+
+ {foro.tags && foro.tags.length > 0 ? ( + foro.tags.map((tag) => ( + + )) + ) : ( + Sin etiquetas + )} +
+
+
+ +
+ +
+
+ {foro.posts && foro.posts.length > 0 ? ( + foro.posts.map((post)=>( + + + + ))):(Se el primero en comentar)} +
+
+ + Back home + + + Publicar + +
+
+ ) + +} \ No newline at end of file diff --git a/src/front/pages/Home.jsx b/src/front/pages/Home.jsx deleted file mode 100644 index 341ed21768..0000000000 --- a/src/front/pages/Home.jsx +++ /dev/null @@ -1,52 +0,0 @@ -import React, { useEffect } from "react" -import rigoImageUrl from "../assets/img/rigo-baby.jpg"; -import useGlobalReducer from "../hooks/useGlobalReducer.jsx"; - -export const Home = () => { - - const { store, dispatch } = useGlobalReducer() - - const loadMessage = async () => { - try { - const backendUrl = import.meta.env.VITE_BACKEND_URL - - if (!backendUrl) throw new Error("VITE_BACKEND_URL is not defined in .env file") - - const response = await fetch(backendUrl + "/api/hello") - const data = await response.json() - - if (response.ok) dispatch({ type: "set_hello", payload: data.message }) - - return data - - } catch (error) { - if (error.message) throw new Error( - `Could not fetch the message from the backend. - Please check if the backend is running and the backend port is public.` - ); - } - - } - - useEffect(() => { - loadMessage() - }, []) - - return ( -
-

Hello Rigo!!

-

- Rigo Baby -

-
- {store.message ? ( - {store.message} - ) : ( - - Loading message from the backend (make sure your python 🐍 backend is running)... - - )} -
-
- ); -}; \ No newline at end of file diff --git a/src/front/pages/Landing.jsx b/src/front/pages/Landing.jsx new file mode 100644 index 0000000000..30803401df --- /dev/null +++ b/src/front/pages/Landing.jsx @@ -0,0 +1,137 @@ +import { Link } from "react-router-dom"; +import { useLogin } from "../hooks/useLogin"; +import { + FaUsers, + FaComments, + FaGlobe, + FaLightbulb +} from "react-icons/fa"; + +import "../../css/Landing.css" +import { FaEye, FaEyeSlash } from "react-icons/fa"; + +export const Landing = () => { + + const { + email, + setEmail, + password, + setPassword, + showPassword, + setShowPassword, + error, + handleLogin + } = useLogin(); + + return ( +
+ +
+ +
+ {/* Parte Izquierda */} + +
+ +

+ Bienvenido a MindFed +

+ +

+ La comunidad donde puedes compartir ideas, + debatir y conectar con personas que comparten + tus mismos intereses. +

+ +
+ +
+ + Comunidades activas +
+ +
+ + Debates en tiempo real +
+ +
+ + Películas, deportes, videojuegos y más +
+ +
+ + Comparte tus ideas +
+ +
+ +
+ + {/* Parte Derecha */} + +
+ +
+ +

+ Iniciar Sesión +

+ + setEmail(e.target.value)} + /> + +
+ setPassword(e.target.value)} + /> + + setShowPassword(!showPassword)} + > + {showPassword ? : } + +
+ + { + error && ( +
+ {error} +
+ ) + } + + + + + Registrarse + + +
+ +
+ +
+ +
+ +
+ ); +}; \ No newline at end of file diff --git a/src/front/pages/PostView.jsx b/src/front/pages/PostView.jsx new file mode 100644 index 0000000000..dc80484e26 --- /dev/null +++ b/src/front/pages/PostView.jsx @@ -0,0 +1,148 @@ +import { useRef } from "react"; +import { useParams } from "react-router-dom"; +import { FaUserCircle, FaEdit, FaTimes } from "react-icons/fa"; +import { usePostView } from "../hooks/Hooks_post/usePostView"; + +export const PostView = () => { + const { post_id } = useParams(); + const { + post, + comments, + loading, + commentText, + setCommentText, + handleComment, + handleEditComment, + editingCommentId, + cancelEdit, + currentUserId + } = usePostView(post_id); + const inputRef = useRef(null); + + const handleKeyDown = (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleComment(); + } + }; + + if (loading) { + return ( +
+

Cargando...

+
+ ); + } + + if (!post) { + return ( +
+

Post no encontrado.

+
+ ); + } + + const hasImg = !!post.img; + + return ( +
+ +
+ +
+ +

{post.title}

+ +
+ + {hasImg && ( +
+ {post.title} +
+ )} + +
+

{post.content}

+
+ +
+ +
+
Comentarios
+
+ {comments.length === 0 ? ( +

Sé el primero en comentar.

+ ) : ( + comments.map((comment) => ( +
+
+ + + {comment.username || `Usuario ${comment.user_id}`} + + {Number(comment.user_id) === Number(currentUserId) && ( + + )} +
+

{comment.content}

+
+ )) + )} +
+ + {editingCommentId && ( +
+ Editando comentario + +
+ )} + +
+ setCommentText(e.target.value)} + onKeyDown={handleKeyDown} + /> + +
+
+ +
+
+ +
+ ); +}; \ No newline at end of file diff --git a/src/front/pages/Profile.jsx b/src/front/pages/Profile.jsx new file mode 100644 index 0000000000..b32b4c4aff --- /dev/null +++ b/src/front/pages/Profile.jsx @@ -0,0 +1,492 @@ +import "../../css/profile.css"; +import { FaEdit } from "react-icons/fa"; +import { useProfileView } from "../hooks/useProfileView"; +import { useEffect } from "react"; +import { useTag } from "../hooks/useTag"; +import { BodyTag } from "../components/BodyTag"; +import { deleteTagFromUser } from "../../services/tagService"; +import useGlobalReducer from "../hooks/useGlobalReducer"; + + +export const Profile = () => { + + const { + profile, + setProfile, + handleProfileChange, + followForos, + myForos, + userTags, + setUserTags, + loading, + editingSection, + setEditingSection, + handleUnfollow, + selectedImage, + setSelectedImage, + handleUpdateAvatar, + handleUpdateProfile + } = useProfileView(); + + const { store } = useGlobalReducer(); + + + const { + getDataTag, + onSelectedTag, + handleSave, + selectedTag, + setSelectedTag, + } = useTag(); + + useEffect(() => { + getDataTag(); + }, []); + + if (loading) { + return ( +
+

Cargando perfil...

+
+ ); + } + + return ( +
+ + {/* IZQUIERDA */} +
+ +
+ + {profile?.first_name} + + + + { + editingSection === "avatar" && ( +
+ + + setSelectedImage( + e.target.files[0] + ) + } + /> + + + +
+ ) + } + +

+ {profile?.first_name} {profile?.last_name} +

+ +
+ +
+ +
+

Foros Seguidos

+ + +
+ +
+ + {followForos.length > 0 ? ( + followForos.map((foro) => ( +
+ +
+ {foro.title} + + + {foro.title} + +
+ + {editingSection === "follow" && ( + + )} + +
+ )) + ) : ( +

+ No sigues ningún foro todavía. +

+ )} + +
+ +
+ +
+ +
+

Mis foros creados

+
+ +
+ + { + myForos?.length > 0 ? ( + myForos.map((foro) => ( +
+ +
+ {foro.title} + + + {foro.title} + +
+ +
+ )) + ) : ( +

+ Todavía no has creado ningún foro. +

+ ) + } + +
+ +
+ +
+ + + {/* DERECHA */} +
+ +
+ +
+ +

Información del usuario

+ + + +
+ +
+ Nombre + + { + editingSection === "info" ? ( + + setProfile({ + ...profile, + first_name: e.target.value + }) + } + /> + ) : ( +

{profile?.first_name}

+ ) + } +
+ +
+ Apellido + + { + editingSection === "info" ? ( + + setProfile({ + ...profile, + last_name: e.target.value + }) + } + /> + ) : ( +

{profile?.last_name}

+ ) + } +
+ +
+ Usuario + { + editingSection === "info" ? ( + + setProfile({ + ...profile, + username: e.target.value + }) + } + /> + ) : ( +

@{profile?.username}

+ ) + } +
+ +
+ Email + { + editingSection === "info" ? ( + + ) : ( +

{profile?.email}

+ ) + } +
+ +
+ Fecha de nacimiento + { + editingSection === "info" ? ( + + setProfile({ + ...profile, + date_birth: e.target.value + }) + } + /> + ) : ( +

{profile?.date_birth || "No especificada"}

+ ) + } +
+ +
+ Descripción + { + editingSection === "info" ? ( +